| 1 | import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' |
| 2 | import os from 'node:os' |
| 3 | import path from 'node:path' |
| 4 | import { CompositeBackend, FilesystemBackend } from 'deepagents' |
| 5 | import { describe, expect, it } from 'vitest' |
| 6 | import { attachProductSkillsBackend } from '../../../src/main/agent-runtime/skills' |
| 7 | import { compareVersion, initializeSkills } from '../../../src/main/product-skills/initializer' |
| 8 | import { |
| 9 | CHART_SKILL_NAME, |
| 10 | DATA_ANIM_SKILL_NAME, |
| 11 | LAYOUT_SKILL_NAME, |
| 12 | PRODUCT_SKILLS_ROUTE, |
| 13 | RED_LAYOUT_SKILL_NAME, |
| 14 | REQUIRED_PRODUCT_SKILL_NAMES, |
| 15 | SOURCE_READING_SKILL_NAME, |
| 16 | SQUARE_1_1_LAYOUT_SKILL_NAME, |
| 17 | STANDARD_4_3_LAYOUT_SKILL_NAME, |
| 18 | SYSTEM_SKILLS_SOURCE_PATH, |
| 19 | VERTICAL_3_4_LAYOUT_SKILL_NAME, |
| 20 | VERTICAL_9_16_LAYOUT_SKILL_NAME |
| 21 | } from '../../../src/main/product-skills/contract' |
| 22 | import { setSkillsRuntime } from '../../../src/main/product-skills/runtime-state' |
| 23 | |
| 24 | async function makeSkill(root: string, name: string, version: string, body = '# Skill\n'): Promise<void> { |
| 25 | const skillPath = path.join(root, name) |
| 26 | await mkdir(skillPath, { recursive: true }) |
| 27 | await writeFile( |
| 28 | path.join(skillPath, 'skill.json'), |
| 29 | `${JSON.stringify({ name, version, source: 'builtin' }, null, 2)}\n`, |
| 30 | 'utf8' |
| 31 | ) |
| 32 | await writeFile( |
| 33 | path.join(skillPath, 'SKILL.md'), |
| 34 | `---\nname: ${name}\ndescription: Test skill\n---\n\n${body}`, |
| 35 | 'utf8' |
| 36 | ) |
| 37 | } |
| 38 | |
| 39 | describe('initializeSkills', () => { |
| 40 | it('compares numeric semver segments', () => { |
| 41 | expect(compareVersion('1.10.0', '1.2.0')).toBeGreaterThan(0) |
| 42 | expect(compareVersion('2.0.0', '10.0.0')).toBeLessThan(0) |
| 43 | expect(compareVersion('1.0', '1.0.0')).toBe(0) |
| 44 | }) |
| 45 | |
| 46 | it('installs bundled system skills into installed root and writes manifest', async () => { |
| 47 | const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-skills-')) |
| 48 | const bundled = path.join(tmp, 'bundled') |
| 49 | const installed = path.join(tmp, 'installed') |
| 50 | await makeSkill(bundled, 'oh-my-ppt-data-anim', '1.0.0') |
| 51 | |
| 52 | const result = await initializeSkills({ |
| 53 | builtinSourcePath: bundled, |
| 54 | installedRootPath: installed, |
| 55 | }) |
| 56 | |
| 57 | expect(result).toMatchObject({ |
| 58 | builtinCount: 1, |
| 59 | copiedCount: 1, |
| 60 | skippedCount: 0, |
| 61 | failedCount: 0, |
| 62 | }) |
| 63 | const manifest = JSON.parse( |
| 64 | await readFile(path.join(installed, 'system', '.manifest.json'), 'utf8') |
| 65 | ) |
| 66 | expect(manifest.skills['oh-my-ppt-data-anim']).toMatchObject({ |
| 67 | version: '1.0.0', |
| 68 | source: 'builtin', |
| 69 | }) |
| 70 | await expect( |
| 71 | readFile(path.join(installed, 'system', 'oh-my-ppt-data-anim', 'SKILL.md'), 'utf8') |
| 72 | ).resolves.toContain('Test skill') |
| 73 | }) |
| 74 | |
| 75 | it('keeps installed skills readable through the DeepAgents composite route', async () => { |
| 76 | const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-skills-route-')) |
| 77 | const bundled = path.join(tmp, 'bundled') |
| 78 | const installed = path.join(tmp, 'installed') |
| 79 | await makeSkill(bundled, 'oh-my-ppt-data-anim', '1.0.0', '# Routed Skill\n') |
| 80 | await makeSkill(bundled, 'oh-my-ppt-chart', '1.0.0', '# Routed Chart Skill\n') |
| 81 | await initializeSkills({ |
| 82 | builtinSourcePath: bundled, |
| 83 | installedRootPath: installed, |
| 84 | }) |
| 85 | |
| 86 | const backend = new CompositeBackend( |
| 87 | new FilesystemBackend({ rootDir: tmp, virtualMode: true }), |
| 88 | { |
| 89 | [PRODUCT_SKILLS_ROUTE]: new FilesystemBackend({ |
| 90 | rootDir: installed, |
| 91 | virtualMode: true, |
| 92 | }), |
| 93 | } |
| 94 | ) |
| 95 | |
| 96 | const skillSource = `${PRODUCT_SKILLS_ROUTE}${SYSTEM_SKILLS_SOURCE_PATH.replace(/^\//, '')}` |
| 97 | const dataAnimSkillPath = `${skillSource}oh-my-ppt-data-anim/SKILL.md` |
| 98 | const chartSkillPath = `${skillSource}oh-my-ppt-chart/SKILL.md` |
| 99 | const listed = await backend.ls(skillSource) |
| 100 | expect(listed.error).toBeUndefined() |
| 101 | expect(listed.files?.map((file) => file.path)).toContain( |
| 102 | `${skillSource}oh-my-ppt-data-anim/` |
| 103 | ) |
| 104 | expect(listed.files?.map((file) => file.path)).toContain( |
| 105 | `${skillSource}oh-my-ppt-chart/` |
| 106 | ) |
| 107 | |
| 108 | const read = await backend.read( |
| 109 | dataAnimSkillPath, |
| 110 | 0, |
| 111 | 20 |
| 112 | ) |
| 113 | expect(read.error).toBeUndefined() |
| 114 | expect(read.content).toContain('Routed Skill') |
| 115 | |
| 116 | const downloads = await backend.downloadFiles([ |
| 117 | dataAnimSkillPath, |
| 118 | chartSkillPath, |
| 119 | ]) |
| 120 | expect(downloads.map((download) => download.error ?? null)).toEqual([null, null]) |
| 121 | expect(new TextDecoder().decode(downloads[1].content)).toContain('Routed Chart Skill') |
| 122 | }) |
| 123 | |
| 124 | it('upgrades system skills by numeric version and marks removed bundled skills', async () => { |
| 125 | const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-skills-upgrade-')) |
| 126 | const bundled = path.join(tmp, 'bundled') |
| 127 | const installed = path.join(tmp, 'installed') |
| 128 | await makeSkill(bundled, 'oh-my-ppt-data-anim', '1.0.0', 'old body\n') |
| 129 | await makeSkill(bundled, 'legacy-skill', '1.0.0', 'legacy body\n') |
| 130 | |
| 131 | await initializeSkills({ |
| 132 | builtinSourcePath: bundled, |
| 133 | installedRootPath: installed, |
| 134 | }) |
| 135 | |
| 136 | const upgradedBundled = path.join(tmp, 'bundled-upgraded') |
| 137 | await makeSkill(upgradedBundled, 'oh-my-ppt-data-anim', '1.10.0', 'new body\n') |
| 138 | const upgraded = await initializeSkills({ |
| 139 | builtinSourcePath: upgradedBundled, |
| 140 | installedRootPath: installed, |
| 141 | }) |
| 142 | |
| 143 | expect(upgraded.copiedCount).toBe(1) |
| 144 | expect(upgraded.manifest.skills['oh-my-ppt-data-anim']).toMatchObject({ |
| 145 | version: '1.10.0', |
| 146 | }) |
| 147 | await expect( |
| 148 | readFile(path.join(installed, 'system', 'oh-my-ppt-data-anim', 'SKILL.md'), 'utf8') |
| 149 | ).resolves.toContain('new body') |
| 150 | expect(upgraded.manifest.skills['legacy-skill'].missingFromBundle).toBe(true) |
| 151 | }) |
| 152 | |
| 153 | it('exposes only the selected canvas product skills through the DeepAgents source', async () => { |
| 154 | const tmp = await mkdtemp(path.join(os.tmpdir(), 'ohmyppt-filtered-skills-')) |
| 155 | const bundled = path.join(tmp, 'bundled') |
| 156 | const installed = path.join(tmp, 'installed') |
| 157 | await makeSkill(bundled, VERTICAL_9_16_LAYOUT_SKILL_NAME, '1.0.0', '# Portrait Layout\n') |
| 158 | await makeSkill(bundled, STANDARD_4_3_LAYOUT_SKILL_NAME, '1.0.0', '# Standard Layout\n') |
| 159 | await makeSkill(bundled, DATA_ANIM_SKILL_NAME, '1.0.0', '# Data Anim\n') |
| 160 | await makeSkill(bundled, CHART_SKILL_NAME, '1.0.0', '# Chart\n') |
| 161 | await makeSkill(bundled, SOURCE_READING_SKILL_NAME, '1.0.0', '# Source Reading\n') |
| 162 | await initializeSkills({ |
| 163 | builtinSourcePath: bundled, |
| 164 | installedRootPath: installed |
| 165 | }) |
| 166 | setSkillsRuntime({ installedSkillsPath: installed, ready: Promise.resolve(null) }) |
| 167 | |
| 168 | const agentBackend = attachProductSkillsBackend( |
| 169 | new FilesystemBackend({ rootDir: tmp, virtualMode: true }), |
| 170 | 'session-deck', |
| 171 | [ |
| 172 | VERTICAL_9_16_LAYOUT_SKILL_NAME, |
| 173 | DATA_ANIM_SKILL_NAME, |
| 174 | CHART_SKILL_NAME, |
| 175 | SOURCE_READING_SKILL_NAME |
| 176 | ] |
| 177 | ) |
| 178 | |
| 179 | const listed = await agentBackend.backend.ls(agentBackend.skillSource) |
| 180 | expect(listed.error).toBeUndefined() |
| 181 | expect(listed.files?.map((file) => file.path).sort()).toEqual([ |
| 182 | `${agentBackend.skillSource}${CHART_SKILL_NAME}/`, |
| 183 | `${agentBackend.skillSource}${DATA_ANIM_SKILL_NAME}/`, |
| 184 | `${agentBackend.skillSource}${SOURCE_READING_SKILL_NAME}/`, |
| 185 | `${agentBackend.skillSource}${VERTICAL_9_16_LAYOUT_SKILL_NAME}/` |
| 186 | ]) |
| 187 | |
| 188 | const allowedRead = await agentBackend.backend.read( |
| 189 | `${agentBackend.skillSource}${VERTICAL_9_16_LAYOUT_SKILL_NAME}/SKILL.md` |
| 190 | ) |
| 191 | expect(allowedRead.error).toBeUndefined() |
| 192 | expect(allowedRead.content).toContain('Portrait Layout') |
| 193 | |
| 194 | const blockedRead = await agentBackend.backend.read( |
| 195 | `${agentBackend.skillSource}${STANDARD_4_3_LAYOUT_SKILL_NAME}/SKILL.md` |
| 196 | ) |
| 197 | expect(blockedRead.error).toContain('Product skill is not enabled for this canvas') |
| 198 | }) |
| 199 | |
| 200 | it('bundles dedicated layout skills for every supported canvas', async () => { |
| 201 | for (const skillName of [ |
| 202 | LAYOUT_SKILL_NAME, |
| 203 | VERTICAL_9_16_LAYOUT_SKILL_NAME, |
| 204 | STANDARD_4_3_LAYOUT_SKILL_NAME, |
| 205 | SQUARE_1_1_LAYOUT_SKILL_NAME, |
| 206 | VERTICAL_3_4_LAYOUT_SKILL_NAME, |
| 207 | RED_LAYOUT_SKILL_NAME |
| 208 | ]) { |
| 209 | expect(REQUIRED_PRODUCT_SKILL_NAMES).toContain(skillName) |
| 210 | const raw = await readFile( |
| 211 | path.join(process.cwd(), 'resources', 'skills', skillName, 'skill.json'), |
| 212 | 'utf8' |
| 213 | ) |
| 214 | expect(JSON.parse(raw)).toMatchObject({ name: skillName, source: 'builtin' }) |
| 215 | await expect( |
| 216 | readFile(path.join(process.cwd(), 'resources', 'skills', skillName, 'SKILL.md'), 'utf8') |
| 217 | ).resolves.toContain(`name: ${skillName}`) |
| 218 | } |
| 219 | }) |
| 220 | }) |
| 221 |