mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(web): make same-turn HTML artifact recovery agent-agnostic (#4308) PR #4249 fixed duplicate HTML artifact persistence for Claude Code, but the web recovery path stayed Claude-gated: findSameTurnHtmlWriteForRecoveredArtifact only bound a recovered artifact to a same-turn HTML write when `allowAnyHtmlWrite = agentId === 'claude'`. Any other filesystem-backed CLI that writes a project HTML file and then echoes an `<artifact type="text/html">` block still persisted a duplicate. Replace the agent gate with the phenomenon-based rule from the issue: - exact normalized content match → bind (unchanged, all agents); - otherwise, if the turn produced HTML file(s) and the assistant echoed an HTML artifact, bind to the same-turn write when the choice is unambiguous (exactly one HTML file, or several with identical content); - multiple same-turn HTML files with differing content → avoid arbitrary selection (return null) so a wrong bind can't clobber another in-flight file. Drops the `allowAnyHtmlWrite` param and the `=== 'claude'` checks at both call sites. Reads candidate contents once up front to compare. Tests (red→green): single same-turn HTML write + differing echo now binds (was the Claude-only path); multiple differing → null; multiple identical → first. Existing exact-match and non-HTML cases unchanged. typecheck + pnpm guard (63) + web suite (3232 passed/1 skipped) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): bind recovered artifact only on exact content match (#4308) The agent-agnostic generalization bound a single same-turn HTML file to an echoed artifact even when their content differed. That mis-fired across queued runs: a queued run's `preTurnFileNames` snapshot can predate the previous run's persist, so `computeProducedFiles` reports the earlier run's artifact as "produced this turn". The blind single-file bind then bound the new echo to that unrelated prior file, dropping the expected artifact tab — breaking the [P0] `app-restoration` "queued send" e2e. Bind only on a normalized-exact content match. This is already agent-agnostic: any filesystem-backed CLI that writes an HTML file and echoes the same document normalizes-equal and is suppressed (the Claude-specific `allowAnyHtmlWrite` gate stays removed). A differing same-turn HTML file is a genuinely different document and must persist on its own — matching the issue's "avoid arbitrary selection" intent. #4249 stays covered (its tests bind via the exact match / daemon-side suppression, not the dropped non-exact path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): resolve recovered HTML before the same-turn dedup lookup (#4308) findSameTurnHtmlWriteForRecoveredArtifact received the raw <artifact> body, but persistArtifact first recovers the real document from the preceding same-turn <html> via recoverHtmlArtifactFromPrecedingDocument. For a prose-only artifact the lookup compared the summary text against the same-turn Write file's real HTML, so the normalized exact-match missed and the recovered document persisted a second time as a duplicate. Resolve the persisted HTML through a shared resolvePersistedArtifactHtml so the dedup lookup and the persist path agree on the same document. Both call sites pass the same sourceText (replayedContent / finalText) and identifier that persistArtifact uses, so the normalized exact-match now binds the same-turn write instead of duplicating it. The exact-match contract from the prior commit is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: re-trigger CI (flaky UI P0 project-runtime e2e timeout, unrelated to this PR) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { recoverHtmlArtifactFromPrecedingDocument, recoverHtmlDocumentFromMarkdownFence, recoverStandaloneHtmlDocument, resolvePersistedArtifactHtml } from '../../src/artifacts/recover';
|
|
|
|
const completeHtml = '<!doctype html><html><head><title>Demo</title></head><body><main><h1>Recovered artifact</h1></main></body></html>';
|
|
|
|
describe('recoverHtmlArtifactFromPrecedingDocument', () => {
|
|
it('recovers a complete HTML document emitted immediately before a prose artifact tag', () => {
|
|
const sourceText = [
|
|
'Here is the prototype:',
|
|
completeHtml,
|
|
'<artifact identifier="clay-code-longform" type="text/html" title="Clay & Code">',
|
|
'(The complete document above is the delivered artifact.)',
|
|
'</artifact>',
|
|
].join('\n');
|
|
|
|
expect(recoverHtmlArtifactFromPrecedingDocument({
|
|
artifactHtml: '(The complete document above is the delivered artifact.)',
|
|
identifier: 'clay-code-longform',
|
|
sourceText,
|
|
})).toBe(completeHtml);
|
|
});
|
|
|
|
it('does not recover when the artifact body is already valid HTML', () => {
|
|
expect(recoverHtmlArtifactFromPrecedingDocument({
|
|
artifactHtml: completeHtml,
|
|
identifier: 'demo',
|
|
sourceText: `${completeHtml}\n<artifact identifier="demo">ignored</artifact>`,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it('does not recover non-adjacent prior HTML', () => {
|
|
expect(recoverHtmlArtifactFromPrecedingDocument({
|
|
artifactHtml: 'summary only',
|
|
identifier: 'demo',
|
|
sourceText: `${completeHtml}\nThis is an explanation.\n<artifact identifier="demo">summary only</artifact>`,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it('recovers the immediately preceding html document instead of an earlier doctype document', () => {
|
|
const oldHtml = '<!doctype html><html><head><title>Old</title></head><body><main><h1>Old document</h1></main></body></html>';
|
|
const newHtml = '<html><head><title>New</title></head><body><main><h1>New document</h1></main></body></html>';
|
|
const sourceText = `${oldHtml}\nExplanation between drafts.\n${newHtml}\n<artifact identifier="demo" type="text/html">summary only</artifact>`;
|
|
|
|
expect(recoverHtmlArtifactFromPrecedingDocument({
|
|
artifactHtml: 'summary only',
|
|
identifier: 'demo',
|
|
sourceText,
|
|
})).toBe(newHtml);
|
|
});
|
|
|
|
it('ignores a stray doctype mention before the immediately preceding html document', () => {
|
|
const html = '<html><head><title>Final</title></head><body><main><h1>Final document</h1></main></body></html>';
|
|
const sourceText = `Mention <!doctype html> in prose.\n${html}\n<artifact identifier="demo" type="text/html">summary only</artifact>`;
|
|
|
|
expect(recoverHtmlArtifactFromPrecedingDocument({
|
|
artifactHtml: 'summary only',
|
|
identifier: 'demo',
|
|
sourceText,
|
|
})).toBe(html);
|
|
});
|
|
});
|
|
|
|
describe('recoverStandaloneHtmlDocument', () => {
|
|
it('recovers a response that is itself a complete HTML document', () => {
|
|
expect(recoverStandaloneHtmlDocument(`\n${completeHtml}\n`)).toBe(completeHtml);
|
|
});
|
|
|
|
it('does not recover HTML embedded in prose', () => {
|
|
expect(recoverStandaloneHtmlDocument(`Here is the page:\n${completeHtml}`)).toBeNull();
|
|
});
|
|
|
|
it('does not recover a document followed by trailing prose', () => {
|
|
expect(recoverStandaloneHtmlDocument(`${completeHtml}\nI also updated the layout.`)).toBeNull();
|
|
});
|
|
|
|
it('does not recover partial snippets', () => {
|
|
expect(recoverStandaloneHtmlDocument('<main><h1>Snippet</h1></main>')).toBeNull();
|
|
});
|
|
|
|
it('does not recover document-shaped output missing a closing html tag', () => {
|
|
expect(recoverStandaloneHtmlDocument('<!doctype html><html><body><main><h1>Missing close</h1></main></body>')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolvePersistedArtifactHtml', () => {
|
|
// The persist path and the same-turn dedup lookup MUST resolve to the same
|
|
// document, or the lookup compares a prose summary against the real file and
|
|
// the duplicate slips through (#4318).
|
|
it('resolves to the recovered document for a prose-only artifact', () => {
|
|
const sourceText = `${completeHtml}\n<artifact identifier="demo" type="text/html">summary only</artifact>`;
|
|
expect(resolvePersistedArtifactHtml({
|
|
artifactHtml: 'summary only',
|
|
identifier: 'demo',
|
|
sourceText,
|
|
})).toBe(completeHtml);
|
|
});
|
|
|
|
it('resolves to the artifact body when it is already a complete document', () => {
|
|
expect(resolvePersistedArtifactHtml({
|
|
artifactHtml: completeHtml,
|
|
identifier: 'demo',
|
|
sourceText: `${completeHtml}\n<artifact identifier="demo">ignored</artifact>`,
|
|
})).toBe(completeHtml);
|
|
});
|
|
|
|
it('resolves to the artifact body when there is no recoverable preceding document', () => {
|
|
expect(resolvePersistedArtifactHtml({
|
|
artifactHtml: 'summary only',
|
|
identifier: 'demo',
|
|
sourceText: `${completeHtml}\nThis is an explanation.\n<artifact identifier="demo">summary only</artifact>`,
|
|
})).toBe('summary only');
|
|
});
|
|
});
|
|
|
|
describe('recoverHtmlDocumentFromMarkdownFence', () => {
|
|
it('recovers a single complete html fenced code block from prose', () => {
|
|
expect(recoverHtmlDocumentFromMarkdownFence([
|
|
'明白了,这是落地页原型。',
|
|
'',
|
|
'```html',
|
|
completeHtml,
|
|
'```',
|
|
].join('\n'))).toBe(completeHtml);
|
|
});
|
|
|
|
it('does not recover non-html fences or partial html snippets', () => {
|
|
expect(recoverHtmlDocumentFromMarkdownFence(['```ts', completeHtml, '```'].join('\n'))).toBeNull();
|
|
expect(recoverHtmlDocumentFromMarkdownFence('```html\n<main><h1>Snippet</h1></main>\n```')).toBeNull();
|
|
});
|
|
|
|
it('does not guess when multiple complete html fences are present', () => {
|
|
expect(recoverHtmlDocumentFromMarkdownFence([
|
|
'```html',
|
|
completeHtml,
|
|
'```',
|
|
'```html',
|
|
completeHtml.replace('Demo', 'Second'),
|
|
'```',
|
|
].join('\n'))).toBeNull();
|
|
});
|
|
});
|