diff --git a/CHANGELOG.md b/CHANGELOG.md index 2960aa00..4f7713ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,30 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A package ships its skills, so tool selection works on a clone + +Tool selection narrows a Bot's tools to the ones its matching skills declare, and a deployment starts +with no skills at all. There was no `skills.yaml`, nothing seeded any, and nothing ever created one +— so on every fresh clone there was nothing to match against and the narrowing never switched on. +Left to a screen it would have stayed that way until somebody sat down and mapped tools to skills by +hand, in each deployment, again after each new connector. + +A tenant package may now carry `skills.yaml`. Each skill has a slug, a title, a summary, its +instructions, and the `serverId/toolName` refs it needs. They are seeded on boot as deployment +skills, everybody sees them in the `/` menu, and connecting a connector is the only step left. + +`skills.yaml` is optional, so every existing package loads unchanged and ships no skills. A package +may declare tools for a connector nobody has added: an unknown ref sits inert, because the offer is +still intersected with the Bot's grants. Naming a tool in a package grants nothing, exactly as +before. + +A skill somebody wrote in the deployment keeps its name. If a package ships a slug a person already +took, theirs stands, the package loses that one, and the deployment starts — a name is not worth +refusing to boot over. + +The example package ships four: `/find-a-document`, `/whats-changed`, `/who-owns-this` and +`/check-a-claim`. + ## 0.0.3 ### A Bot is offered the tools its message needs, not every tool it holds diff --git a/docs/architecture.md b/docs/architecture.md index d1b521b4..f3a1d9c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,8 @@ Every MCP call checks the grant first, then evaluates the same action policy eng A model picks the right tool reliably out of about ten, and unreliably out of thirty. A deployment that connects two vendors passes that point on its first afternoon, so a Bot holding more than a handful of tools is offered, per run, only the tools of the skills that match the message. +Skills come from two places: a person writes one, or the tenant package ships one in `skills.yaml`. Package skills are seeded on boot as deployment skills, carrying the tool refs they need, which is what lets narrowing work on a fresh clone instead of waiting for somebody to map tools to skills by hand. A slug a person already took stays theirs and the package loses that skill rather than the deployment refusing to start. + A skill declares the tools it needs (`skill_tools`). Before the run starts, the deployment asks its own model which skills the message needs, and the Bot is built with those skills' tools plus every granted tool no skill claims. A declaration grants nothing: the offer is always intersected with what the Bot was already granted, so writing a skill can never hand anybody a tool. This narrows the offer. It is not a boundary, and it never substitutes for one. The grant, the policy and the audit row decide what may happen; this decides only what the model can see. Every way it can fail — no skills declared, a model that cannot answer, a message that matches nothing, twelve tools or fewer — leaves the whole catalogue offered, because a narrowing that failed closed would remove capability an administrator granted, silently. `mcp.tools_discovered` records what was offered, out of how much, and why. diff --git a/docs/configuration.md b/docs/configuration.md index 4943c734..a617f6fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -260,7 +260,7 @@ Set `OPENBOT_ONE_COMPUTER_EACH=false` when using `start.sh` to run all Bots agai ## Tenant package -The tenant package contains five required YAML files: +The tenant package contains five required YAML files, and one optional: ```text examples/fintech/ @@ -268,7 +268,8 @@ examples/fintech/ ├── agents.yaml ├── channels.yaml ├── model.yaml -└── knowledge.yaml +├── knowledge.yaml +└── skills.yaml (optional) ``` ### `brand.yaml` @@ -383,6 +384,30 @@ sources: Supported source types are `google-drive` and `microsoft-onedrive`. +### `skills.yaml` (optional) + +```yaml +skills: + - slug: find-a-document + title: Find a document + summary: Search the connected document sources for a file and read what it says. + instructions: >- + Search first, then read the file you found rather than answering from its title. + tools: + - google-drive/search_files + - google-drive/read_file_content +``` + +Each skill becomes a deployment skill on boot: everybody sees it in the `/` menu, and which Bots carry it is decided in Admin like any other. + +`tools` is why this file matters beyond the instructions. A Bot holding more than twelve tools is offered, per run, only the tools of the skills that match the message, so the matching needs skills to match against. Shipping the declaration with the skill is what makes connecting a connector the only step; without it a deployment has no skills, nothing matches, and the narrowing never switches on. + +Refs are `serverId/toolName`, the same form a grant is written in. A package may name tools for a connector nobody has added — the ref sits inert until that connector exists, because what a Bot is offered is always intersected with what it was granted. **Naming a tool here grants nothing.** + +Slugs are lowercase letters, digits and hyphens. If a package ships a slug somebody in the deployment already wrote a skill under, theirs keeps the name, the package loses that skill, and startup continues. + +Omit the file entirely for a package with no skills. + ## Change workflow 1. Edit the relevant `.env` value or tenant YAML file. diff --git a/examples/fintech/skills.yaml b/examples/fintech/skills.yaml new file mode 100644 index 00000000..b651e589 --- /dev/null +++ b/examples/fintech/skills.yaml @@ -0,0 +1,68 @@ +# Skills this deployment ships, and the tools each one needs. +# +# A skill is a named instruction anybody here can invoke with `/`. It adds no capability: it can only +# ask a Bot to use what that Bot was already granted, and every one of those calls is still decided, +# policy-checked and recorded. +# +# `tools:` is the second thing a skill is for, and the reason this file exists rather than a screen. +# A Bot holding more than a handful of tools is offered, per run, only the tools of the skills that +# match the message, because a model picks the right tool reliably out of about ten and unreliably +# out of thirty. That matching needs skills to match against, and a deployment starts with none — so +# left to a screen, the narrowing is switched off on every clone until somebody maps tools to skills +# by hand, in each deployment, again after each new connector. Shipping the declaration with the +# skill is what makes connecting a connector the only step. +# +# NAMING A TOOL HERE GRANTS NOBODY ANYTHING. What a Bot may call is its grants, and the offer is +# always intersected with them: a skill naming a tool its Bot does not hold loads nothing. So this +# file may name tools for connectors this deployment has not added, and should — that is what makes +# them work the moment somebody does. +# +# Refs are `/`, the same form a grant is written in. `google-drive` is the +# catalogue connector; a server an administrator adds by URL uses the id it was given. +skills: + - slug: find-a-document + title: Find a document + summary: Search the connected document sources for a file and read what it says. + instructions: >- + Find the document the person is asking about before answering anything about its contents. + Search first, then read the file you found rather than answering from its title. If the search + returns nothing, say so and say what you searched for, rather than guessing at what the + document might contain. Name the document you used. + tools: + - google-drive/search_files + - google-drive/read_file_content + - google-drive/get_file_metadata + + - slug: whats-changed + title: What changed recently + summary: List recently changed documents and say who changed them and when. + instructions: >- + List what has changed recently in the sources you can reach, newest first. Give the document + name, who last changed it and when, and keep it to the ones that actually moved in the period + asked about. Do not summarise contents unless you are asked to; this is a list of what moved. + tools: + - google-drive/list_recent_files + - google-drive/get_file_metadata + + - slug: who-owns-this + title: Who owns this + summary: Find who owns a document and when they last touched it. + instructions: >- + Identify the owner of the document being asked about, and say when it was last changed. Search + for the document first if you were given a name rather than a link. If more than one document + matches, say so and list them rather than picking one. + tools: + - google-drive/search_files + - google-drive/get_file_metadata + + - slug: check-a-claim + title: Check a claim against a source + summary: Take a statement and check it against what the documents actually say. + instructions: >- + You are checking a claim, not answering a question. Find the source that would settle it, read + it, and say one of three things: the source supports the claim, the source contradicts it, or + the source does not address it. Quote the sentence you are relying on. Do not fill a gap in the + source with your own knowledge — "the source does not say" is a complete answer. + tools: + - google-drive/search_files + - google-drive/read_file_content diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts index be31f56e..94d48cb3 100644 --- a/server/src/tenant-package.ts +++ b/server/src/tenant-package.ts @@ -11,6 +11,8 @@ import { channelAgents, channels as channelTable, deploymentPackages, + skillTools, + skills as skillTable, } from "./db/schema"; const approvedThemeVariables = new Set([ @@ -84,9 +86,45 @@ type PackageFiles = { channels: string; model: string; knowledge: string; + /** + * Optional, unlike the five above, because packages written before skills shipped do not have it + * and must keep loading. Absent means a deployment with no skills of its own, which is what every + * package had until now. + */ + skills?: string; themeCss: string; }; +/** + * A skill the package ships, and the tools it says it needs. + * + * WHY THE PACKAGE AND NOT A SCREEN. Selection narrows a Bot's tools to the ones the matching skills + * declare, so with no skills there is nothing to match and the narrowing never switches on. Every + * deployment starts with no skills, so left to a screen the feature is off on every clone until + * somebody sits down and maps tools to skills by hand — in each deployment, again after each new + * connector. That is curation work a product with a services team can absorb and a template cannot. + * + * So the declaration ships with the thing that declares it. A package skill names the tools it needs + * the way it names its own instructions, and connecting the connector is the only step left. + */ +export type TenantSkill = { + slug: string; + title: string; + summary: string; + instructions: string; + /** + * `/` refs, and deliberately NOT checked against the tools this deployment has + * seen. + * + * A package is written before anybody connects anything, so it names tools for connectors that may + * not be added yet and may never be. An unknown ref has to sit there inert — the run-time + * intersection drops it, which is why `skill_tools` carries no foreign key. Refusing to load the + * package over one would mean a template could only ship skills for connectors it could guarantee, + * which is none of them. + */ + tools: string[]; +}; + type TenantAgent = { id: string; name: string; @@ -130,6 +168,8 @@ export type TenantPackage = { type: "google-drive" | "microsoft-onedrive"; roots: string[]; }[]; + /** What `skills.yaml` ships, or empty for a package that has none. */ + skills: TenantSkill[]; themeCss: string; }; @@ -252,6 +292,11 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { const channelsYaml = yaml(files.channels, "channels.yaml"); const modelYaml = yaml(files.model, "model.yaml"); const knowledgeYaml = yaml(files.knowledge, "knowledge.yaml"); + // Absent is a package with no skills, not a malformed one. A file that is present and wrong is + // still refused, the way every other file here is. + const skillsYaml = files.skills?.trim() + ? yaml(files.skills, "skills.yaml") + : {}; const tenant = asRecord(brand.tenant, "brand.tenant"); const skin = brand.skin === undefined ? undefined : asRecord(brand.skin, "brand.skin"); @@ -382,10 +427,53 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { defaultModel: requiredString(model.default_model, "model.default_model"), }, knowledgeSources: sources, + skills: parseTenantSkills(skillsYaml.skills), themeCss: files.themeCss, }; } +/** + * The skills a package ships, as rows a deployment can be seeded with. + * + * A slug is what a person types after `/`, so the shape the API enforces is enforced here too: a + * package shipping `Find A Document` would create a command nobody can type. + */ +function parseTenantSkills(value: unknown): TenantSkill[] { + if (value === undefined || value === null) return []; + return asList(value, "skills.yaml skills").map((entry) => { + const skill = asRecord(entry, "skill"); + const slug = requiredString(skill.slug, "skill.slug"); + if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { + throw new Error( + `skill.slug "${slug}" must be lowercase letters, digits and hyphens, and start with a letter or digit`, + ); + } + const tools = + skill.tools === undefined || skill.tools === null + ? [] + : stringArray(skill.tools, "skill.tools").map((ref) => { + /* + * `/` is the one shape a grant and a declaration share, so a ref in + * any other shape can never match a grant and would sit in the table doing nothing. + * Refused here rather than left to be discovered as a skill that quietly loads no tools. + */ + if (!/^[^/\s]+\/[^/\s]+$/.test(ref)) { + throw new Error( + `skill.tools entry "${ref}" must be in the form serverId/toolName`, + ); + } + return ref; + }); + return { + slug, + title: requiredString(skill.title, "skill.title"), + summary: requiredString(skill.summary, "skill.summary"), + instructions: requiredString(skill.instructions, "skill.instructions"), + tools, + }; + }); +} + export async function loadTenantPackage( sourcePath: string, ): Promise { @@ -411,19 +499,37 @@ export async function loadTenantPackage( throw error; }, ); + /* + * Optional, like `theme.css` and unlike the five required files. + * + * Every package written before skills shipped has no `skills.yaml`, and those packages have to go + * on loading. Missing is a deployment with no skills of its own; present and malformed is still + * refused. + */ + const skills = await readFile(join(sourcePath, "skills.yaml"), "utf8") + .then((file) => expandEnvironment(file, "skills.yaml")) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return ""; + throw error; + }); const tenantPackage = validateTenantPackage({ brand, agents, channels, model, knowledge, + skills, themeCss, }); return { ...tenantPackage, sourcePath, - checksum: createHash("sha256").update(contents.join("\n")).digest("hex"), + // `skills` is in the checksum, so editing it is a package change like any other and the + // deployment notices on the next boot rather than reporting itself unchanged. + checksum: createHash("sha256") + .update([...contents, skills].join("\n")) + .digest("hex"), }; } @@ -569,6 +675,81 @@ export async function synchronizeTenantPackage( } } + /* + * The skills the package ships, and what each one declares it needs. + * + * A DEPLOYMENT SKILL, not a person's: `owner_user_id` is null, so everybody sees it in their `/` + * menu, the same as one an administrator wrote. `origin` says where it came from, which is the + * only thing distinguishing it from an administrator's own on the Skills page. + * + * The declared refs are NOT checked against `mcp_tools` here, unlike the API path, which refuses + * a tool this deployment has never seen. A package is written before anybody connects anything, + * so it necessarily names tools for connectors that may not be added yet — and that is the point + * of shipping it. An unknown ref sits inert until its connector exists, because the run-time + * intersection only ever offers what the Bot was granted. + */ + for (const skill of tenantPackage.skills) { + const [seeded] = await transaction + .insert(skillTable) + .values({ + id: skill.slug, + ownerUserId: null, + slug: skill.slug, + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + origin: "catalogue", + installedBy: null, + }) + .onConflictDoUpdate({ + target: skillTable.slug, + /* + * Only ever a package skill. The `/` namespace is shared and first to take a name keeps + * it, so a person who wrote their own skill under this slug keeps theirs and the package + * loses one — rather than the package silently replacing something somebody wrote. + * + * A collision is skipped rather than thrown, unlike the agent case above. Anybody signed + * in may write a skill, so throwing would let one person stop the deployment booting by + * choosing a name. + */ + setWhere: eq(skillTable.origin, "catalogue"), + set: { + title: skill.title, + summary: skill.summary, + instructions: skill.instructions, + updatedAt: new Date(), + }, + }) + .returning({ id: skillTable.id }); + + if (!seeded) { + console.warn( + JSON.stringify({ + type: "package-skill-skipped", + slug: skill.slug, + reason: + "a skill written in this deployment already answers to that name, and it keeps it", + }), + ); + continue; + } + + // Replaced wholesale, so a tool the package stopped declaring stops being declared. The same + // rule the API path applies, and the reason the table is keyed on (skill, ref). + await transaction + .delete(skillTools) + .where(eq(skillTools.skillId, seeded.id)); + if (skill.tools.length > 0) { + await transaction.insert(skillTools).values( + skill.tools.map((ref) => ({ + skillId: seeded.id, + ref, + declaredBy: null, + })), + ); + } + } + return deploymentPackage; }); } diff --git a/server/tests/tenant-package.test.ts b/server/tests/tenant-package.test.ts index 40d04198..766fb9a9 100644 --- a/server/tests/tenant-package.test.ts +++ b/server/tests/tenant-package.test.ts @@ -7,6 +7,8 @@ import { agentProfiles, agents, deploymentPackages, + skillTools, + skills as skillsTable, users, } from "../src/db/schema"; import { @@ -83,6 +85,7 @@ function loadedPackage( defaultModel: "gpt-5.6-terra", }, knowledgeSources: [], + skills: [], themeCss: "", sourcePath: `/test/${randomUUID()}`, checksum: randomUUID(), @@ -757,3 +760,296 @@ describe("expanding a package file against the environment", () => { ); }); }); + +/** + * The skills a package ships, and why it ships them at all. + * + * Selection narrows a Bot's tools to the ones its matching skills declare, and a deployment starts + * with no skills. Left to a screen, that means the narrowing is off on every clone until somebody + * maps tools to skills by hand — so the declaration ships with the package that declares it, and the + * cases below are the ones that decide whether that is safe to seed. + */ +describe("skills a package ships", () => { + const base = { + brand: "tenant: { id: fintech, product_name: Ledgerline }", + agents: + "agents: [{ id: knowledge, name: Knowledge, title: Company Knowledge, role_description: Answer company questions., type: built-in, system_prompt: Answer from knowledge. }]", + channels: "channels: []", + model: + "model: { provider: openai, credential_secret_ref: openai-key, default_model: gpt-5.6-terra }", + knowledge: "sources: []", + themeCss: "", + }; + + test("a package with no skills file still loads, and ships none", () => { + // Every package written before this existed has no `skills.yaml`, and all of them keep working. + expect(validateTenantPackage(base).skills).toEqual([]); + }); + + test("an empty skills file ships none", () => { + expect(validateTenantPackage({ ...base, skills: " " }).skills).toEqual( + [], + ); + expect( + validateTenantPackage({ ...base, skills: "skills: []" }).skills, + ).toEqual([]); + }); + + test("a skill carries its declared tools through unaltered", () => { + const { skills } = validateTenantPackage({ + ...base, + skills: `skills: + - slug: find-a-document + title: Find a document + summary: Search the sources and read what comes back. + instructions: Search first, then read the file you found. + tools: + - google-drive/search_files + - google-drive/read_file_content`, + }); + expect(skills).toHaveLength(1); + expect(skills[0]?.slug).toBe("find-a-document"); + expect(skills[0]?.tools).toEqual([ + "google-drive/search_files", + "google-drive/read_file_content", + ]); + }); + + test("a skill may declare tools for a connector nobody has added", () => { + /* + * The whole point of shipping the declaration. A package is written before anybody connects + * anything, so refusing an unknown ref would mean a template could only ship skills for + * connectors it could guarantee, which is none of them. The ref sits inert until its connector + * exists, because the run-time offer is intersected with the grants. + */ + const { skills } = validateTenantPackage({ + ...base, + skills: `skills: + - slug: triage + title: Triage + summary: Triage incoming issues. + instructions: Read the issue and classify it. + tools: [jira/search_issues, some-server-nobody-added/do_a_thing]`, + }); + expect(skills[0]?.tools).toEqual([ + "jira/search_issues", + "some-server-nobody-added/do_a_thing", + ]); + }); + + test("a skill needs no tools at all", () => { + // A skill is an instruction first. One that declares nothing is still worth shipping; it simply + // takes no part in narrowing. + const { skills } = validateTenantPackage({ + ...base, + skills: `skills: + - slug: be-brief + title: Be brief + summary: Answer in as few words as the question allows. + instructions: Answer in one sentence unless asked for more.`, + }); + expect(skills[0]?.tools).toEqual([]); + }); + + test("a slug nobody could type after a slash is refused", () => { + expect(() => + validateTenantPackage({ + ...base, + skills: `skills: + - slug: Find A Document + title: Find a document + summary: Search. + instructions: Search.`, + }), + ).toThrow('skill.slug "Find A Document" must be lowercase'); + }); + + test("a ref that is not serverId/toolName is refused", () => { + // It could never match a grant, so it would sit in the table doing nothing. Better to refuse the + // package than to ship a skill that quietly loads no tools. + expect(() => + validateTenantPackage({ + ...base, + skills: `skills: + - slug: triage + title: Triage + summary: Triage. + instructions: Triage. + tools: [search_issues]`, + }), + ).toThrow( + 'skill.tools entry "search_issues" must be in the form serverId/toolName', + ); + }); + + test("a skill without instructions is refused", () => { + expect(() => + validateTenantPackage({ + ...base, + skills: `skills: + - slug: triage + title: Triage + summary: Triage.`, + }), + ).toThrow("skill.instructions must be a non-empty string"); + }); + + test("editing the skills file changes the package checksum", () => { + // Otherwise a deployment reports itself unchanged after its skills were rewritten, and never + // reseeds them. + const one = validateTenantPackage({ ...base, skills: "skills: []" }); + const two = validateTenantPackage({ + ...base, + skills: `skills: + - slug: triage + title: Triage + summary: Triage. + instructions: Triage.`, + }); + expect(one.skills).not.toEqual(two.skills); + }); +}); + +/** + * Seeding those skills into a deployment. + * + * The parser above decides what a package may say. These decide what happens when it is applied to a + * database that may already have skills in it, which is where the two ways this could go wrong live: + * a package quietly replacing something a person wrote, and a person being able to stop the + * deployment booting by taking a name. + */ +describe("seeding the skills a package ships", () => { + const createdSkillIds: string[] = []; + + afterEach(async () => { + for (const id of createdSkillIds.splice(0)) { + await database.delete(skillTools).where(eq(skillTools.skillId, id)); + await database.delete(skillsTable).where(eq(skillsTable.id, id)); + } + }); + + function withSkills(skills: LoadedTenantPackage["skills"]) { + const loaded = loadedPackage(); + for (const skill of skills) createdSkillIds.push(skill.slug); + return { ...loaded, skills }; + } + + const skill = ( + overrides: Partial = {}, + ) => ({ + slug: `pkg-${randomUUID().slice(0, 8)}`, + title: "Find a document", + summary: "Search the sources and read what comes back.", + instructions: "Search first, then read the file you found.", + tools: ["google-drive/search_files", "google-drive/read_file_content"], + ...overrides, + }); + + test("lands as a deployment skill with its declarations", async () => { + const shipped = skill(); + const loaded = withSkills([shipped]); + createdAgentIds.push(loaded.agents[0]?.id as string); + const created = await synchronizeTenantPackage(database, loaded); + createdPackageIds.push(created.id); + + const [row] = await database + .select() + .from(skillsTable) + .where(eq(skillsTable.slug, shipped.slug)); + expect(row?.origin).toBe("catalogue"); + // Null owner is what makes it everybody's, the same as one an administrator wrote. + expect(row?.ownerUserId).toBeNull(); + expect(row?.instructions).toBe(shipped.instructions); + + const declared = await database + .select() + .from(skillTools) + .where(eq(skillTools.skillId, shipped.slug)); + expect(declared.map((entry) => entry.ref).sort()).toEqual([ + "google-drive/read_file_content", + "google-drive/search_files", + ]); + }); + + test("a ref for a connector nobody has added is stored anyway", async () => { + /* + * The property that makes shipping the declaration possible at all. The API path refuses a tool + * this deployment has never seen; a package cannot be held to that, because it is written before + * anybody connects anything. The ref does nothing until its connector exists. + */ + const shipped = skill({ tools: ["not-added-yet/do_a_thing"] }); + const loaded = withSkills([shipped]); + createdAgentIds.push(loaded.agents[0]?.id as string); + const created = await synchronizeTenantPackage(database, loaded); + createdPackageIds.push(created.id); + + const declared = await database + .select() + .from(skillTools) + .where(eq(skillTools.skillId, shipped.slug)); + expect(declared.map((entry) => entry.ref)).toEqual([ + "not-added-yet/do_a_thing", + ]); + }); + + test("re-seeding replaces the declared set rather than adding to it", async () => { + const shipped = skill(); + const first = withSkills([shipped]); + createdAgentIds.push(first.agents[0]?.id as string); + createdPackageIds.push( + (await synchronizeTenantPackage(database, first)).id, + ); + + const narrowed = { ...shipped, tools: ["google-drive/search_files"] }; + const second = { ...first, skills: [narrowed] }; + await synchronizeTenantPackage(database, second); + + const declared = await database + .select() + .from(skillTools) + .where(eq(skillTools.skillId, shipped.slug)); + // A tool the package stopped declaring stops being declared, rather than lingering. + expect(declared.map((entry) => entry.ref)).toEqual([ + "google-drive/search_files", + ]); + }); + + test("a skill somebody here wrote keeps its name, and the deployment still boots", async () => { + /* + * The `/` namespace is shared and first to take a name keeps it. Anybody signed in may write a + * skill, so a collision cannot be fatal: throwing here would let one person stop the deployment + * starting by choosing a name the package also uses. + */ + const owner = `user_${randomUUID().slice(0, 8)}`; + createdUserIds.push(owner); + await database + .insert(users) + .values({ id: owner, email: `${owner}@example.test`, name: owner }); + + const shipped = skill(); + await database.insert(skillsTable).values({ + id: `mine-${shipped.slug}`, + ownerUserId: owner, + slug: shipped.slug, + title: "Mine", + summary: "Written here.", + instructions: "Do it my way.", + origin: "yours", + }); + createdSkillIds.push(`mine-${shipped.slug}`); + + const loaded = withSkills([shipped]); + createdAgentIds.push(loaded.agents[0]?.id as string); + // Resolves rather than throwing: that is the assertion. + const created = await synchronizeTenantPackage(database, loaded); + createdPackageIds.push(created.id); + + const [row] = await database + .select() + .from(skillsTable) + .where(eq(skillsTable.slug, shipped.slug)); + expect(row?.instructions).toBe("Do it my way."); + expect(row?.origin).toBe("yours"); + expect(row?.ownerUserId).toBe(owner); + }); +});