open-design/apps/daemon/tests/prompts/system.test.ts
Tom Huang e11e86d468
feat(hyperframes): land HTML-in-Canvas across web + skills (#866)
* feat(hyperframes): land HTML-in-Canvas across web + skills

Ships HTML-in-Canvas as a first-class HyperFrames video path:
- 7 new video prompt templates (liquid glass, iPhone+MacBook, portal,
  shatter, magnetic, liquid background, text-cursor reveal).
- skills/hyperframes/references/html-in-canvas.md, surfaced via
  SKILL.md description+triggers and the system-prompt pre-flight
  references list.
- ChatPane starter prompts now branch by project kind and video model,
  so the hyperframes-html surface shows HTML-in-canvas-shaped prompts
  instead of the generic prototype trio.
- NewProjectPanel propagates a picked template's model+aspect onto
  the project, and defaults videoModel to hyperframes-html when the
  hyperframes skill resolves for the video tab.

Polish bundled in the same branch:
- DesignFilesPanel empty state becomes a centered pill with a "New
  sketch" CTA; designFiles.empty copy simplified across 19 locales.
- Topbar project title + meta render on one baseline row separated
  by a middot.
- scripts/seed-test-projects.ts hardens daemon URL discovery against
  pnpm engine warnings on stdout.

* fix(new-project): preserve explicit video model choice across tab revisits

Latch a videoModelTouched guard once the user picks a model via the
dropdown or via a template that declares one, so the hyperframes-html
auto-default no longer silently overwrites the override when the Video
tab is re-entered.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(i18n): register hyperframes html-in-canvas templates, category, and tags

Adds the seven new prompt-template ids, the "VFX / HTML-in-Canvas"
category, and the new tag set to the de/ru/fr i18n bundles so the
e2e localized-content coverage test passes.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* fix(daemon): inject html-in-canvas preflight for hyperframes runs

The contracts-side derivePreflight() learned about
references/html-in-canvas.md when this PR landed, but the daemon
copy at apps/daemon/src/prompts/system.ts kept the older five-ref
allowlist. server.ts:4138 wires composeSystemPrompt from the
daemon copy into live chat runs, so the main HyperFrames flow this
PR is meant to improve still wasn't auto-injecting the preflight
directive in production.

Mirror the html-in-canvas case into the daemon composer and lock it
behind a daemon-side test so the two copies cannot drift again on
this reference. The broader live-artifact preflight gap (artifact-
schema / connector-policy / refresh-contract) is pre-existing drift
and is intentionally out of scope here.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): restyle designs empty state as centered card on grid backdrop

Swap the horizontal pill for a stacked card and add a faint grid backdrop
so the empty designs surface reads as an intentional canvas rather than a
gap. Title now wraps instead of truncating; container is taller.

* fix(new-project): pin skillId to hyperframes when videoModel is hyperframes-html

When the Video tab resolves its skill it used to fall back to `list[0]?.id`
if no skill declared `default_for: video`. That list is built from an
unsorted `readdir()` in apps/daemon/src/skills.ts, so a freshly mounted
project could land on `video-shortform` even when the user had explicitly
chosen the HyperFrames-HTML model (or one of the new
`hyperframes-html-in-canvas-*` templates). The agent then ran without the
hyperframes SKILL body or its `references/html-in-canvas.md` preflight —
the exact regression PR #866 was meant to land.

`skillIdForTab` now pins to `hyperframes` whenever the current video model
is `hyperframes-html`, regardless of discovery order. Added a unit test
that mounts both `video-shortform` and `hyperframes` (with hyperframes
last, simulating the bad readdir order) and asserts the create payload
routes through `hyperframes`.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 15:45:12 +08:00

197 lines
9.4 KiB
TypeScript

