@@ -3,9 +3,11 @@ import type { SessionEvent } from "@openagentpack/sdk";
33import {
44 classifyUrl ,
55 collectDeliveredFiles ,
6+ documentTypeLabel ,
67 extractArtifacts ,
78 lastAssistantText ,
89 preferInlineMarkdownPreview ,
10+ resolveDocumentContent ,
911} from "./artifact" ;
1012
1113function deliveredEvent ( artifact : Record < string , unknown > ) : SessionEvent {
@@ -311,3 +313,156 @@ describe("collectDeliveredFiles", () => {
311313 ) . toEqual ( [ ] ) ;
312314 } ) ;
313315} ) ;
316+
317+ // ---------------------------------------------------------------------------
318+ // Document segment extraction
319+ // ---------------------------------------------------------------------------
320+
321+ /** Generate a realistic HTML document string of a given approximate length. */
322+ function makeHtmlDocument ( minChars = 1200 ) : string {
323+ const body = `<h1>Report Title</h1>\n<p>${ "This is a paragraph of report content with data-driven insights. " . repeat (
324+ Math . ceil ( minChars / 60 ) ,
325+ ) } </p>`;
326+ return `<!DOCTYPE html>\n<html lang="en">\n<head><meta charset="UTF-8"><title>Report</title></head>\n<body>\n${ body } \n</body>\n</html>` ;
327+ }
328+
329+ /** Generate a Mermaid diagram of a given approximate length. */
330+ function makeMermaidDiagram ( minChars = 600 ) : string {
331+ const nodes = Array . from ( { length : Math . ceil ( minChars / 50 ) } , ( _ , i ) => ` N${ i } ["Node ${ i } description text"]` ) ;
332+ const edges = Array . from ( { length : nodes . length - 1 } , ( _ , i ) => ` N${ i } --> N${ i + 1 } ` ) ;
333+ return `graph TD\n${ nodes . join ( "\n" ) } \n${ edges . join ( "\n" ) } ` ;
334+ }
335+
336+ describe ( "document extraction" , ( ) => {
337+ test ( "inline HTML document is extracted as a document segment" , ( ) => {
338+ const html = makeHtmlDocument ( ) ;
339+ const input = `以下是报告:\n\n${ html } ` ;
340+ const { segments } = extractArtifacts ( input ) ;
341+ const docs = segments . filter ( ( s ) => s . type === "document" ) ;
342+ expect ( docs ) . toHaveLength ( 1 ) ;
343+ expect ( docs [ 0 ] ) . toMatchObject ( { type : "document" , mimeType : "text/html" } ) ;
344+ // The preamble text should survive as a text segment.
345+ const texts = segments . filter ( ( s ) => s . type === "text" ) ;
346+ expect ( texts . some ( ( s ) => s . type === "text" && s . content . includes ( "以下是报告" ) ) ) . toBe ( true ) ;
347+ } ) ;
348+
349+ test ( "inline HTML document title is extracted from <title>" , ( ) => {
350+ const html = makeHtmlDocument ( ) ;
351+ const input = html ;
352+ const { segments } = extractArtifacts ( input ) ;
353+ const doc = segments . find ( ( s ) => s . type === "document" ) ;
354+ expect ( doc ?. type === "document" ? doc . title : undefined ) . toBe ( "Report" ) ;
355+ } ) ;
356+
357+ test ( "URLs inside an extracted HTML document are NOT extracted as artifacts" , ( ) => {
358+ const html = makeHtmlDocument ( ) . replace (
359+ "</head>" ,
360+ '<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet"></head>' ,
361+ ) ;
362+ const { segments } = extractArtifacts ( html ) ;
363+ const artifacts = segments . filter ( ( s ) => s . type === "artifact" || s . type === "images" ) ;
364+ expect ( artifacts ) . toHaveLength ( 0 ) ;
365+ } ) ;
366+
367+ test ( "short fenced HTML block stays as text (below threshold)" , ( ) => {
368+ const input = [
369+ "### 交付文件:`index.html`" ,
370+ "" ,
371+ "```html" ,
372+ "<!DOCTYPE html>" ,
373+ '<link rel="preconnect" href="https://fonts.googleapis.com">' ,
374+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' ,
375+ '<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">' ,
376+ "```" ,
377+ "" ,
378+ "预览:https://preview.example.com/page" ,
379+ ] . join ( "\n" ) ;
380+ const { segments } = extractArtifacts ( input ) ;
381+ // The fenced block is too short to be a document — stays as text.
382+ expect ( segments . some ( ( s ) => s . type === "text" && s . content . includes ( "fonts.googleapis.com" ) ) ) . toBe ( true ) ;
383+ expect ( segments . every ( ( s ) => s . type !== "document" ) ) . toBe ( true ) ;
384+ } ) ;
385+
386+ test ( "large fenced HTML block is extracted as a document segment" , ( ) => {
387+ const htmlContent = makeHtmlDocument ( 1000 ) ;
388+ const input = `Report:\n\n\`\`\`html\n${ htmlContent } \n\`\`\`\n\nDone.` ;
389+ const { segments } = extractArtifacts ( input ) ;
390+ const docs = segments . filter ( ( s ) => s . type === "document" ) ;
391+ expect ( docs ) . toHaveLength ( 1 ) ;
392+ expect ( docs [ 0 ] ) . toMatchObject ( { type : "document" , mimeType : "text/html" } ) ;
393+ // The preamble and postamble should survive as text segments.
394+ expect ( segments . some ( ( s ) => s . type === "text" && s . content . includes ( "Report:" ) ) ) . toBe ( true ) ;
395+ expect ( segments . some ( ( s ) => s . type === "text" && s . content . includes ( "Done." ) ) ) . toBe ( true ) ;
396+ } ) ;
397+
398+ test ( "fenced mermaid block is extracted as a document segment" , ( ) => {
399+ const mermaid = makeMermaidDiagram ( 600 ) ;
400+ const input = `Diagram:\n\n\`\`\`mermaid\n${ mermaid } \n\`\`\`\n\nEnd.` ;
401+ const { segments } = extractArtifacts ( input ) ;
402+ const docs = segments . filter ( ( s ) => s . type === "document" ) ;
403+ expect ( docs ) . toHaveLength ( 1 ) ;
404+ expect ( docs [ 0 ] ) . toMatchObject ( { type : "document" , mimeType : "text/mermaid" } ) ;
405+ } ) ;
406+
407+ test ( "truncated inline HTML (no </html>) is still extracted as document" , ( ) => {
408+ // Simulate a truncated HTML stream: starts with DOCTYPE but never closes.
409+ const truncatedHtml = `<!DOCTYPE html>\n<html>\n<head><title>Partial</title></head>\n<body>\n${ "<p>content</p>\n" . repeat ( 80 ) } ` ;
410+ const { segments } = extractArtifacts ( truncatedHtml ) ;
411+ const docs = segments . filter ( ( s ) => s . type === "document" ) ;
412+ expect ( docs ) . toHaveLength ( 1 ) ;
413+ expect ( docs [ 0 ] ) . toMatchObject ( { type : "document" , mimeType : "text/html" } ) ;
414+ } ) ;
415+
416+ test ( "truncated HTML with short preamble is extracted as document" , ( ) => {
417+ // The common agent pattern: "以下是报告:\n\n<!DOCTYPE html>...(truncated)..."
418+ const preamble = "以下是基于你提供的素材整理而成的 HTML 格式行业研究报告。\n\n" ;
419+ const truncatedHtml = `<!DOCTYPE html>\n<html lang="zh-CN">\n<head><title>Report</title></head>\n<body>\n${ "<p>报告内容段落。</p>\n" . repeat ( 100 ) } ` ;
420+ const input = preamble + truncatedHtml ;
421+ expect ( input ) . not . toMatch ( / < \/ h t m l > / i) ; // no closing tag = truncated
422+ const { segments } = extractArtifacts ( input ) ;
423+ const docs = segments . filter ( ( s ) => s . type === "document" ) ;
424+ expect ( docs ) . toHaveLength ( 1 ) ;
425+ expect ( docs [ 0 ] ) . toMatchObject ( { type : "document" , mimeType : "text/html" } ) ;
426+ // Preamble survives as text.
427+ const texts = segments . filter ( ( s ) => s . type === "text" ) ;
428+ expect ( texts . some ( ( s ) => s . type === "text" && s . content . includes ( "以下是基于" ) ) ) . toBe ( true ) ;
429+ } ) ;
430+
431+ test ( "preferInlineMarkdownPreview returns false when document segments exist" , ( ) => {
432+ const html = makeHtmlDocument ( ) ;
433+ const input = `\n\nLong text content here for testing.\n\n${ html } ` ;
434+ const { segments } = extractArtifacts ( input ) ;
435+ expect ( preferInlineMarkdownPreview ( segments ) ) . toBe ( false ) ;
436+ } ) ;
437+ } ) ;
438+
439+ describe ( "resolveDocumentContent" , ( ) => {
440+ test ( "returns HTML content as-is" , ( ) => {
441+ const html = "<!DOCTYPE html><html><body>Hello</body></html>" ;
442+ const result = resolveDocumentContent ( { type : "document" , content : html , mimeType : "text/html" } ) ;
443+ expect ( result ) . toBe ( html ) ;
444+ } ) ;
445+
446+ test ( "returns SVG content as-is" , ( ) => {
447+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>' ;
448+ const result = resolveDocumentContent ( { type : "document" , content : svg , mimeType : "image/svg+xml" } ) ;
449+ expect ( result ) . toBe ( svg ) ;
450+ } ) ;
451+
452+ test ( "wraps Mermaid content in an HTML shell with mermaid.js" , ( ) => {
453+ const mermaid = "graph TD\n A --> B" ;
454+ const result = resolveDocumentContent ( { type : "document" , content : mermaid , mimeType : "text/mermaid" } ) ;
455+ expect ( result ) . toContain ( "mermaid.min.js" ) ;
456+ expect ( result ) . toContain ( '<pre class="mermaid">' ) ;
457+ expect ( result ) . toContain ( "graph TD" ) ;
458+ expect ( result ) . toContain ( "mermaid.initialize" ) ;
459+ } ) ;
460+ } ) ;
461+
462+ describe ( "documentTypeLabel" , ( ) => {
463+ test ( "returns correct labels" , ( ) => {
464+ expect ( documentTypeLabel ( "text/html" ) ) . toBe ( "HTML 文档" ) ;
465+ expect ( documentTypeLabel ( "image/svg+xml" ) ) . toBe ( "SVG 图形" ) ;
466+ expect ( documentTypeLabel ( "text/mermaid" ) ) . toBe ( "Mermaid 图表" ) ;
467+ } ) ;
468+ } ) ;
0 commit comments