| 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 { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILL_IDS, skillTools, skillsOn } from "../../../packages/contracts/src/skills.ts"; |
| 7 | import { Audience, type AudienceInfo } from "./audience.ts"; |
| 8 | import { applyChanges } from "./definition.ts"; |
| 9 | import { systemPrompt } from "./prompt.ts"; |
| 10 | import { skillsSection } from "./skills.ts"; |
| 11 | import { TEMPLATE_IDS } from "./templates.ts"; |
| 12 | import { type FoliosPorts, type ToolPorts, TOOL_NAMES, ToolBox, docBody } from "./tools.ts"; |
| 13 | |
| 14 | test("the foundational skills name only tools agents have, and say what's coming", () => { |
| 15 | assert.deepEqual(FOUNDATIONAL_SKILL_IDS, ["documents", "research", "data", "code", "communication", "files"]); |
| 16 | for (const skill of FOUNDATIONAL_SKILLS) { |
| 17 | assert.equal(skill.source, "foundational"); |
| 18 | assert.ok(skill.instructions.length > 100, `${skill.id} has a playbook`); |
| 19 | for (const ability of skill.abilities) { |
| 20 | for (const tool of ability.tools) assert.ok(TOOL_NAMES.has(tool), `${skill.id}/${ability.id} names ${tool}, which agents have`); |
| 21 | if (ability.status === "coming") assert.deepEqual(ability.tools, [], `${skill.id}/${ability.id} is coming, so it uses no tool yet`); |
| 22 | assert.ok(ability.note, `${skill.id}/${ability.id} says how or what's missing`); |
| 23 | } |
| 24 | // Every tool a playbook tells the agent to call is one it can have. |
| 25 | 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}`); |
| 26 | } |
| 27 | assert.ok(skillTools(FOUNDATIONAL_SKILLS[0]!).includes("make_file")); |
| 28 | const coming = FOUNDATIONAL_SKILLS.flatMap((s) => s.abilities.filter((a) => a.status === "coming").map((a) => a.id)); |
| 29 | for (const id of ["slides", "search", "browse", "sql", "run", "schedule", "ocr", "images"]) assert.ok(coming.includes(id), `${id} is marked coming`); |
| 30 | }); |
| 31 | |
| 32 | test("each skill that is on puts its playbook in the prompt, with what isn't here and what's coming", () => { |
| 33 | const all = [...TOOL_NAMES]; |
| 34 | const section = skillsSection([], all)!; |
| 35 | for (const skill of FOUNDATIONAL_SKILLS) assert.match(section, new RegExp(`### ${skill.name}\\n`)); |
| 36 | assert.match(section, /they never add one/); |
| 37 | assert.match(section, /Not yet in g1t: slide decks\./); |
| 38 | assert.match(section, /Not yet in g1t: search the web and browse and read pages\./); |
| 39 | assert.doesNotMatch(section, /Not available in this conversation/, "every tool is offered"); |
| 40 | // In a conversation whose people can't all read code: the code abilities say so. |
| 41 | const noCode = skillsSection([], all.filter((t) => !["list_repositories", "search_code", "read_file", "recent_activity", "get_pull", "review_pull", "comment", "draft_issue"].includes(t)))!; |
| 42 | assert.match(noCode, /### Code[\s\S]*Not available in this conversation \(its tools aren't offered here\): read and explain code, review pull requests and open pull requests\./); |
| 43 | // Off: gone from the prompt, the rest stays. |
| 44 | const someOff = skillsSection(["communication", "files"], all)!; |
| 45 | assert.doesNotMatch(someOff, /### Communication/); |
| 46 | assert.doesNotMatch(someOff, /### Files and media/); |
| 47 | assert.match(someOff, /### Documents/); |
| 48 | assert.equal(skillsSection(FOUNDATIONAL_SKILL_IDS, all), null); |
| 49 | assert.equal(skillsOn(["data"]).length, 5); |
| 50 | const prompt = systemPrompt({ |
| 51 | agent: { id: "agt_1", handle: "ship", display_name: "Ship", role: "Release manager", instructions: "Ship.", personality_preset: "crisp", personality: "" }, |
| 52 | workspace: "acme", |
| 53 | channel: { kind: "dm", name: null }, |
| 54 | asker: { name: "dana", display_name: null, access: null }, |
| 55 | today: new Date("2026-10-10T00:00:00Z"), |
| 56 | skills: section, |
| 57 | }); |
| 58 | assert.ok(prompt.indexOf("## Your skills") > prompt.indexOf("## How to answer"), "skills come after the rules"); |
| 59 | }); |
| 60 | |
| 61 | test("owners turn skills off by id; unknown ids are refused, and the list reads in the skills' order", () => { |
| 62 | const made = applyChanges(null, { handle: "ship", display_name: "Ship", role: "r", instructions: "i" }, TEMPLATE_IDS); |
| 63 | assert.ok(made.ok); |
| 64 | if (!made.ok) return; |
| 65 | assert.deepEqual(made.value.skills_off, []); |
| 66 | const off = applyChanges(made.value, { skills_off: ["files", " data ", "files"] }, TEMPLATE_IDS); |
| 67 | assert.ok(off.ok); |
| 68 | assert.deepEqual(off.ok && off.value.skills_off, ["data", "files"]); |
| 69 | const bad = applyChanges(made.value, { skills_off: ["teleport"] }, TEMPLATE_IDS); |
| 70 | assert.equal(bad.ok, false); |
| 71 | assert.match(!bad.ok ? bad.message : "", /no skill called teleport/); |
| 72 | assert.equal(applyChanges(made.value, { skills_off: "data" as unknown as string[] }, TEMPLATE_IDS).ok, false); |
| 73 | }); |
| 74 | |
| 75 | // ── make_file, end to end through the tool box ────────────────────────── |
| 76 | |
| 77 | const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User; |
| 78 | |
| 79 | const basePorts: ToolPorts = { |
| 80 | readFile: async () => null, |
| 81 | searchCode: async () => [], |
| 82 | listIssues: async () => [], |
| 83 | getIssue: async () => null, |
| 84 | getPull: async () => null, |
| 85 | recentPulls: async () => [], |
| 86 | searchMessages: async () => [], |
| 87 | readThread: async () => null, |
| 88 | roster: async () => "", |
| 89 | consult: async () => ({ ok: false, message: "no" }), |
| 90 | }; |
| 91 | |
| 92 | function fakeFolios(opts: { audienceCanRead?: boolean; canEdit?: boolean } = {}) { |
| 93 | 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: [] }; |
| 94 | const ref = (id: string, title: string): FolioRef => ({ id, kind: "doc", title, path: `/acme/-/artifacts/${id}`, icon: null }) as unknown as FolioRef; |
| 95 | const folios: FoliosPorts = { |
| 96 | spaces: async () => [], |
| 97 | recall: async () => [], |
| 98 | search: async () => "", |
| 99 | read: async (_v, _a, id) => ({ |
| 100 | ok: true, |
| 101 | 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, |
| 102 | }), |
| 103 | stale: async () => "", |
| 104 | create: async (_v, input) => { |
| 105 | log.created.push({ title: input.title, markdown: input.markdown }); |
| 106 | return { ok: true, value: ref("fol_new", input.title) }; |
| 107 | }, |
| 108 | edit: async (_v, _id, edit) => { |
| 109 | log.edits.push(edit.kind === "doc" ? edit.markdown : ""); |
| 110 | return { ok: true, value: { mode: "applied", version_id: null, folio: ref("fol_new", "x"), summary: "" } }; |
| 111 | }, |
| 112 | share: async () => ({ ok: true, value: null }), |
| 113 | attach: async (_v, folio, file) => { |
| 114 | log.attached.push({ folio, name: file.name, type: file.content_type, bytes: file.bytes.length }); |
| 115 | return { ok: true, value: { url: "https://g1tusercontent.com/docs-files/abc", name: file.name, bytes: file.bytes.length } }; |
| 116 | }, |
| 117 | sendLink: async (_a, link) => { |
| 118 | log.links.push(link.path); |
| 119 | return true; |
| 120 | }, |
| 121 | }; |
| 122 | return { folios, log }; |
| 123 | } |
| 124 | |
| 125 | const actions = { remember: async () => ({ ok: true, message: "" }), forget: async () => ({ ok: true, message: "" }), draftIssue: async () => ({ ok: true, message: "" }) }; |
| 126 | const context = { agentId: "agt_me", notConsult: [], hops: 0, maxHops: 4 }; |
| 127 | |
| 128 | async function box(info: AudienceInfo, folios: FoliosPorts) { |
| 129 | const people = [person("asker"), person("bea")]; |
| 130 | const audience = await Audience.build("acme", "asker", { |
| 131 | info: async () => info, |
| 132 | users: async (ids) => people.filter((u) => ids.includes(u.id)), |
| 133 | workspaceRepos: async () => [], |
| 134 | readable: async () => [], |
| 135 | }); |
| 136 | return new ToolBox(audience, { ...basePorts, folios }, context, [], actions); |
| 137 | } |
| 138 | |
| 139 | test("make_file makes the PDF, keeps it with a new doc holding its text, links it, and hands back the link", async () => { |
| 140 | const { folios, log } = fakeFolios(); |
| 141 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, folios); |
| 142 | assert.ok(tools.definitions().some((t) => t.name === "make_file")); |
| 143 | const result = await tools.run("make_file", { format: "pdf", title: "Q3 report", content: "# Q3 report\n\nRevenue grew." }); |
| 144 | assert.equal(result.outcome, "allowed"); |
| 145 | assert.match(result.text, /Made Q3 report\.pdf \(1 page, \d+ KB\)/); |
| 146 | assert.match(result.text, /https:\/\/g1tusercontent\.com\/docs-files\/abc/); |
| 147 | assert.deepEqual(log.created, [{ title: "Q3 report", markdown: "Revenue grew." }], "the doc has its own title, so the heading isn't repeated"); |
| 148 | assert.equal(log.attached[0]!.type, "application/pdf"); |
| 149 | assert.equal(log.attached[0]!.folio, "fol_new"); |
| 150 | assert.match(log.edits[0]!, /^\*\*File:\*\* \[Q3 report\.pdf\]\(https:\/\/g1tusercontent\.com\/docs-files\/abc\)/); |
| 151 | }); |
| 152 | |
| 153 | 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 () => { |
| 154 | const readOnly = fakeFolios({ canEdit: false }); |
| 155 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, readOnly.folios); |
| 156 | const refused = await tools.run("make_file", { format: "csv", title: "Rows", sheets: [{ rows: [["a"], [1]] }], artifact: "fol_01k7a0b1c2d3e4f5g6h7j8k9mn" }); |
| 157 | assert.equal(refused.outcome, "refused"); |
| 158 | assert.match(refused.text, /can.t edit that doc/); |
| 159 | assert.equal(readOnly.log.attached.length, 0); |
| 160 | |
| 161 | const hidden = fakeFolios({ audienceCanRead: false }); |
| 162 | const group = await box({ kind: "private", member_user_ids: ["asker", "bea"], member_count: 2 }, hidden.folios); |
| 163 | const sent = await group.run("make_file", { format: "xlsx", title: "Salaries", sheets: [{ rows: [["name", "pay"], ["bea", 1]] }], artifact: "fol_01k7a0b1c2d3e4f5g6h7j8k9mn" }); |
| 164 | assert.equal(hidden.log.attached.length, 1, `kept with the doc: ${sent.text}`); |
| 165 | assert.doesNotMatch(sent.text, /docs-files/, "the link isn't given here"); |
| 166 | assert.deepEqual(hidden.log.links, ["/acme/-/artifacts/fol_01k7a0b1c2d3e4f5g6h7j8k9mn"], "the person who asked gets it directly"); |
| 167 | }); |
| 168 | |
| 169 | test("a spreadsheet's doc shows its rows; a format it can't write is refused with what to do instead", async () => { |
| 170 | assert.match(docBody("xlsx", "Sales", { sheets: [{ name: "S", rows: [["a", "b"], [1, 2]] }] }), /^\| a \| b \|/); |
| 171 | assert.equal(docBody("pdf", "T", { content: "# T" }), "The file is attached below."); |
| 172 | const { folios } = fakeFolios(); |
| 173 | const tools = await box({ kind: "dm", member_user_ids: ["asker"], member_count: 1 }, folios); |
| 174 | const deck = await tools.run("make_file", { format: "pptx", title: "Deck" }); |
| 175 | assert.equal(deck.outcome, "refused"); |
| 176 | assert.match(deck.text, /offer a PDF or a doc instead/); |
| 177 | }); |