Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions packages/core/src/generate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChatMessage, ModelRef, StoredDesignSystem } from '@open-codesign/shared';
import { CodesignError } from '@open-codesign/shared';
import { CodesignError, STORED_DESIGN_SYSTEM_SCHEMA_VERSION } from '@open-codesign/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';

const completeMock = vi.fn();
Expand Down Expand Up @@ -27,8 +27,14 @@ const RESPONSE = `Here is your design.
${SAMPLE_HTML}
</artifact>`;

const FENCED_RESPONSE = `Here is the revised HTML artifact.

\`\`\`html
${SAMPLE_HTML}
\`\`\``;

const DESIGN_SYSTEM: StoredDesignSystem = {
schemaVersion: 1,
schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION,
rootPath: '/repo',
summary: 'Muted neutrals with warm copper accents.',
extractedAt: '2026-04-18T00:00:00.000Z',
Expand Down Expand Up @@ -135,12 +141,30 @@ describe('generate()', () => {
if (!user) throw new Error('expected user message');
expect(user.content).toContain('design a warm landing page');
expect(user.content).toContain('Design system to follow');
expect(user.content).toContain('Repository: repo');
expect(user.content).toContain('Muted neutrals with warm copper accents.');
expect(user.content).toContain('brief.md');
expect(user.content).toContain('https://example.com');
expect(user.content).not.toContain('/repo');
expect(user.content).not.toContain('/tmp/brief.md');
});

it('falls back to fenced HTML when the model skips artifact tags', async () => {
completeMock.mockResolvedValueOnce({
content: FENCED_RESPONSE,
inputTokens: 3,
outputTokens: 4,
costUsd: 0,
});

const result = await generate({
prompt: 'design a dashboard',
history: [],
model: MODEL,
apiKey: 'sk-test',
});

expect(result.artifacts).toHaveLength(1);
expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
expect(result.message).toContain('Here is the revised HTML artifact.');
expect(result.message).not.toContain('```html');
});
});

Expand Down Expand Up @@ -193,5 +217,33 @@ describe('applyComment()', () => {
expect(user.content).toContain('#hero');
expect(user.content).toContain(SAMPLE_HTML);
expect(user.content).toContain('Muted neutrals with warm copper accents.');
expect(user.content).toContain('Prioritize the selected element first');
expect(user.content).toContain('Do not use Markdown code fences');
});

