| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { FolioRef, User } from "@g1t/contracts"; |
| 5 | |
| 6 | import { AGENT_TOOL_NAMES, RESERVED_SKILL_NAMES, SKILLS_PER_AGENT_MAX } from "../../../packages/contracts/src/skill-format.ts"; |
| 7 | import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILL_IDS, skillTools, skillsOn } from "../../../packages/contracts/src/skills.ts"; |
| 8 | import { Audience, type AudienceInfo } from "./audience.ts"; |
| 9 | import { applyChanges } from "./definition.ts"; |
| 10 | import { systemPrompt } from "./prompt.ts"; |
| 11 | import { type AttachedRow, shelfFrom, skillsSection, teamSlugs } from "./skills.ts"; |
| 12 | import { TEMPLATE_IDS } from "./templates.ts"; |
| 13 | import { type FoliosPorts, type ToolPorts, TOOL_NAMES, ToolBox, docBody } from "./tools.ts"; |
| 14 | |
| 15 | test("the foundational skills name only tools agents have, and say what's coming", () => { |
| 16 | assert.deepEqual(FOUNDATIONAL_SKILL_IDS, ["documents", "research", "data", "code", "communication", "files"]); |
| 17 | for (const skill of FOUNDATIONAL_SKILLS) { |
| 18 | assert.equal(skill.source, "foundational"); |
| 19 | assert.ok(skill.instructions.length > 100, `${skill.id} has a playbook`); |
| 20 | for (const ability of skill.abilities) { |
| 21 | for (const tool of ability.tools) assert.ok(TOOL_NAMES.has(tool), `${skill.id}/${ability.id} names ${tool}, which agents have`); |
| 22 | if (ability.status === "coming") assert.deepEqual(ability.tools, [], `${skill.id}/${ability.id} is coming, so it uses no tool yet`); |
| 23 | assert.ok(ability.note, `${skill.id}/${ability.id} says how or what's missing`); |
| 24 | } |
| 25 | // Every tool a playbook tells the agent to call is one it can have. |
| 26 | for (const [, tool] of skill.instructions.matchAll(/\b([a-z]+_[a-z_]+)\b/g)) assert.ok(TOOL_NAMES.has(tool!), `${skill.id}'s playbook names ${tool}`); |
| 27 | } |
| 28 | assert.ok(skillTools(FOUNDATIONAL_SKILLS[0]!).includes("make_file")); |
| 29 | const coming = FOUNDATIONAL_SKILLS.flatMap((s) => s.abilities.filter((a) => a.status === "coming").map((a) => a.id)); |
| 30 | for (const id of ["slides", "search", "browse", "sql", "run", "schedule", "ocr", "images"]) assert.ok(coming.includes(id), `${id} is marked coming`); |
| 31 | }); |
| 32 | |
| 33 | test("the tools a skill may name are exactly the tools agents have", () => { |
| 34 | assert.deepEqual([...AGENT_TOOL_NAMES].sort(), [...TOOL_NAMES].sort()); |
| 35 | assert.deepEqual(RESERVED_SKILL_NAMES, FOUNDATIONAL_SKILL_IDS); |
| 36 | }); |
| 37 | |
| 38 | const library = (over: Partial<AttachedRow> = {}): AttachedRow => ({ |
| 39 | skill_id: "skl_01k7a0b1c2d3e4f5g6h7j8k9mn", |
| 40 | name: "release-notes", |
| 41 | description: "Use when someone asks for release notes.", |
| 42 | version: 3, |
| 43 | tools: '["recent_activity","create_artifact","teleport_tool"]', |
| 44 | requires_computer: 0, |
| 45 | scope: "workspace", |
| 46 | attached_at: "2026-10-01T00:00:00Z", |
| 47 | ...over, |
| 48 | }); |
| 49 | |
| 50 | test("the prompt names each skill and when to use it; the playbooks stay out until read", () => { |
| 51 | const all = [...TOOL_NAMES]; |
| 52 | const { skills } = shelfFrom([], [library()]); |
| 53 | const section = skillsSection(skills, all)!; |
| 54 | for (const skill of FOUNDATIONAL_SKILLS) assert.match(section, new RegExp(`^- ${skill.id}: ${skill.when.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m")); |
| 55 | assert.match(section, /^- release-notes: Use when someone asks for release notes\.$/m); |
| 56 | assert.match(section, /call use_skill with its name before you start/); |
| 57 | assert.match(section, /never add one/); |
| 58 | for (const skill of FOUNDATIONAL_SKILLS) assert.ok(!section.includes(skill.instructions), `${skill.id}'s playbook isn't in the prompt`); |
| 59 | // Without use_skill (no tools at all), nothing is listed: no skill could be followed. |
| 60 | assert.equal(skillsSection(skills, all.filter((t) => t !== "use_skill")), null); |
| 61 | // Off: gone from the list, the rest stays; all off and none attached, no section. |
| 62 | const someOff = skillsSection(shelfFrom(["communication", "files", "skl_01k7a0b1c2d3e4f5g6h7j8k9mn"], [library()]).skills, all)!; |
| 63 | assert.doesNotMatch(someOff, /^- communication:/m); |
| 64 | assert.doesNotMatch(someOff, /^- files:/m); |
| 65 | assert.doesNotMatch(someOff, /release-notes/); |
| 66 | assert.match(someOff, /^- documents:/m); |
| 67 | assert.equal(skillsSection(shelfFrom(FOUNDATIONAL_SKILL_IDS, []).skills, all), null); |
| 68 | assert.equal(skillsOn(["data"]).length, 5); |
| 69 | // A skill that needs a computer is marked. |
| 70 | assert.match(skillsSection(shelfFrom([], [library({ requires_computer: 1 })]).skills, all)!, /release-notes: .* Needs a computer of its own \(not available yet\)\./); |
| 71 | const prompt = systemPrompt({ |
| 72 | agent: { id: "agt_1", handle: "ship", display_name: "Ship", role: "Release manager", instructions: "Ship.", personality_preset: "crisp", personality: "" }, |
| 73 | workspace: "acme", |
| 74 | channel: { kind: "dm", name: null }, |
| 75 | asker: { name: "dana", display_name: null, access: null }, |
| 76 | today: new Date("2026-10-10T00:00:00Z"), |
| 77 | skills: section, |
| 78 | }); |
| 79 | assert.ok(prompt.indexOf("## Your skills") > prompt.indexOf("## How to answer"), "skills come after the rules"); |
| 80 | }); |
| 81 | |
| 82 | test("owners turn skills off by id; unknown ids are refused, and the list reads in the skills' order", () => { |
| 83 | const made = applyChanges(null, { handle: "ship", display_name: "Ship", role: "r", instructions: "i" }, TEMPLATE_IDS); |
| 84 | assert.ok(made.ok); |
| 85 | if (!made.ok) return; |
| 86 | assert.deepEqual(made.value.skills_off, []); |
| 87 | const off = applyChanges(made.value, { skills_off: ["files", " data ", "files"] }, TEMPLATE_IDS); |
| 88 | assert.ok(off.ok); |
| 89 | assert.deepEqual(off.ok && off.value.skills_off, ["data", "files"]); |
| 90 | // Library skills by their ids, after the foundational ones. |
| 91 | const lib = applyChanges(made.value, { skills_off: ["skl_01k7a0b1c2d3e4f5g6h7j8k9mn", "files"] }, TEMPLATE_IDS); |
| 92 | assert.deepEqual(lib.ok && lib.value.skills_off, ["files", "skl_01k7a0b1c2d3e4f5g6h7j8k9mn"]); |
| 93 | const bad = applyChanges(made.value, { skills_off: ["teleport"] }, TEMPLATE_IDS); |
| 94 | assert.equal(bad.ok, false); |
| 95 | assert.match(!bad.ok ? bad.message : "", /no skill called teleport/); |
| 96 | assert.equal(applyChanges(made.value, { skills_off: ["skl_short"] }, TEMPLATE_IDS).ok, false); |
| 97 | assert.equal(applyChanges(made.value, { skills_off: "data" as unknown as string[] }, TEMPLATE_IDS).ok, false); |
| 98 | }); |
| 99 | |
| 100 | test("a library skill reaches an agent once, at the version where it is attached closest, and at most 100 do", () => { |
| 101 | const id = (n: number) => `skl_${String(n).padStart(26, "0")}`; |
| 102 | const { skills } = shelfFrom( |
| 103 | [], |
| 104 | [ |
| 105 | library({ scope: "workspace", version: 1 }), |
| 106 | library({ scope: "agent", version: 3, attached_at: "2026-10-05T00:00:00Z" }), |
| 107 | library({ scope: "team", version: 2 }), |
| 108 | ], |
| 109 | ); |
| 110 | const lib = skills.filter((s) => s.kind === "library"); |
| 111 | assert.equal(lib.length, 1); |
| 112 | assert.equal(lib[0]!.kind === "library" && lib[0]!.version, 3, "attached to the agent itself wins"); |
| 113 | assert.equal(lib[0]!.kind === "library" && lib[0]!.via, "agent"); |
| 114 | const many = Array.from({ length: SKILLS_PER_AGENT_MAX + 5 }, (_, n) => library({ skill_id: id(n), name: `s-${String(n).padStart(3, "0")}`, attached_at: `2026-10-01T00:00:${String(n % 60).padStart(2, "0")}Z` })); |
| 115 | const capped = shelfFrom([], many); |
| 116 | assert.equal(capped.skills.filter((s) => s.kind === "library").length, SKILLS_PER_AGENT_MAX); |
| 117 | assert.equal(capped.over, 5); |
| 118 | assert.deepEqual(teamSlugs({ teams: [{ slug: "qa" }, { slug: "web" }, { slug: "qa" }] as never, agents: [], presence: [] }), ["qa", "web"]); |
| 119 | assert.deepEqual(teamSlugs(null), [], "none when its teams couldn't be read"); |
| 120 | }); |
| 121 | |
| 122 | test("use_skill reads a skill the agent has: the playbook with what isn't here, files, and scripts never run", async () => { |
| 123 | const { folios } = fakeFolios(); |
| 124 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, folios); |
| 125 | assert.ok(!tools.definitions().some((t) => t.name === "use_skill"), "no skills, no use_skill"); |
| 126 | const stored = { |
| 127 | skill_md: "---\nname: release-notes\ndescription: Use when someone asks for release notes.\ntools: [recent_activity, create_artifact]\n---\n\n# Release notes\n\nGroup changes by area.\n", |
| 128 | files: [ |
| 129 | { path: "resources/template.md", content: "## Added\n\n## Fixed\n", encoding: "utf8" as const }, |
| 130 | { path: "scripts/collect.py", content: "print('hi')\n", encoding: "utf8" as const }, |
| 131 | { path: "resources/logo.png", content: "iVBORw0KGgo=", encoding: "base64" as const }, |
| 132 | ], |
| 133 | }; |
| 134 | const reads: string[] = []; |
| 135 | const shelf = shelfFrom(["files"], [library({ tools: '["recent_activity","create_artifact"]', requires_computer: 1 })]).skills; |
| 136 | tools.useShelf(shelf, async (skillId, version) => { |
| 137 | reads.push(`${skillId}@${version}`); |
| 138 | return stored; |
| 139 | }); |
| 140 | assert.ok(tools.definitions().some((t) => t.name === "use_skill")); |
| 141 | const documents = await tools.run("use_skill", { name: "documents" }); |
| 142 | assert.equal(documents.outcome, "allowed"); |
| 143 | assert.match(documents.text, /When someone asks for a document, give them the document/); |
| 144 | assert.match(documents.text, /Not yet in g1t: slide decks\./); |
| 145 | assert.doesNotMatch(documents.text, /Not available in this conversation/, "every tool it uses is offered here"); |
| 146 | const off = await tools.run("use_skill", { name: "files" }); |
| 147 | assert.equal(off.outcome, "refused", "a skill that is off isn't the agent's"); |
| 148 | const notes = await tools.run("use_skill", { name: "release-notes" }); |
| 149 | assert.equal(notes.outcome, "allowed"); |
| 150 | assert.deepEqual(reads, ["skl_01k7a0b1c2d3e4f5g6h7j8k9mn@3"], "the pinned version is read"); |
| 151 | assert.match(notes.text, /^# release-notes \(your workspace's skill, version 3\)\n\n# Release notes\n\nGroup changes by area\./); |
| 152 | assert.doesNotMatch(notes.text, /^name:/m, "front-matter isn't repeated"); |
| 153 | assert.match(notes.text, /needs a computer of its own, which agents don't have yet: its scripts can't run/); |
| 154 | assert.match(notes.text, /Its files, which you can read with use_skill and file: resources\/template\.md \(19 B\), scripts\/collect\.py \(12 B\), resources\/logo\.png \(8 B\)\./); |
| 155 | const template = await tools.run("use_skill", { name: "release-notes", file: "resources/template.md" }); |
| 156 | assert.match(template.text, /## Added/); |
| 157 | const script = await tools.run("use_skill", { name: "release-notes", file: "scripts/collect.py" }); |
| 158 | assert.match(script.text, /This is a script: it needs a computer of its own/); |
| 159 | const image = await tools.run("use_skill", { name: "release-notes", file: "resources/logo.png" }); |
| 160 | assert.match(image.text, /isn't text/); |
| 161 | const missing = await tools.run("use_skill", { name: "teleport" }); |
| 162 | assert.equal(missing.outcome, "refused"); |
| 163 | assert.match(missing.text, /no skill called teleport\. Your skills: documents, research, data, code, communication, release-notes\./); |
| 164 | }); |
| 165 | |
| 166 | // ── make_file, end to end through the tool box ────────────────────────── |
| 167 | |
| 168 | const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User; |
| 169 | |
| 170 | const basePorts: ToolPorts = { |
| 171 | readFile: async () => null, |
| 172 | searchCode: async () => [], |
| 173 | listIssues: async () => [], |
| 174 | getIssue: async () => null, |
| 175 | getPull: async () => null, |
| 176 | recentPulls: async () => [], |
| 177 | searchMessages: async () => [], |
| 178 | readThread: async () => null, |
| 179 | roster: async () => "", |
| 180 | consult: async () => ({ ok: false, message: "no" }), |
| 181 | }; |
| 182 | |
| 183 | function fakeFolios(opts: { audienceCanRead?: boolean; canEdit?: boolean } = {}) { |
| 184 | const log: { created: { title: string; markdown: string | null }[]; attached: { folio: string; name: string; type: string; bytes: number }[]; edits: string[]; links: string[] } = { created: [], attached: [], edits: [], links: [] }; |
| 185 | const ref = (id: string, title: string): FolioRef => ({ id, kind: "doc", title, path: `/acme/-/artifacts/${id}`, icon: null }) as unknown as FolioRef; |
| 186 | const folios: FoliosPorts = { |
| 187 | spaces: async () => [], |
| 188 | recall: async () => [], |
| 189 | search: async () => "", |
| 190 | read: async (_v, _a, id) => ({ |
| 191 | ok: true, |
| 192 | value: { folio: { ...ref(id, "Existing"), edited_at: "2026-10-10" }, space: null, content: "", can: { read: true, suggest: true, edit: opts.canEdit ?? true }, audience_can_read: opts.audienceCanRead ?? true } as never, |
| 193 | }), |
| 194 | stale: async () => "", |
| 195 | create: async (_v, input) => { |
| 196 | log.created.push({ title: input.title, markdown: input.markdown }); |
| 197 | return { ok: true, value: ref("fol_new", input.title) }; |
| 198 | }, |
| 199 | edit: async (_v, _id, edit) => { |
| 200 | log.edits.push(edit.kind === "doc" ? edit.markdown : ""); |
| 201 | return { ok: true, value: { mode: "applied", version_id: null, folio: ref("fol_new", "x"), summary: "" } }; |
| 202 | }, |
| 203 | share: async () => ({ ok: true, value: null }), |
| 204 | attach: async (_v, folio, file) => { |
| 205 | log.attached.push({ folio, name: file.name, type: file.content_type, bytes: file.bytes.length }); |
| 206 | return { ok: true, value: { url: "https://g1tusercontent.com/docs-files/abc", name: file.name, bytes: file.bytes.length } }; |
| 207 | }, |
| 208 | sendLink: async (_a, link) => { |
| 209 | log.links.push(link.path); |
| 210 | return true; |
| 211 | }, |
| 212 | }; |
| 213 | return { folios, log }; |
| 214 | } |
| 215 | |
| 216 | const actions = { remember: async () => ({ ok: true, message: "" }), forget: async () => ({ ok: true, message: "" }), draftIssue: async () => ({ ok: true, message: "" }) }; |
| 217 | const context = { agentId: "agt_me", notConsult: [], hops: 0, maxHops: 4 }; |
| 218 | |
| 219 | async function box(info: AudienceInfo, folios: FoliosPorts) { |
| 220 | const people = [person("asker"), person("bea")]; |
| 221 | const audience = await Audience.build("acme", "asker", { |
| 222 | info: async () => info, |
| 223 | users: async (ids) => people.filter((u) => ids.includes(u.id)), |
| 224 | workspaceRepos: async () => [], |
| 225 | readable: async () => [], |
| 226 | }); |
| 227 | return new ToolBox(audience, { ...basePorts, folios }, context, [], actions); |
| 228 | } |
| 229 | |
| 230 | test("make_file makes the PDF, keeps it with a new doc holding its text, links it, and hands back the link", async () => { |
| 231 | const { folios, log } = fakeFolios(); |
| 232 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, folios); |
| 233 | assert.ok(tools.definitions().some((t) => t.name === "make_file")); |
| 234 | const result = await tools.run("make_file", { format: "pdf", title: "Q3 report", content: "# Q3 report\n\nRevenue grew." }); |
| 235 | assert.equal(result.outcome, "allowed"); |
| 236 | assert.match(result.text, /Made Q3 report\.pdf \(1 page, \d+ KB\)/); |
| 237 | assert.match(result.text, /https:\/\/g1tusercontent\.com\/docs-files\/abc/); |
| 238 | assert.deepEqual(log.created, [{ title: "Q3 report", markdown: "Revenue grew." }], "the doc has its own title, so the heading isn't repeated"); |
| 239 | assert.equal(log.attached[0]!.type, "application/pdf"); |
| 240 | assert.equal(log.attached[0]!.folio, "fol_new"); |
| 241 | assert.match(log.edits[0]!, /^\*\*File:\*\* \[Q3 report\.pdf\]\(https:\/\/g1tusercontent\.com\/docs-files\/abc\)/); |
| 242 | }); |
| 243 | |
| 244 | test("make_file attaches to a doc only where it may edit, and keeps a doc others here can't read out of the conversation", async () => { |
| 245 | const readOnly = fakeFolios({ canEdit: false }); |
| 246 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, readOnly.folios); |
| 247 | const refused = await tools.run("make_file", { format: "csv", title: "Rows", sheets: [{ rows: [["a"], [1]] }], artifact: "fol_01k7a0b1c2d3e4f5g6h7j8k9mn" }); |
| 248 | assert.equal(refused.outcome, "refused"); |
| 249 | assert.match(refused.text, /can.t edit that doc/); |
| 250 | assert.equal(readOnly.log.attached.length, 0); |
| 251 | |
| 252 | const hidden = fakeFolios({ audienceCanRead: false }); |
| 253 | const group = await box({ kind: "private", member_user_ids: ["asker", "bea"], member_count: 2 }, hidden.folios); |
| 254 | const sent = await group.run("make_file", { format: "xlsx", title: "Salaries", sheets: [{ rows: [["name", "pay"], ["bea", 1]] }], artifact: "fol_01k7a0b1c2d3e4f5g6h7j8k9mn" }); |
| 255 | assert.equal(hidden.log.attached.length, 1, `kept with the doc: ${sent.text}`); |
| 256 | assert.doesNotMatch(sent.text, /docs-files/, "the link isn't given here"); |
| 257 | assert.deepEqual(hidden.log.links, ["/acme/-/artifacts/fol_01k7a0b1c2d3e4f5g6h7j8k9mn"], "the person who asked gets it directly"); |
| 258 | }); |
| 259 | |
| 260 | test("a spreadsheet's doc shows its rows; a format it can't write is refused with what to do instead", async () => { |
| 261 | assert.match(docBody("xlsx", "Sales", { sheets: [{ name: "S", rows: [["a", "b"], [1, 2]] }] }), /^\| a \| b \|/); |
| 262 | assert.equal(docBody("pdf", "T", { content: "# T" }), "The file is attached below."); |
| 263 | const { folios } = fakeFolios(); |
| 264 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, folios); |
| 265 | const deck = await tools.run("make_file", { format: "pptx", title: "Deck" }); |
| 266 | assert.equal(deck.outcome, "refused"); |
| 267 | assert.match(deck.text, /offer a PDF or a doc instead/); |
| 268 | }); |