mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(daemon): inject @-mention skills into system prompt (#2552)
* fix(daemon): inject @-mention skills into system prompt Generated-By: looper 0.8.1 (runner=worker, agent=opencode) * fix(daemon): compose ad-hoc skill mode and aliases Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): lazily load and stage ad-hoc skills Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * test(daemon): assert staged skill files before spawn Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): compose skill metadata across @ mentions Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * test(daemon): cover ad-hoc critique skill policy Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): preserve plugin skill composition Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): resolve conflicting composed skill surfaces Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): preserve primary skill surface Generated-By: looper 0.8.1 (runner=fixer, agent=opencode) * fix(daemon): share resolved critique surface Generated-By: looper 0.8.1 (runner=fixer, agent=opencode)
This commit is contained in:
parent
682a9c9a9a
commit
052f8097de
9 changed files with 870 additions and 60 deletions
|
|
@ -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 `<sourceDir>` to `<cwd>/.od-skills/<folderName>/` so an agent can
|
||||
* reach skill side files via a cwd-relative path. Idempotent and
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -183,6 +183,37 @@ type AudioVoiceOption = {
|
|||
labels?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type ExclusiveSurfaceMode = 'deck' | 'image' | 'video' | 'audio';
|
||||
|
||||
const EXCLUSIVE_SURFACE_MODES = new Set<ExclusiveSurfaceMode>(['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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof listAllSkillLikeEntries> | 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<NonNullable<Parameters<typeof composeSystemPrompt>[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<Parameters<typeof composeSystemPrompt>[0]['skillMode']> | null | undefined,
|
||||
) => {
|
||||
if (!mode) return;
|
||||
skillModes.add(mode);
|
||||
};
|
||||
const registerPrimarySkillMode = (
|
||||
mode: NonNullable<Parameters<typeof composeSystemPrompt>[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 <CRITIQUE_RUN> 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
|
||||
// `<cwd>/.od-skills/<folder>/` 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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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('<CRITIQUE_RUN') ? 'unexpected-critique-panel' : 'critique-panel-disabled-by-skill-policy',
|
||||
];
|
||||
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',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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:');
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue