Files
open-design/apps/web/tests/artifacts/recover.test.ts
T
eab98333cb fix(web): make same-turn HTML artifact recovery agent-agnostic (#4308) (#4318)
* 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>
2026-06-21 15:41:02 +00:00

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();
});
});