Преглед изворни кода

fix(visualizer): validate fragment contracts precisely

ZiyaZhang пре 1 месец
родитељ
комит
b17e90f78a

+ 2 - 2
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md
-2026-08-29-inline-visualizer-cordis-extension.md: 527aac505e0cf625282daa4575c8bcd35ed8e342
-2026-08-29-inline-visualizer-cordis-extension.zh.md: c7e6521d5d01e531498abd5587811ee6aec7aa54
+2026-08-29-inline-visualizer-cordis-extension.md: 31fb9ae306df93d24d1047d05de2142149f89f29
+2026-08-29-inline-visualizer-cordis-extension.zh.md: f960e51ee3344957208644cd638620e7e3be2499

+ 1 - 1
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.md

@@ -16,7 +16,7 @@ The package's `./model` function-plugin requires `systemPrompt` and `tools`, the
 
 Each `widget_guidelines` result includes Delivery when `show_widget` is visible, then combines one shared Foundation with only the requested type modules. The Foundation owns host-native composition, responsive flow, theme use, and cross-type accessibility; the type modules own diagram structure, interaction lifecycle, chart semantics, and illustration exceptions.
 
-Mounting the root authority exposes both raw SVG and HTML fragments; source-prefix detection selects the recorded kind without turning rendering policy into model-facing deployment vocabulary. This change supplies no presentation Client and does not activate the capability in Web, headless, or TUI. The Host exposes no mutable-state report, recovery, or model-read surface. No Visualizer session event, Agent-loop rule, or core schema is added.
+Mounting the root authority exposes both raw SVG and HTML fragments; source-prefix detection records the kind without exposing renderer deployment policy in the model-visible schema. This change supplies no presentation Client and does not activate the capability in Web, headless, or TUI. The Host exposes no mutable-state report, recovery, or model-read surface. No Visualizer session event, Agent-loop rule, or core schema is added.
 
 ## Verification
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-29-inline-visualizer-cordis-extension.zh.md

@@ -16,7 +16,7 @@ DSH 缺少一方 Host 契约,让模型请求临时内联视觉内容、渐进
 
 每个 `widget_guidelines` result 都会在 `show_widget` 可见时包含 Delivery,再把一个共享 Foundation 与所请求的类型模块组合起来。Foundation 拥有宿主原生构图、响应式流、主题使用与跨类型可访问性;类型模块分别拥有 diagram 结构、交互生命周期、chart 语义与 illustration 例外。
 
-挂载根权威后会同时公开 raw SVG 与 HTML fragment;源码前缀检测只选择已记录的 kind,不把 renderer 部署策略泄漏成模型词汇。本次变更不提供展示 Client,也不在 Web、headless 或 TUI 中激活该能力。Host 不提供可变状态上报、恢复或模型读取面。实现不新增 Visualizer session event、Agent loop 规则或 core schema。
+挂载根权威后会同时公开 raw SVG 与 HTML fragment;源码前缀检测记录 kind,而不在模型可见 schema 中暴露 renderer 部署策略。本次变更不提供展示 Client,也不在 Web、headless 或 TUI 中激活该能力。Host 不提供可变状态上报、恢复或模型读取面。实现不新增 Visualizer session event、Agent loop 规则或 core schema。
 
 ## 验证
 

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: ef1cfee182bb8908d92f4e50b534562eaf4519f9
-tool-catalog.zh.md: 0e7b37857578238c286a109af87bf12187ab5dd4
+tool-catalog.md: ac1b84c279247990f22728995684f8d4ca6e5254
+tool-catalog.zh.md: 42071bbbf65fb0fb1ace66e8b1858a834a4b5e12

+ 1 - 1
docs/tool-catalog.md

@@ -2085,7 +2085,7 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c
 
 ### `show_widget`
 
-Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; the host validates and renders it afterward.
+Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.
 
 ```json
 {

+ 1 - 1
docs/tool-catalog.zh.md

@@ -2092,7 +2092,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
 
 ### `show_widget`
 
-从对话内容或已完成的工具结果渲染一个临时内联图形或交互组件。以 <svg 开头的源码使用 SVG,其余源码使用 HTML。在本次调用中传入一份小而完整的源码;Host 会在调用后进行校验并渲染。
+从对话内容或已完成的工具结果渲染一个临时内联图形或交互组件。以 <svg 开头的源码使用 SVG,其余源码使用 HTML。在本次调用中传入一份小而完整的源码;提交后才会进行校验。
 
 ```json
 {

+ 2 - 2
packages/visualizer/tool-visualizer/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/visualizer/tool-visualizer/README.md
-README.md: 25dd20fe7b3acdacd7e1db90320faf31cb76522c
-README.zh.md: 396405523c3f3950a08029afb0ffe81f0e571429
+README.md: 2c518938618ab98edc478166d8efec5a1f06d0cd
+README.zh.md: eab1a8a5471a80e472ae492bb8c4dc22d7667c93

+ 1 - 1
packages/visualizer/tool-visualizer/README.md

@@ -81,7 +81,7 @@ The routing section and two-tool surface remain prefix-stable while their defini
 
 #### What the model sees
 
-The mounted model surface exposes the generated [`widget_guidelines` and `show_widget` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-visualizer). `widget_guidelines` returns Delivery when `show_widget` is visible, a shared Foundation, and only the requested construction modules; the Foundation owns compact host-native composition, responsive flow, theme use, and cross-type accessibility. `show_widget` accepts raw SVG or an HTML fragment and reports the detected kind.
+The mounted model surface exposes the generated [`widget_guidelines` and `show_widget` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-visualizer). `widget_guidelines` returns Delivery when `show_widget` is visible to the calling Agent, a shared Foundation, and only the requested construction modules; the Foundation owns compact host-native composition, responsive flow, theme use, and cross-type accessibility. `show_widget` accepts raw SVG or an HTML fragment and reports the detected kind.
 
 #### Token effect
 

+ 1 - 1
packages/visualizer/tool-visualizer/README.zh.md

@@ -81,7 +81,7 @@ Use show_widget for a temporary inline visual that belongs to the reply: when as
 
 #### 模型看到什么
 
-挂载后的模型面会公开生成式 [`widget_guidelines` 与 `show_widget` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-visualizer)。`widget_guidelines` 在 `show_widget` 可见时返回 Delivery,并返回共享 Foundation 与所请求的构建模块;Foundation 拥有紧凑的宿主原生构图、响应式流、主题使用与跨类型可访问性。`show_widget` 接纳 raw SVG 或 HTML fragment,并返回检测出的 kind。
+挂载后的模型面会公开生成式 [`widget_guidelines` 与 `show_widget` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-visualizer)。`widget_guidelines` 在 `show_widget` 对调用 Agent 可见时返回 Delivery,并返回共享 Foundation 与所请求的构建模块;Foundation 拥有紧凑的宿主原生构图、响应式流、主题使用与跨类型可访问性。`show_widget` 接纳 raw SVG 或 HTML fragment,并返回检测出的 kind。
 
 #### Token 影响
 

+ 4 - 3
packages/visualizer/tool-visualizer/src/tools.ts

@@ -7,6 +7,7 @@ import type { WidgetGuidelineModule, WidgetKind } from './types.ts'
 const MODULES = ['diagram', 'mockup', 'interactive', 'chart', 'illustration'] as const
 
 const WIDGET_CODE_FENCE = /^\s*(?:`{3,}|~{3,})/
+const HTML_DOCUMENT_WRAPPER = /^(?:<!--[\s\S]*?-->\s*)*<(?:!doctype|html|head|body)(?=[\s/>])/i
 
 function widgetKind(source: string): WidgetKind {
   const trimmed = source.trimStart()
@@ -33,7 +34,7 @@ function validateWidgetArgs(
     throw new Error('visualizer: widget_code must be raw source without Markdown code fences')
   }
   const trimmed = source.trimStart()
-  if (/^<(?:!doctype|html|head|body)(?:\s|>)/i.test(trimmed)) {
+  if (HTML_DOCUMENT_WRAPPER.test(trimmed)) {
     throw new Error('visualizer: widget_code must be a fragment without document wrappers')
   }
   return widgetKind(source)
@@ -75,7 +76,7 @@ export function registerVisualizerTools(
 
   ctx.tools.register(defineTool({
     name: 'show_widget',
-    description: 'Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; the host validates and renders it afterward.',
+    description: 'Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.',
     parameters: {
       title: { type: 'string', required: true, description: 'Short user-facing title in the user\'s language.' },
       widget_code: { type: 'string', required: true, description: sourceDescription },
@@ -91,7 +92,7 @@ export function registerVisualizerTools(
       },
       render: (args, value) => [{
         type: 'text',
-        text: `Widget "${args.title}" accepted (${value.kind}) and displayed inline. It is final for this response; finish with brief supporting prose.`,
+        text: `Widget "${args.title}" accepted (${value.kind}). It is final for this response; finish with brief supporting prose.`,
       }],
       presentationMeta: (_args, value) => ({ kind: value.kind }),
     },

+ 2 - 2
packages/visualizer/tool-visualizer/tests/__snapshots__/model-surface.spec.ts.snap

@@ -56,14 +56,14 @@ exports[`Visualizer model surface > ships one neutral SVG and HTML model surface
     ],
     "show_widget": [
       {
-        "text": "Widget "Snapshot" accepted (html) and displayed inline. It is final for this response; finish with brief supporting prose.",
+        "text": "Widget "Snapshot" accepted (html). It is final for this response; finish with brief supporting prose.",
         "type": "text",
       },
     ],
   },
   "tools": {
     "show_widget": {
-      "description": "Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; the host validates and renders it afterward.",
+      "description": "Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.",
       "output": {
         "additionalProperties": false,
         "properties": {

+ 6 - 3
packages/visualizer/tool-visualizer/tests/model-surface.spec.ts

@@ -168,13 +168,13 @@ describe('Visualizer model surface', () => {
       required: ['modules'],
     })
     expect(show.description)
-      .toBe('Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; the host validates and renders it afterward.')
+      .toBe('Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.')
     const staticReceipt = show.output.render?.(
       { title: 'Static', widget_code: '<svg></svg>' },
       { accepted: true, kind: 'svg' },
     )[0]
     expect(staticReceipt?.type === 'text' ? staticReceipt.text : '')
-      .toBe('Widget "Static" accepted (svg) and displayed inline. It is final for this response; finish with brief supporting prose.')
+      .toBe('Widget "Static" accepted (svg). It is final for this response; finish with brief supporting prose.')
     expect((show.output.schema as { properties: { kind: { enum: string[] } } }).properties.kind.enum)
       .toEqual(['svg', 'html'])
     const sourceDescription = JSON.stringify(show.parameters)
@@ -463,6 +463,9 @@ describe('Visualizer model surface', () => {
     expect(await errorText(' ', '<svg></svg>')).toContain('title must be non-empty')
     expect(await errorText('汉'.repeat(86), '<svg></svg>')).toContain('title is 258 UTF-8 bytes')
     expect(await errorText('A', '<html></html>')).toContain('document wrappers')
+    expect(await errorText('A', '<html/>')).toContain('document wrappers')
+    expect(await errorText('A', '<body/>')).toContain('document wrappers')
+    expect(await errorText('A', '<!--note--><html lang="en">')).toContain('document wrappers')
     expect(await errorText('A', '```html\n<html></html>\n```')).toContain('raw source without Markdown code fences')
     expect(await errorText('A', '```html\n  \n```')).toContain('raw source without Markdown code fences')
     expect(await errorText('A', '```html5\n<button>A</button>\n```')).toContain('raw source without Markdown code fences')
@@ -489,7 +492,7 @@ describe('Visualizer model surface', () => {
     expect(show.output.render?.({ title: 'Demo', widget_code: '<button>A</button>' }, { accepted: true, kind: 'html' }))
       .toEqual([{
         type: 'text',
-        text: 'Widget "Demo" accepted (html) and displayed inline. It is final for this response; finish with brief supporting prose.',
+        text: 'Widget "Demo" accepted (html). It is final for this response; finish with brief supporting prose.',
       }])
     expect(show.output.presentationMeta?.({ title: 'Demo', widget_code: '<button>A</button>' }, { accepted: true, kind: 'html' }))
       .toEqual({ kind: 'html' })

+ 1 - 1
snapshots/web/visualizer-host/tool-schemas.expected.json

@@ -406,7 +406,7 @@
     },
     {
       "name": "show_widget",
-      "description": "Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; the host validates and renders it afterward.",
+      "description": "Render one temporary inline graphic or interactive widget from conversation content or completed tool results. Source beginning with <svg uses SVG; anything else uses HTML. Pass one small, complete source in this call; validation occurs after submission.",
       "parameters": {
         "type": "object",
         "properties": {