diff --git a/apps/daemon/src/cwd-aliases.ts b/apps/daemon/src/cwd-aliases.ts index 98bb36a5b..73806443d 100644 --- a/apps/daemon/src/cwd-aliases.ts +++ b/apps/daemon/src/cwd-aliases.ts @@ -28,6 +28,7 @@ // source root so an environment that puts `skills/` itself behind a // symlink (e.g. a content-addressable mount) is followed correctly. +import { createHash } from 'node:crypto'; import { cp, lstat, rm, stat } from 'node:fs/promises'; import path from 'node:path'; @@ -44,6 +45,13 @@ export interface SkillStagingResult { reason?: string; } +export function skillCwdAliasSegment(dir: string): string { + const folder = path.basename(dir) || 'skill'; + const normalizedDir = path.resolve(dir).replaceAll('\\', '/'); + const digest = createHash('sha256').update(normalizedDir).digest('hex').slice(0, 10); + return `${folder}-${digest}`; +} + /** * Copy `` to `/.od-skills//` so an agent can * reach skill side files via a cwd-relative path. Idempotent and diff --git a/apps/daemon/src/orbit.ts b/apps/daemon/src/orbit.ts index 167ea4349..f197b1752 100644 --- a/apps/daemon/src/orbit.ts +++ b/apps/daemon/src/orbit.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import type { OrbitRunSummary, OrbitStatusResponse } from '@open-design/contracts/api/orbit'; import type { OrbitConfigPrefs } from './app-config.js'; +import { skillCwdAliasSegment } from './cwd-aliases.js'; export interface OrbitConnectorRunResult { connectorId: string; @@ -418,9 +419,9 @@ export function buildOrbitSystemPrompt( 'Selected example template:', `- Skill id: ${template.id}`, `- Skill name: ${template.name}`, - `- Staged root: .od-skills/${path.basename(template.dir)}/`, + `- Staged root: .od-skills/${skillCwdAliasSegment(template.dir)}/`, '', - `Before writing the artifact, read ".od-skills/${path.basename(template.dir)}/SKILL.md" and, if present, ".od-skills/${path.basename(template.dir)}/example.html". Follow that staged template's structure, layout, tokens, domain rules, and visual language as the source of truth. The staged template is for visual/domain guidance; still use the live-artifact workflow to register the final artifact.`, + `Before writing the artifact, read ".od-skills/${skillCwdAliasSegment(template.dir)}/SKILL.md" and, if present, ".od-skills/${skillCwdAliasSegment(template.dir)}/example.html". Follow that staged template's structure, layout, tokens, domain rules, and visual language as the source of truth. The staged template is for visual/domain guidance; still use the live-artifact workflow to register the final artifact.`, '', 'Selected template example prompt:', '', diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts index 8031394b4..dbe9638dd 100644 --- a/apps/daemon/src/prompts/system.ts +++ b/apps/daemon/src/prompts/system.ts @@ -183,6 +183,37 @@ type AudioVoiceOption = { labels?: Record | null; }; +type ExclusiveSurfaceMode = 'deck' | 'image' | 'video' | 'audio'; + +const EXCLUSIVE_SURFACE_MODES = new Set(['deck', 'image', 'video', 'audio']); + +export function resolveExclusiveSurface(args: { + metadata?: ProjectMetadata | undefined; + skillMode?: ComposeInput['skillMode'] | undefined; + skillModes?: ComposeInput['skillModes'] | undefined; +}): ExclusiveSurfaceMode | null { + const activeSkillModes = new Set( + Array.isArray(args.skillModes) + ? args.skillModes.filter(Boolean) + : args.skillMode + ? [args.skillMode] + : [], + ); + const metadataSurface = EXCLUSIVE_SURFACE_MODES.has(args.metadata?.kind as ExclusiveSurfaceMode) + ? args.metadata?.kind as ExclusiveSurfaceMode + : null; + const primarySkillSurface = EXCLUSIVE_SURFACE_MODES.has(args.skillMode as ExclusiveSurfaceMode) + ? args.skillMode as ExclusiveSurfaceMode + : null; + const composedSurfaceModes = Array.from(activeSkillModes).filter((mode): mode is ExclusiveSurfaceMode => + EXCLUSIVE_SURFACE_MODES.has(mode as ExclusiveSurfaceMode), + ); + + return metadataSurface + ?? primarySkillSurface + ?? (composedSurfaceModes.length === 1 ? composedSurfaceModes[0] ?? null : null); +} + export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT; export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip discovery form @@ -235,6 +266,7 @@ export interface ComposeInput { | 'video' | 'audio' | undefined; + skillModes?: Array<'prototype' | 'deck' | 'template' | 'design-system' | 'image' | 'video' | 'audio'> | undefined; designSystemBody?: string | undefined; designSystemTitle?: string | undefined; // Compiled (machine-readable) form of the active brand's design system, @@ -346,6 +378,7 @@ export function composeSystemPrompt({ skillBody, skillName, skillMode, + skillModes, designSystemBody, designSystemTitle, designSystemUsageMd, @@ -378,6 +411,14 @@ export function composeSystemPrompt({ // wording later in the official base prompt. const parts: string[] = []; const activeDesignSystemBody = designSystemBody?.trim(); + const activeSkillModes = new Set( + Array.isArray(skillModes) + ? skillModes.filter(Boolean) + : skillMode + ? [skillMode] + : [], + ); + const resolvedExclusiveSurface = resolveExclusiveSurface({ metadata, skillMode, skillModes }); // API/BYOK mode (streamFormat === 'plain'): mirrors the same fix from // `@open-design/contracts`'s composer. The daemon hits this path for @@ -534,8 +575,8 @@ export function composeSystemPrompt({ // skeleton would conflict. The skill-seed path takes over via // `derivePreflight` above, so we only fire the generic skeleton when no // skill seed is on offer. - const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck'; - const isFreeformProject = !skillMode && (!metadata || metadata.kind === 'other'); + const isDeckProject = resolvedExclusiveSurface === 'deck'; + const isFreeformProject = activeSkillModes.size === 0 && (!metadata || metadata.kind === 'other'); const hasSkillSeed = !!skillBody && /assets\/template\.html/.test(skillBody); if (isDeckProject && !hasSkillSeed) { @@ -556,12 +597,9 @@ export function composeSystemPrompt({ } const isMediaSurface = - skillMode === 'image' || - skillMode === 'video' || - skillMode === 'audio' || - metadata?.kind === 'image' || - metadata?.kind === 'video' || - metadata?.kind === 'audio'; + resolvedExclusiveSurface === 'image' + || resolvedExclusiveSurface === 'video' + || resolvedExclusiveSurface === 'audio'; if (isMediaSurface) { parts.push(MEDIA_GENERATION_CONTRACT); } diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 95c4743c9..3c9f8f66e 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -18,6 +18,7 @@ import { import { composeSystemPrompt, renderCodexImagegenOverride, + resolveExclusiveSurface, shouldRenderCodexImagegenOverride, } from './prompts/system.js'; import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js'; @@ -56,7 +57,12 @@ export { signDesktopImportToken, verifyDesktopImportToken, } from './desktop-auth.js'; -import { findSkillById, listSkills, splitDerivedSkillId } from './skills.js'; +import { + findSkillById, + listSkills, + resolveSkillId, + splitDerivedSkillId, +} from './skills.js'; import { validateLinkedDirs } from './linked-dirs.js'; import { installFromTarget, uninstallById, sanitizeRepoName } from './library-install.js'; import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js'; @@ -215,7 +221,7 @@ import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js'; import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; import { loadCraftSections } from './craft.js'; -import { stageActiveSkill } from './cwd-aliases.js'; +import { skillCwdAliasSegment, stageActiveSkill } from './cwd-aliases.js'; import { buildDesktopPdfExportInput } from './pdf-export.js'; import { generateMedia } from './media.js'; import { listElevenLabsVoiceOptions } from './elevenlabs-voices.js'; @@ -8765,6 +8771,7 @@ export async function startServer({ agentId, projectId, skillId, + skillIds, designSystemId, streamFormat, locale, @@ -8782,35 +8789,131 @@ export async function startServer({ ? designSystemId : project?.designSystemId; const metadata = project?.metadata; + let allSkillsPromise: ReturnType | null = null; + const loadAllSkills = async () => { + allSkillsPromise ??= listAllSkillLikeEntries(); + return await allSkillsPromise; + }; + + // Per-turn skills picked via the composer's @-mention popover. They + // never persist on the project — we just append their bodies after the + // primary skill so the agent sees one combined block this turn. + const effectiveCanonicalSkillId = + typeof effectiveSkillId === 'string' && effectiveSkillId + ? resolveSkillId(effectiveSkillId) + : null; + const adHocSkillIds = Array.isArray(skillIds) + ? skillIds + .map((s) => (typeof s === 'string' ? s.trim() : '')) + .filter(Boolean) + .filter((id) => resolveSkillId(id) !== effectiveCanonicalSkillId) + : []; let skillBody; let skillName; let skillMode; + const skillModes = new Set[0]['skillMode']>>(); let skillCraftRequires = []; let activeSkillDir = null; + const activeSkillDirs: string[] = []; // Per-skill Critique Theater override sourced from // `od.critique.policy` in the resolved skill's SKILL.md frontmatter. // `null` means the skill has no opinion and the lower-priority tiers // (project override, env override, rollout phase default) decide. let skillCritiquePolicy: SkillCritiquePolicy = null; + let critiqueSkillId = effectiveCanonicalSkillId; + const registerSkillMode = ( + mode: NonNullable[0]['skillMode']> | null | undefined, + ) => { + if (!mode) return; + skillModes.add(mode); + }; + const registerPrimarySkillMode = ( + mode: NonNullable[0]['skillMode']> | null | undefined, + ) => { + if (!mode) return; + skillMode ??= mode; + registerSkillMode(mode); + }; + const registerSkillDir = (dir: string | null | undefined) => { + if (typeof dir !== 'string' || dir.length === 0) return; + if (!activeSkillDir) activeSkillDir = dir; + if (!activeSkillDirs.includes(dir)) activeSkillDirs.push(dir); + }; + const mergeSkillCritiquePolicy = ( + current: SkillCritiquePolicy, + next: SkillCritiquePolicy, + ): SkillCritiquePolicy => { + if (next === 'opt-out') return 'opt-out'; + if (next === 'required') return current === 'opt-out' ? current : 'required'; + if (next === 'opt-in') { + return current === 'required' || current === 'opt-out' ? current : 'opt-in'; + } + return current; + }; if (effectiveSkillId) { // Span both functional skills and design templates so a project // saved against either surface keeps its system prompt after the // skills/design-templates split. See specs/current/skills-and-design-templates.md. - const skill = findSkillById( - await listAllSkillLikeEntries(), - effectiveSkillId, - ); + const allSkills = await loadAllSkills(); + const skill = findSkillById(allSkills, effectiveSkillId); if (skill) { skillBody = skill.body; skillName = skill.name; - skillMode = skill.mode; - activeSkillDir = skill.dir; - skillCritiquePolicy = skill.critiquePolicy; + registerPrimarySkillMode(skill.mode); + registerSkillDir(skill.dir); + skillCritiquePolicy = mergeSkillCritiquePolicy( + skillCritiquePolicy, + skill.critiquePolicy, + ); if (Array.isArray(skill.craftRequires)) skillCraftRequires = skill.craftRequires; } } + let composedSkillBlocks = ''; + if (adHocSkillIds.length > 0) { + const allSkills = await loadAllSkills(); + const seen = new Set( + effectiveCanonicalSkillId ? [String(effectiveCanonicalSkillId)] : [], + ); + const blocks = []; + const baseBody = skillBody && skillBody.trim().length > 0 ? skillBody : ''; + for (const id of adHocSkillIds) { + const canonicalId = resolveSkillId(id); + if (typeof canonicalId !== 'string' || canonicalId.length === 0) continue; + if (seen.has(canonicalId)) continue; + seen.add(canonicalId); + const extra = findSkillById(allSkills, id); + if (!extra) continue; + registerSkillDir(extra.dir); + registerSkillMode(extra.mode); + if (!effectiveCanonicalSkillId && adHocSkillIds.length === 1) { + registerPrimarySkillMode(extra.mode); + } + if (!critiqueSkillId || extra.critiquePolicy !== null) critiqueSkillId = canonicalId; + skillCritiquePolicy = mergeSkillCritiquePolicy( + skillCritiquePolicy, + extra.critiquePolicy, + ); + if (Array.isArray(extra.craftRequires)) { + for (const craft of extra.craftRequires) { + if (!skillCraftRequires.includes(craft)) skillCraftRequires.push(craft); + } + } + blocks.push( + `\n\n---\n\n## Composed skill — ${extra.name || id}\n\n${(extra.body || '').trim()}`, + ); + } + if (blocks.length > 0) { + composedSkillBlocks = blocks.join(''); + skillBody = baseBody + composedSkillBlocks; + if (!skillName) { + skillName = adHocSkillIds.length === 1 + ? findSkillById(allSkills, adHocSkillIds[0])?.name ?? null + : 'composed'; + } + } + } // Stage A of plugin-driven-flow-plan: when the run is bound to a // plugin snapshot, prefer the plugin's local SKILL.md (declared via @@ -8830,9 +8933,10 @@ export async function startServer({ const { loadPluginLocalSkill } = await import('./plugins/local-skill.js'); const local = await loadPluginLocalSkill(plugin); if (local) { - skillBody = local.body; + skillBody = local.body + composedSkillBlocks; skillName = local.name; activeSkillDir = local.dir; + registerSkillDir(local.dir); } } } @@ -9001,8 +9105,8 @@ export async function startServer({ && typeof designSystemBody === 'string' ? { name: designSystemTitle, design_md: designSystemBody } : undefined; - const critiqueSkill = critiqueEnabledForRun && typeof effectiveSkillId === 'string' - ? { id: effectiveSkillId } + const critiqueSkill = critiqueEnabledForRun && typeof critiqueSkillId === 'string' + ? { id: critiqueSkillId } : undefined; // Single-source-of-truth eligibility check. The composer downstream // appends instructions only when this check passes, and @@ -9016,13 +9120,15 @@ export async function startServer({ // panel addendum has to be suppressed here too: otherwise the model // is instructed to emit Critique Theater tags that no orchestrator // consumes. + const resolvedExclusiveSurface = resolveExclusiveSurface({ + metadata, + skillMode, + skillModes: skillModes.size > 0 ? Array.from(skillModes) : undefined, + }); const isMediaSurface = - skillMode === 'image' || - skillMode === 'video' || - skillMode === 'audio' || - metadata?.kind === 'image' || - metadata?.kind === 'video' || - metadata?.kind === 'audio'; + resolvedExclusiveSurface === 'image' + || resolvedExclusiveSurface === 'video' + || resolvedExclusiveSurface === 'audio'; const isPlainAdapter = (streamFormat ?? 'plain') === 'plain'; const critiqueShouldRun = critiqueEnabledForRun && critiqueBrand !== undefined @@ -9089,6 +9195,7 @@ export async function startServer({ skillBody, skillName, skillMode, + skillModes: skillModes.size > 0 ? Array.from(skillModes) : undefined, designSystemBody, designSystemTitle, designSystemUsageMd, @@ -9132,7 +9239,7 @@ export async function startServer({ // `listSkills()` scan in `startChatRun`. critiqueShouldRun threads // the same panel-eligibility decision down to the spawn-path // orchestrator gate so prompt and orchestrator stay in lockstep. - return { prompt, activeSkillDir, critiqueShouldRun }; + return { prompt, activeSkillDir, activeSkillDirs, critiqueShouldRun }; }; // Plan §3.I1 / §3.D / spec §10.1: fire the pipeline schedule on a @@ -9222,6 +9329,7 @@ export async function startServer({ assistantMessageId, clientRequestId, skillId, + skillIds, designSystemId, attachments = [], commentAttachments = [], @@ -9457,11 +9565,16 @@ export async function startServer({ .filter((s) => typeof oauthTokensForSpawn[s.id] === 'string') .map((s) => ({ id: s.id, label: s.label })); - const { prompt: daemonSystemPrompt, activeSkillDir, critiqueShouldRun } = + const { + prompt: daemonSystemPrompt, + activeSkillDirs, + critiqueShouldRun, + } = await composeDaemonSystemPrompt({ agentId, projectId, skillId, + skillIds, designSystemId, streamFormat: def?.streamFormat ?? 'plain', locale, @@ -9477,11 +9590,11 @@ export async function startServer({ // advertises both the cwd-relative path (1) and the absolute path // (2/3) so the agent can pick whichever works. // - // 1. CWD-relative copy. Stage the *active* skill into + // 1. CWD-relative copy. Stage every active/composed skill into // `/.od-skills//` so any agent CLI — not just the // ones that honour `--add-dir` — can reach those files via a // path inside its working directory. We copy (not symlink) so - // the staged directory is a true write barrier — agents cannot + // each staged directory is a true write barrier — agents cannot // mutate the shipped repo resource through their cwd. // 2. `--add-dir` allowlist. For non-Codex agents, pass `SKILLS_DIR` // and `DESIGN_SYSTEMS_DIR` so the absolute fallback path in the @@ -9499,17 +9612,19 @@ export async function startServer({ // daemon and folded into the system prompt directly (see // `readDesignSystem`), so an agent never has to open them via the // filesystem. - if (cwd && activeSkillDir) { - const result = await stageActiveSkill( - cwd, - path.basename(activeSkillDir), - activeSkillDir, - (msg) => console.warn(msg), - ); - if (!result.staged) { - console.warn( - `[od] skill-stage skipped: ${result.reason ?? 'unknown reason'}; falling back to absolute paths`, + if (cwd && activeSkillDirs.length > 0) { + for (const skillDir of activeSkillDirs) { + const result = await stageActiveSkill( + cwd, + skillCwdAliasSegment(skillDir), + skillDir, + (msg) => console.warn(msg), ); + if (!result.staged) { + console.warn( + `[od] skill-stage skipped: ${result.reason ?? 'unknown reason'}; falling back to absolute paths`, + ); + } } } // Resolve the agent's effective working directory once and use it @@ -10699,7 +10814,7 @@ export async function startServer({ const cwd = await ensureProject(PROJECTS_DIR, projectId); const result = await stageActiveSkill( cwd, - path.basename(template.dir), + skillCwdAliasSegment(template.dir), template.dir, (msg) => console.warn(msg), ); diff --git a/apps/daemon/src/skills.ts b/apps/daemon/src/skills.ts index 1ed1a120b..99a7ecefd 100644 --- a/apps/daemon/src/skills.ts +++ b/apps/daemon/src/skills.ts @@ -11,7 +11,7 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promi import path from "node:path"; import { parseFrontmatter } from "./frontmatter.js"; import type { SkillCritiquePolicy } from "./critique/rollout.js"; -import { SKILLS_CWD_ALIAS } from "./cwd-aliases.js"; +import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from "./cwd-aliases.js"; // Persisted skill ids on existing projects can outlive a folder rename. // listSkills() derives the id from the SKILL.md frontmatter `name`, so once @@ -396,7 +396,7 @@ export function splitDerivedSkillId(id: unknown): DerivedSkillIdParts | null { // the right form on its own without daemon-side feature detection. function withSkillRootPreamble(body: string, dir: string): string { const referencedFiles = collectReferencedSideFiles(body); - const folder = path.basename(dir); + const folder = skillCwdAliasSegment(dir); const skillRootRel = `${SKILLS_CWD_ALIAS}/${folder}`; const exampleFile = referencedFiles[0]; const relativeGuidance = exampleFile diff --git a/apps/daemon/tests/chat-route.test.ts b/apps/daemon/tests/chat-route.test.ts index a38018370..57579ce52 100644 --- a/apps/daemon/tests/chat-route.test.ts +++ b/apps/daemon/tests/chat-route.test.ts @@ -24,6 +24,7 @@ import { startServer, validateCodexGeneratedImagesDir, } from '../src/server.js'; +import { skillCwdAliasSegment } from '../src/cwd-aliases.js'; import { getAgentDef } from '../src/agents.js'; import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js'; import { renderCodexImagegenOverride } from '../src/prompts/system.js'; @@ -68,6 +69,39 @@ describe('/api/chat', () => { const originalAgentHome = process.env.OD_AGENT_HOME; const tempDirs: string[] = []; + async function createPluginFixture(args: { + pluginId: string; + dirName: string; + localSkillPath?: string; + }): Promise { + const root = await fsp.mkdtemp(join(tmpdir(), 'od-plugin-fixture-')); + tempDirs.push(root); + const fixtureDir = resolve(root, args.dirName); + const baseFixtureDir = resolve( + process.cwd(), + 'tests', + 'fixtures', + 'plugin-fixtures', + 'sample-plugin', + ); + await fsp.cp(baseFixtureDir, fixtureDir, { recursive: true }); + const manifestPath = resolve(fixtureDir, 'open-design.json'); + const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) as { + name: string; + title: string; + od?: { context?: { skills?: Array<{ ref?: string; path?: string }> } }; + }; + manifest.name = args.pluginId; + manifest.title = args.pluginId; + if (args.localSkillPath) { + manifest.od ??= {}; + manifest.od.context ??= {}; + manifest.od.context.skills = [{ path: args.localSkillPath }]; + } + await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); + return fixtureDir; + } + beforeAll(async () => { if (process.env.OD_DATA_DIR) { originalMemoryConfig = await readMemoryConfig(process.env.OD_DATA_DIR); @@ -180,6 +214,582 @@ process.exit(0); ); }); + it('injects @-mention skillIds into the composed system prompt', async () => { + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const checks = [ + prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header', + prompt.includes('# FAQ Page Skill') ? 'has-faq-skill-body' : 'missing-faq-skill-body', + prompt.includes('category filtering') ? 'has-faq-skill-content' : 'missing-faq-skill-content', + ]; + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + message: 'build an faq page', + skillIds: ['faq-page'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('has-composed-skill-header'); + expect(body).toContain('has-faq-skill-body'); + expect(body).toContain('has-faq-skill-content'); + expect(body).not.toContain('missing-composed-skill-header'); + expect(body).not.toContain('missing-faq-skill-body'); + expect(body).not.toContain('missing-faq-skill-content'); + }, + ); + }); + + it('stages ad-hoc skill side files into the project cwd', async () => { + const projectId = `project-${randomUUID()}`; + const stagedRelativePath = `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`; + const expectedChecklist = await fsp.readFile( + resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'), + 'utf8', + ); + + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Ad hoc staged skill project', + }), + }); + + expect(createProjectResponse.ok).toBe(true); + + const fakeAgentScript = ` +const fs = require('node:fs'); +const stagedChecklist = fs.readFileSync(${JSON.stringify(stagedRelativePath)}, 'utf8'); +if (stagedChecklist !== ${JSON.stringify(expectedChecklist)}) { + console.error('staged-skill-side-files-mismatch'); + process.exit(1); +} +process.stdin.resume(); +process.stdin.on('end', () => { + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: 'staged-skill-side-files-before-spawn' } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`; + + await withFakeAgent( + 'opencode', + fakeAgentScript, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + projectId, + message: 'draft the release notes', + skillIds: ['release-notes-one-pager'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('staged-skill-side-files-before-spawn'); + }, + ); + + const stagedFileResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/raw/${stagedRelativePath}`, + ); + const stagedFileBody = await stagedFileResponse.text(); + + expect(stagedFileResponse.ok).toBe(true); + expect(stagedFileBody).toBe(expectedChecklist); + }); + + it('stages side files for every composed skill into the project cwd', async () => { + const projectId = `project-${randomUUID()}`; + const stagedPaths = [ + `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`, + `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template'))}/references/checklist.md`, + ] as const; + const expectedBodies = await Promise.all( + [ + resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'), + resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template', 'references', 'checklist.md'), + ].map((file) => fsp.readFile(file, 'utf8')), + ); + + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Multi staged skill project', + }), + }); + + expect(createProjectResponse.ok).toBe(true); + + const fakeAgentScript = ` +const fs = require('node:fs'); +const stagedBodies = [ + fs.readFileSync(${JSON.stringify(stagedPaths[0])}, 'utf8'), + fs.readFileSync(${JSON.stringify(stagedPaths[1])}, 'utf8'), +]; +const expectedBodies = ${JSON.stringify(expectedBodies)}; +if (JSON.stringify(stagedBodies) !== JSON.stringify(expectedBodies)) { + console.error('multi-staged-skill-side-files-mismatch'); + process.exit(1); +} +process.stdin.resume(); +process.stdin.on('end', () => { + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: 'multi-staged-skill-side-files-before-spawn' } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`; + + await withFakeAgent( + 'opencode', + fakeAgentScript, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + projectId, + message: 'compose multiple skills', + skillIds: ['release-notes-one-pager', 'swiss-creative-mode-template'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('multi-staged-skill-side-files-before-spawn'); + }, + ); + }); + + it('propagates the composed skill mode for ad-hoc-only deck skills', async () => { + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const checks = [ + prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-deck-skill-header' : 'missing-deck-skill-header', + prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'has-deck-framework' : 'missing-deck-framework', + ]; + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + message: 'build an editorial brand deck', + skillIds: ['open-design-landing-deck'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('has-deck-skill-header'); + expect(body).toContain('has-deck-framework'); + expect(body).not.toContain('missing-deck-skill-header'); + expect(body).not.toContain('missing-deck-framework'); + }, + ); + }); + + it('preserves a persisted media skill as the primary surface over a composed deck mention', async () => { + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const checks = [ + prompt.includes('# imagegen') ? 'has-base-image-skill-body' : 'missing-base-image-skill-body', + prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-composed-deck-skill-header' : 'missing-composed-deck-skill-header', + prompt.includes('## Media generation contract (load-bearing — overrides softer wording above)') ? 'has-image-contract' : 'missing-image-contract', + prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'unexpected-deck-framework' : 'kept-deck-framework-out', + ]; + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + message: 'generate an image while also referencing a deck template', + skillId: 'imagegen', + skillIds: ['open-design-landing-deck'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('has-base-image-skill-body'); + expect(body).toContain('has-composed-deck-skill-header'); + expect(body).toContain('has-image-contract'); + expect(body).toContain('kept-deck-framework-out'); + expect(body).not.toContain('missing-base-image-skill-body'); + expect(body).not.toContain('missing-composed-deck-skill-header'); + expect(body).not.toContain('missing-image-contract'); + expect(body).not.toContain('unexpected-deck-framework'); + }, + ); + }); + + it('propagates ad-hoc skill critique policy into the chat resolver', async () => { + if (!process.env.OD_DATA_DIR) { + throw new Error('OD_DATA_DIR is required for user skill critique-policy tests'); + } + + const skillId = `critique-opt-out-${randomUUID()}`; + const skillDir = resolve(process.env.OD_DATA_DIR, 'skills', skillId); + const originalCritiqueEnabled = process.env.OD_CRITIQUE_ENABLED; + + await fsp.mkdir(skillDir, { recursive: true }); + await fsp.writeFile( + resolve(skillDir, 'SKILL.md'), + `--- +name: ${skillId} +description: Ad-hoc critique opt-out regression fixture. +od: + critique: + policy: opt-out +--- + +# Critique opt-out fixture + +This skill should suppress critique when selected through skillIds. +`, + 'utf8', + ); + + process.env.OD_CRITIQUE_ENABLED = 'true'; + + try { + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const checks = [ + prompt.includes('## Composed skill — ${skillId}') ? 'has-opt-out-skill-header' : 'missing-opt-out-skill-header', + prompt.includes(' { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + designSystemId: 'default', + message: 'draft an opt-out skill artifact', + skillIds: [skillId], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('has-opt-out-skill-header'); + expect(body).toContain('critique-panel-disabled-by-skill-policy'); + expect(body).not.toContain('missing-opt-out-skill-header'); + expect(body).not.toContain('unexpected-critique-panel'); + }, + ); + } finally { + if (originalCritiqueEnabled == null) { + delete process.env.OD_CRITIQUE_ENABLED; + } else { + process.env.OD_CRITIQUE_ENABLED = originalCritiqueEnabled; + } + await fsp.rm(skillDir, { recursive: true, force: true }); + } + }); + + it('preserves plugin-local and composed @-mention skills in plugin-bound runs', async () => { + const pluginId = `plugin-local-${randomUUID()}`; + const pluginFixtureDir = await createPluginFixture({ + pluginId, + dirName: `plugin-local-${randomUUID()}`, + localSkillPath: './SKILL.md', + }); + const installResponse = await fetch(`${baseUrl}/api/plugins/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ source: pluginFixtureDir }), + }); + const installBody = await installResponse.text(); + + expect(installResponse.status).toBe(200); + expect(installBody).toContain(`"id":"${pluginId}"`); + + const projectId = `project-${randomUUID()}`; + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Plugin-bound skill composition project', + pluginId, + pluginInputs: { topic: 'agentic design' }, + }), + }); + const createProjectBody = await createProjectResponse.json() as { + appliedPluginSnapshotId?: string; + }; + + expect(createProjectResponse.ok).toBe(true); + expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy(); + + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const checks = [ + prompt.includes('# Sample Plugin') ? 'has-plugin-skill-body' : 'missing-plugin-skill-body', + prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header', + prompt.includes('# FAQ Page Skill') ? 'has-composed-skill-body' : 'missing-composed-skill-body', + ]; + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const createRunResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + projectId, + message: 'build a plugin-backed faq page', + appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId, + skillIds: ['faq-page'], + }), + }); + const createRunBody = await createRunResponse.json() as { runId: string }; + + expect(createRunResponse.status).toBe(202); + + const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`); + const body = await readSseUntil(eventsResponse, 'event: final'); + + expect(body).toContain('has-plugin-skill-body'); + expect(body).toContain('has-composed-skill-header'); + expect(body).toContain('has-composed-skill-body'); + expect(body).not.toContain('missing-plugin-skill-body'); + expect(body).not.toContain('missing-composed-skill-header'); + expect(body).not.toContain('missing-composed-skill-body'); + }, + ); + }); + + it('stages colliding plugin and composed skill dirs under distinct aliases', async () => { + if (!process.env.OD_DATA_DIR) { + throw new Error('OD_DATA_DIR is required for colliding skill-dir staging tests'); + } + + const pluginId = `plugin-collision-${randomUUID()}`; + const pluginFixtureDir = await createPluginFixture({ + pluginId, + dirName: 'sample-plugin', + localSkillPath: './SKILL.md', + }); + const installResponse = await fetch(`${baseUrl}/api/plugins/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' }, + body: JSON.stringify({ source: pluginFixtureDir }), + }); + const installBody = await installResponse.text(); + + expect(installResponse.status).toBe(200); + expect(installBody).toContain(`"id":"${pluginId}"`); + + const projectId = `project-${randomUUID()}`; + const userSkillDir = resolve(process.env.OD_DATA_DIR, 'skills', 'sample-plugin'); + const userChecklist = 'user-skill-checklist'; + const userAlias = skillCwdAliasSegment(userSkillDir); + + await fsp.mkdir(resolve(userSkillDir, 'references'), { recursive: true }); + await fsp.writeFile( + resolve(userSkillDir, 'SKILL.md'), + '# Sample-plugin side-file fixture\n\nRead references/checklist.md before drafting.', + 'utf8', + ); + await fsp.writeFile(resolve(userSkillDir, 'references', 'checklist.md'), userChecklist, 'utf8'); + + try { + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Colliding skill-dir project', + pluginId, + pluginInputs: { topic: 'agentic design' }, + }), + }); + const createProjectBody = await createProjectResponse.json() as { + appliedPluginSnapshotId?: string; + }; + const installedPluginResponse = await fetch(`${baseUrl}/api/plugins/${pluginId}`); + const installedPluginBody = await installedPluginResponse.json() as { fsPath: string }; + const pluginAlias = skillCwdAliasSegment(installedPluginBody.fsPath); + + expect(createProjectResponse.ok).toBe(true); + expect(installedPluginResponse.ok).toBe(true); + expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy(); + expect(pluginAlias).not.toBe(userAlias); + + await withFakeAgent( + 'opencode', + ` +const fs = require('node:fs'); +const pluginSkill = fs.readFileSync(${JSON.stringify(`.od-skills/${pluginAlias}/SKILL.md`)}, 'utf8'); +const userChecklist = fs.readFileSync(${JSON.stringify(`.od-skills/${userAlias}/references/checklist.md`)}, 'utf8'); +if (!pluginSkill.includes('# Sample Plugin')) { + console.error('plugin-skill-stage-missing'); + process.exit(1); +} +if (userChecklist !== ${JSON.stringify(userChecklist)}) { + console.error('colliding-skill-stage-mismatch'); + process.exit(1); +} +process.stdin.resume(); +process.stdin.on('end', () => { + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: 'colliding-skill-dirs-staged' } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const createRunResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + projectId, + message: 'use both plugin and user skill side files', + appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId, + skillIds: ['sample-plugin'], + }), + }); + const createRunBody = await createRunResponse.json() as { runId: string }; + + expect(createRunResponse.status).toBe(202); + + const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`); + const body = await readSseUntil(eventsResponse, 'event: final'); + + expect(body).toContain('colliding-skill-dirs-staged'); + }, + ); + } finally { + await fsp.rm(userSkillDir, { recursive: true, force: true }); + } + }); + + it('canonicalizes aliased skill ids before deduping composed skills', async () => { + await withFakeAgent( + 'opencode', + ` +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + prompt += chunk; +}); +process.stdin.on('end', () => { + const hasDuplicateComposedAlias = prompt.includes('## Composed skill — open-design-landing'); + const checks = [ + hasDuplicateComposedAlias ? 'duplicate-alias-composed-skill' : 'deduped-alias-composed-skill', + prompt.includes('# open-design-landing') ? 'has-base-alias-skill-body' : 'missing-base-alias-skill-body', + ]; + console.log(JSON.stringify({ type: 'step_start' })); + console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } })); + console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } })); + process.exit(0); +}); +`, + async () => { + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'opencode', + message: 'build the Open Design landing page', + skillId: 'editorial-collage', + skillIds: ['open-design-landing'], + }), + }); + const body = await response.text(); + + expect(response.ok).toBe(true); + expect(body).toContain('deduped-alias-composed-skill'); + expect(body).toContain('has-base-alias-skill-body'); + expect(body).not.toContain('duplicate-alias-composed-skill'); + expect(body).not.toContain('missing-base-alias-skill-body'); + }, + ); + }); + it('classifies Cursor Agent authentication stderr as a typed run error', async () => { await withFakeAgent( 'cursor-agent', diff --git a/apps/daemon/tests/orbit.test.ts b/apps/daemon/tests/orbit.test.ts index 7b6ebb27b..eaff1ad78 100644 --- a/apps/daemon/tests/orbit.test.ts +++ b/apps/daemon/tests/orbit.test.ts @@ -12,6 +12,7 @@ import { type OrbitRunHandler, type OrbitTemplateSelection, } from '../src/orbit.js'; +import { skillCwdAliasSegment } from '../src/cwd-aliases.js'; function formatExpectedLocalOrbitPromptTimestamp(date: Date): string { const yyyy = date.getFullYear(); @@ -80,11 +81,12 @@ describe('buildOrbitSystemPrompt', () => { }; const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template); + const stagedAlias = skillCwdAliasSegment(template.dir); expect(prompt).toContain('Skill id: orbit-general'); - expect(prompt).toContain('Staged root: .od-skills/orbit-general/'); - expect(prompt).toContain('read ".od-skills/orbit-general/SKILL.md"'); - expect(prompt).toContain('".od-skills/orbit-general/example.html"'); + expect(prompt).toContain(`Staged root: .od-skills/${stagedAlias}/`); + expect(prompt).toContain(`read ".od-skills/${stagedAlias}/SKILL.md"`); + expect(prompt).toContain(`".od-skills/${stagedAlias}/example.html"`); expect(prompt).toContain('visual/domain guidance'); expect(prompt).not.toContain('Selected template skill instructions:'); expect(prompt).toContain('Selected template example prompt:'); diff --git a/apps/daemon/tests/prompts/system.test.ts b/apps/daemon/tests/prompts/system.test.ts index c22496b7c..9e4c528eb 100644 --- a/apps/daemon/tests/prompts/system.test.ts +++ b/apps/daemon/tests/prompts/system.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { composeSystemPrompt } from '../../src/prompts/system.js'; +import { composeSystemPrompt, resolveExclusiveSurface } from '../../src/prompts/system.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -246,6 +246,34 @@ describe('composeSystemPrompt', () => { expect(prompt).not.toContain('**platformTargets**'); }); + it('uses the primary skill surface when composed skill modes conflict', () => { + const prompt = composeSystemPrompt({ + skillMode: 'image', + skillModes: ['deck', 'image'], + }); + + expect(prompt).toContain('## Media generation contract'); + expect(prompt).not.toContain('# Slide deck — fixed framework'); + }); + + it('lets metadata.kind win over conflicting composed skill modes', () => { + const prompt = composeSystemPrompt({ + skillMode: 'image', + skillModes: ['deck', 'image'], + metadata: { kind: 'deck' } as any, + }); + + expect(prompt).toContain('# Slide deck — fixed framework'); + expect(prompt).not.toContain('## Media generation contract'); + }); + + it('resolves a non-media primary surface ahead of composed media mentions', () => { + expect(resolveExclusiveSurface({ + skillMode: 'deck', + skillModes: ['deck', 'image'], + })).toBe('deck'); + }); + describe('artifact handoff no-emit clauses (#1143)', () => { it('drops the absolute "non-negotiable" framing in favor of conditional language', () => { const prompt = composeSystemPrompt({}); diff --git a/apps/daemon/tests/skills.test.ts b/apps/daemon/tests/skills.test.ts index 7bea73bdb..8e7b4fb0e 100644 --- a/apps/daemon/tests/skills.test.ts +++ b/apps/daemon/tests/skills.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { rmSync } from 'node:fs'; -import { SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js'; +import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from '../src/cwd-aliases.js'; import { readFileSync } from 'node:fs'; import { deleteUserSkill, @@ -87,14 +87,15 @@ describe('listSkills', () => { previewType: 'html', }); expect(skill.triggers.length).toBeGreaterThan(0); + const liveArtifactAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(liveArtifactRoot)}`; expect(skill.body).toContain(`> **Skill root (absolute fallback):** \`${liveArtifactRoot}\``); - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/live-artifact/`); + expect(skill.body).toContain(`${liveArtifactAlias}/`); expect(skill.body).toContain('references/artifact-schema.md'); expect(skill.body).toContain('references/connector-policy.md'); expect(skill.body).toContain('references/refresh-contract.md'); - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/artifact-schema.md`); - expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/assets/template.html`); - expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/live-artifact/references/layouts.md`); + expect(skill.body).toContain(`${liveArtifactAlias}/references/artifact-schema.md`); + expect(skill.body).not.toContain(`${liveArtifactAlias}/assets/template.html`); + expect(skill.body).not.toContain(`${liveArtifactAlias}/references/layouts.md`); expect(skill.body).toContain('"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json'); expect(skill.body).toContain('do not ask “where should the data come from?” before checking daemon connector tools'); expect(skill.body).toContain('notion.notion_search'); @@ -201,12 +202,14 @@ describe('listSkills preamble', () => { const skill = skills[0]; if (!skill) throw new Error('demo-skill not found'); + const demoAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'demo-skill'))}`; + // The cwd-relative alias path is the primary one — that's what makes // the agent stay inside its working directory when reading skill // side files (issue #430). - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/demo-skill/`); + expect(skill.body).toContain(`${demoAlias}/`); expect(skill.body).toContain( - `${SKILLS_CWD_ALIAS}/demo-skill/assets/template.html`, + `${demoAlias}/assets/template.html`, ); // The absolute fallback is required for two cases the relative path @@ -233,8 +236,10 @@ describe('listSkills preamble', () => { const skill = skills[0]; if (!skill) throw new Error('orbit-style skill not found'); - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/`); - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/orbit-style/example.html`); + const orbitAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'orbit-style'))}`; + + expect(skill.body).toContain(`${orbitAlias}/`); + expect(skill.body).toContain(`${orbitAlias}/example.html`); expect(skill.body).toContain('Known side files in this skill: `example.html`.'); }); @@ -250,12 +255,15 @@ describe('listSkills preamble', () => { const skill = skills[0]; if (!skill) throw new Error('magazine-web-ppt skill not found'); + const folderAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'guizang-ppt'))}`; + const frontmatterAlias = `${SKILLS_CWD_ALIAS}/${skillCwdAliasSegment(path.join(root, 'magazine-web-ppt'))}`; + // `id`/`name` reflect the frontmatter value (used elsewhere as a stable // public id), but the on-disk alias path must use the actual folder // name — that is what the daemon-staged junction maps to. expect(skill.id).toBe('magazine-web-ppt'); - expect(skill.body).toContain(`${SKILLS_CWD_ALIAS}/guizang-ppt/`); - expect(skill.body).not.toContain(`${SKILLS_CWD_ALIAS}/magazine-web-ppt/`); + expect(skill.body).toContain(`${folderAlias}/`); + expect(skill.body).not.toContain(`${frontmatterAlias}/`); }); it('does not emit a preamble for skills without side files', async () => {