it('returns a parsed artifact for fenced revision responses', async () => {
completeMock.mockResolvedValueOnce({
content: FENCED_RESPONSE,
inputTokens: 0,
outputTokens: 0,
costUsd: 0,
});

const result = await applyComment({
html: SAMPLE_HTML,
comment: 'Make the title more playful.',
selection: {
selector: 'h1',
tag: 'h1',
outerHTML: '<h1>Hi</h1>',
rect: { top: 0, left: 0, width: 80, height: 24 },
},
model: MODEL,
apiKey: 'sk-test',
});

expect(result.artifacts).toHaveLength(1);
expect(result.artifacts[0]?.content).toBe(SAMPLE_HTML);
expect(result.message).toContain('Here is the revised HTML artifact.');
});
});
74 changes: 61 additions & 13 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { basename } from 'node:path';
import { type ArtifactEvent, createArtifactParser } from '@open-codesign/artifacts';
import { type RetryReason, complete, completeWithRetry } from '@open-codesign/providers';
import type {
Expand Down Expand Up @@ -75,28 +74,68 @@ interface ModelRunInput {
messages: ChatMessage[];
}

function createHtmlArtifact(content: string, index: number): Artifact {
return {
id: `design-${index + 1}`,
type: 'html',
title: 'Design',
content,
designParams: [],
createdAt: new Date().toISOString(),
};
}

function collect(events: Iterable<ArtifactEvent>, into: Collected): void {
for (const ev of events) {
if (ev.type === 'text') {
into.text += ev.delta;
} else if (ev.type === 'artifact:end') {
into.artifacts.push({
id: ev.identifier || `design-${into.artifacts.length + 1}`,
type: 'html',
title: 'Design',
content: ev.fullContent,
designParams: [],
createdAt: new Date().toISOString(),
});
const artifact = createHtmlArtifact(ev.fullContent, into.artifacts.length);
if (ev.identifier) artifact.id = ev.identifier;
into.artifacts.push(artifact);
}
}
}

function extractHtmlDocument(source: string): string | null {
const doctypeMatch = source.match(/<!doctype html[\s\S]*?<\/html>/i);
if (doctypeMatch) return doctypeMatch[0].trim();

const htmlMatch = source.match(/<html[\s\S]*?<\/html>/i);
if (htmlMatch) return htmlMatch[0].trim();

return null;
}

function extractFallbackArtifact(text: string): { artifact: Artifact | null; message: string } {
const fencedMatches = [...text.matchAll(/```(?:html)?\s*([\s\S]*?)```/gi)];
for (const match of fencedMatches) {
const block = match[1];
const matchedText = match[0];
if (!block || !matchedText) continue;

const html = extractHtmlDocument(block);
if (!html) continue;

return {
artifact: createHtmlArtifact(html, 0),
message: text.replace(matchedText, '').trim(),
};
}

const html = extractHtmlDocument(text);
if (!html) return { artifact: null, message: text.trim() };

return {
artifact: createHtmlArtifact(html, 0),
message: text.replace(html, '').trim(),
};
}

function formatDesignSystem(designSystem: StoredDesignSystem): string {
const repoLabel = basename(designSystem.rootPath);
const lines = [
'## Design system to follow',
`Repository: ${repoLabel}`,
`Root path: ${designSystem.rootPath}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This now sends the absolute design-system root path to the provider, and the same leak happens again below with file.path. That is a privacy regression: the prompt only needs repo/file names here, not the user’s local filesystem layout.

Suggested fix:

import { basename } from 'node:path';

const lines = [
  '## Design system to follow',
  `Repository: ${basename(designSystem.rootPath)}`,
  `Summary: ${designSystem.summary}`,
];

const lines = [`${index + 1}. ${file.name}`];

`Summary: ${designSystem.summary}`,
];
if (designSystem.colors.length > 0) lines.push(`Colors: ${designSystem.colors.join(', ')}`);
Expand All @@ -114,7 +153,7 @@ function formatAttachments(attachments: AttachmentContext[]): string | null {
if (attachments.length === 0) return null;
const body = attachments
.map((file, index) => {
const lines = [`${index + 1}. ${file.name}`];
const lines = [`${index + 1}. ${file.name} (${file.path})`];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] This now includes absolute local paths in prompt context. That leaks filesystem structure to the provider even though the basename/filename was enough before.

Suggested fix:

import { basename } from 'node:path';

const lines = [
  '## Design system to follow',
  `Repository: ${basename(designSystem.rootPath)}`,
  `Summary: ${designSystem.summary}`,
];

const lines = [`${index + 1}. ${file.name}`];

if (file.note) lines.push(`Note: ${file.note}`);
if (file.excerpt) lines.push(`Excerpt:\n${file.excerpt}`);
return lines.join('\n');
Expand Down Expand Up @@ -159,6 +198,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
const parts = [
'Revise the existing HTML artifact below.',
'Keep the overall structure, copy, and layout intact unless the user request requires a broader change.',
'Prioritize the selected element first and avoid unrelated edits.',
`User request: ${input.comment.trim()}`,
`Selected element tag: <${input.selection.tag}>`,
`Selected element selector: ${input.selection.selector}`,
Expand All @@ -172,7 +212,7 @@ function buildRevisionPrompt(input: ApplyCommentInput, contextSections: string[]
parts.push(contextSections.join('\n\n'));
}
parts.push(
'Return the full updated HTML artifact. Do not explain the diff line by line; a short summary outside the artifact is enough.',
'Return exactly one full updated HTML artifact wrapped in the required <artifact> tag. Do not use Markdown code fences. A short summary outside the artifact is enough.',
);
return parts.join('\n\n');
}
Expand All @@ -197,6 +237,14 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
collect(parser.feed(result.content), collected);
collect(parser.flush(), collected);

if (collected.artifacts.length === 0) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This silently repairs malformed model output instead of surfacing the contract failure. Principle 10 forbids silent fallbacks, so if we keep this recovery path it needs to be explicit in the returned message (or throw with context).

Suggested fix:

if (collected.artifacts.length === 0) {
  const fallback = extractFallbackArtifact(collected.text);
  if (fallback.artifact) {
    collected.artifacts.push(fallback.artifact);
    collected.text = [
      'Warning: recovered HTML from a Markdown-fenced response because the model skipped the required <artifact> tag.',
      fallback.message,
    ].filter(Boolean).join('\n\n');
  }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This still silently repairs malformed model output instead of surfacing the contract failure. Principles §10 requires the caller/UI to see that recovery happened, otherwise a bad provider response is indistinguishable from a valid <artifact> reply.

Suggested fix:

if (collected.artifacts.length === 0) {
  const fallback = extractFallbackArtifact(collected.text);
  if (fallback.artifact) {
    collected.artifacts.push(fallback.artifact);
    collected.text = [
      'Recovered HTML from a fenced response because the model skipped the required <artifact> tag.',
      fallback.message,
    ].filter(Boolean).join('\n\n');
  }
}

const fallback = extractFallbackArtifact(collected.text);
if (fallback.artifact) {
collected.artifacts.push(fallback.artifact);
collected.text = fallback.message;
}
}

return {
message: collected.text.trim(),
artifacts: collected.artifacts,
Expand Down
Loading