import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { composeSystemPrompt } from '../../src/prompts/system.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../../..');
const liveArtifactRoot = path.join(repoRoot, 'skills/live-artifact');
const liveArtifactSkillPath = path.join(repoRoot, 'skills/live-artifact/SKILL.md');
const liveArtifactSkillMarkdown = readFileSync(liveArtifactSkillPath, 'utf8');
const liveArtifactSkillBody = [
`> **Skill root (absolute):** \`${liveArtifactRoot}\``,
'>',
'> This skill ships side files alongside `SKILL.md`. When the workflow',
'> below references side files such as `references/artifact-schema.md`, resolve',
'> them against the skill root above and open them via their full absolute path.',
'>',
'> Known side files in this skill: `references/artifact-schema.md`, `references/connector-policy.md`, `references/refresh-contract.md`.',
'',
'',
liveArtifactSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(),
].join('\n');
const hyperframesRoot = path.join(repoRoot, 'skills/hyperframes');
const hyperframesSkillPath = path.join(repoRoot, 'skills/hyperframes/SKILL.md');
const hyperframesSkillMarkdown = readFileSync(hyperframesSkillPath, 'utf8');
const hyperframesSkillBody = [
`> **Skill root (absolute):** \`${hyperframesRoot}\``,
'>',
'> This skill ships side files alongside `SKILL.md`. Resolve references',
'> like `references/html-in-canvas.md` against the skill root above.',
'',
'',
hyperframesSkillMarkdown.replace(/^---[\s\S]*?---\n\n/, '').trim(),
].join('\n');
describe('composeSystemPrompt', () => {
it('injects live-artifact skill guidance and metadata intent', () => {
const prompt = composeSystemPrompt({
skillName: 'live-artifact',
skillMode: 'prototype',
skillBody: liveArtifactSkillBody,
metadata: {
kind: 'prototype',
intent: 'live-artifact',
} as any,
});
expect(prompt).toContain('## Active skill — live-artifact');
expect(prompt).toContain(`> **Skill root (absolute):** \`${liveArtifactRoot}\``);
expect(prompt).not.toContain('**Pre-flight (do this before any other tool):** Read `assets/template.html`');
expect(prompt).not.toContain('live-artifact/references/layouts.md');
expect(prompt).not.toContain('live-artifact/assets/template.html');
expect(prompt).toContain('`references/artifact-schema.md`');
expect(prompt).toContain('`references/connector-policy.md`');
expect(prompt).toContain('`references/refresh-contract.md`');
expect(prompt).toContain('The wrapper reads injected `OD_NODE_BIN`, `OD_BIN`, `OD_DAEMON_URL`, and `OD_TOOL_TOKEN`');
expect(prompt).toContain('Do not include or invent `projectId`; the daemon derives project/run scope from the token.');
expect(prompt).toContain('"$OD_NODE_BIN" "$OD_BIN" tools live-artifacts create --input artifact.json');
expect(prompt).toContain('if the user names a connector/source (for example Notion)');
expect(prompt).toContain('list connectors before asking where the data comes from');
expect(prompt).toContain('a connected `notion` connector plus a user brief that names Notion is enough to start with `notion.notion_search`');
expect(prompt).toContain('Prefer the `live-artifact` skill workflow when available');
expect(prompt).toContain('The first output should be a live artifact/dashboard/report');
});
// The daemon composer (this file) is what apps/daemon/src/server.ts wires
// into live chat runs. The contracts copy at packages/contracts/src/prompts
// /system.ts exists for non-daemon contexts and was updated in the
// hyperframes PR; without this test the two copies drift silently and the
// main HyperFrames flow misses its preflight directive in production.
it('injects the html-in-canvas preflight for the hyperframes skill', () => {
const prompt = composeSystemPrompt({
skillName: 'hyperframes',
skillMode: 'video',
skillBody: hyperframesSkillBody,
metadata: {
kind: 'video',
videoModel: 'hyperframes-html',
} as any,
});
expect(prompt).toContain('## Active skill — hyperframes');
expect(prompt).toContain('**Pre-flight (do this before any other tool):**');
expect(prompt).toContain('`references/html-in-canvas.md`');
});
describe('artifact handoff no-emit clauses (#1143)', () => {
it('drops the absolute "non-negotiable" framing in favor of conditional language', () => {
const prompt = composeSystemPrompt({});
expect(prompt).not.toContain('non-negotiable output rule');
});
it('includes the "When NOT to emit <artifact>" sub-section', () => {
const prompt = composeSystemPrompt({});
expect(prompt).toContain('When NOT to emit `<artifact>`');
});
it('forbids wrapping in-place-edit-only turns in an artifact block', () => {
const prompt = composeSystemPrompt({});
expect(prompt).toMatch(/in-place|Edit-only|already-existing/i);
expect(prompt).toMatch(/do not (emit|wrap|send) (a |an )?`?<artifact/i);
});
it('forbids putting prose / summaries / paths inside an artifact block', () => {
const prompt = composeSystemPrompt({});
expect(prompt).toMatch(/complete `?<!doctype html>`?/i);
expect(prompt).toMatch(/summar(y|ies)|prose|file path/i);
});
it('does not carry unconditional "Emit single <artifact>" / "emit a single <artifact>" lines anywhere in the composed prompt', () => {
const prompt = composeSystemPrompt({});
// Discovery layer used to carry hard-rule unconditional emit instructions
// (plan template step 9, default arc Turn 3+ recap, deck workflow step 7).
// Those must be conditional now — otherwise the no-emit exception in the
// base prompt is overridden by the higher-priority discovery layer.
expect(prompt).not.toMatch(/^- 9\.\s+Emit single <artifact>\s*$/m);
expect(prompt).not.toMatch(/emit a single `?<artifact>`?\.\s*$/m);
expect(prompt).not.toMatch(/^7\.\s+Emit single <artifact>\s*$/m);
});
it('declares artifact-emission conditionality at the dominant discovery layer', () => {
const prompt = composeSystemPrompt({});
// The base prompt's "When NOT to emit" section is at lower precedence than
// DISCOVERY_AND_PHILOSOPHY, so the exception itself must be stated once at
// the dominant layer (near RULE 3) — not only back-pointed.
expect(prompt).toMatch(/only when this turn wrote a new canonical HTML/i);
expect(prompt).toMatch(/only edited an existing HTML file/i);
});
it('also keeps deck-mode prompts free of the unconditional emit line (DECK_FRAMEWORK_DIRECTIVE only stacks for deck projects)', () => {
// The plain composeSystemPrompt({}) call does NOT include
// DECK_FRAMEWORK_DIRECTIVE; that directive only stacks when
// `skillMode === 'deck'` or `metadata.kind === 'deck'`. So if
// deck-framework.ts:327 ever regresses back to "Emit single <artifact>",
// a no-args negative assertion is a false negative — exercise the deck
// path explicitly here.
const deckPrompt = composeSystemPrompt({ skillMode: 'deck' });
expect(deckPrompt).not.toMatch(/^7\.\s+Emit single <artifact>\s*$/m);
expect(deckPrompt).toMatch(/Emit single <artifact> if a new canonical deck HTML/i);
});
});
describe('connectedExternalMcp directive', () => {
it('omits the directive when no servers are passed', () => {
const prompt = composeSystemPrompt({});
expect(prompt).not.toContain('External MCP servers — already authenticated');
expect(prompt).not.toContain('mcp__<server>__authenticate');
});
it('omits the directive when an empty array is passed', () => {
const prompt = composeSystemPrompt({ connectedExternalMcp: [] });
expect(prompt).not.toContain('External MCP servers — already authenticated');
});
it('lists each connected server and forbids the synthetic auth tools', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [
{ id: 'higgsfield-openclaw', label: 'Higgsfield (OpenClaw)' },
{ id: 'github' },
],
});
expect(prompt).toContain('## External MCP servers — already authenticated');
expect(prompt).toContain('`higgsfield-openclaw`');
expect(prompt).toContain('Higgsfield (OpenClaw)');
expect(prompt).toContain('`github`');
expect(prompt).toContain(
'**Do NOT call any tool whose name matches `mcp__<server>__authenticate` or `mcp__<server>__complete_authentication`',
);
expect(prompt).toContain('localhost:<random>/callback');
expect(prompt).toContain('Settings → External MCP');
});
it('skips entries with blank ids and emits no directive when nothing usable remains', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [
{ id: ' ', label: 'blank' },
{ id: '', label: 'empty' },
] as any,
});
expect(prompt).not.toContain('External MCP servers — already authenticated');
});
it('does not duplicate the label when it equals the id', () => {
const prompt = composeSystemPrompt({
connectedExternalMcp: [{ id: 'github', label: 'github' }],
});
expect(prompt).toContain('- `github`\n');
expect(prompt).not.toContain('- `github` (github)');
});
});
});