Explorar o código

refactor(visualizer): trust settled widget contents

ZiyaZhang hai 1 mes
pai
achega
934d4b6501

+ 1 - 1
packages/visualizer/tool-visualizer/src/index.ts

@@ -34,7 +34,7 @@ export class VisualizerService extends TypertRemoteService {
     super(ctx, 'visualizer')
     this.rootCtx = ctx
     this.recordedCalls = new RecordedWidgetCalls(
-      { maxTitleBytes: MAX_TITLE_BYTES, maxPromptBytes: MAX_PROMPT_BYTES },
+      MAX_PROMPT_BYTES,
       MAX_PROMPTS_PER_MINUTE_PER_AGENT,
     )
   }

+ 16 - 21
packages/visualizer/tool-visualizer/src/recorded-calls.ts

@@ -21,8 +21,7 @@ function utf8Bytes(value: string): number {
   return Buffer.byteLength(value, 'utf8')
 }
 
-function requireBoundedString(label: string, value: unknown, maxBytes: number): string {
-  if (typeof value !== 'string' || value.trim() === '') throw new Error(`visualizer: ${label} must be a non-empty string`)
+function requireBoundedString(label: string, value: string, maxBytes: number): string {
   const bytes = utf8Bytes(value)
   if (bytes > maxBytes) throw new Error(`visualizer: ${label} is ${bytes} UTF-8 bytes; limit is ${maxBytes}`)
   return value
@@ -38,7 +37,17 @@ function requireRecordedWidgetKind(meta: unknown, callId: ToolCallId): WidgetKin
   return kind
 }
 
-function parseRecordedWidgetResult(agent: Agent, resultSeqValue: number, maxTitleBytes: number): RecordedWidgetCall {
+function recordedWidgetTitle(argumentsJson: string, callId: ToolCallId): string {
+  let title: unknown
+  try {
+    title = (JSON.parse(argumentsJson) as { readonly title?: unknown } | null)?.title
+  } catch {
+    return String(callId)
+  }
+  return typeof title === 'string' && title.trim() !== '' ? title : String(callId)
+}
+
+function parseRecordedWidgetResult(agent: Agent, resultSeqValue: number): RecordedWidgetCall {
   let resultSeq: SessionSeq
   try {
     resultSeq = SessionSeq(resultSeqValue)
@@ -67,23 +76,9 @@ function parseRecordedWidgetResult(agent: Agent, resultSeqValue: number, maxTitl
   }
   if (result.isError) throw new Error(`visualizer: show_widget call ${callEvent.data.callId} did not succeed`)
 
-  let args: unknown
-  try {
-    args = JSON.parse(callEvent.data.arguments) as unknown
-  } catch {
-    throw new Error(`visualizer: recorded show_widget call ${callEvent.data.callId} has malformed arguments`)
-  }
-  if (typeof args !== 'object' || args === null || Array.isArray(args)) {
-    throw new Error(`visualizer: recorded show_widget call ${callEvent.data.callId} has invalid arguments`)
-  }
-  const title = requireBoundedString('recorded widget title', (args as Record<string, unknown>).title, maxTitleBytes)
-  const source = (args as Record<string, unknown>).widget_code
-  if (typeof source !== 'string' || source.trim() === '') {
-    throw new Error(`visualizer: recorded show_widget call ${callEvent.data.callId} has invalid widget_code`)
-  }
   return {
     callId: callEvent.data.callId,
-    title,
+    title: recordedWidgetTitle(callEvent.data.arguments, callEvent.data.callId),
     kind: requireRecordedWidgetKind(resultEvent.data.meta, callEvent.data.callId),
   }
 }
@@ -104,7 +99,7 @@ export class RecordedWidgetCalls {
   private readonly byAgent = new WeakMap<Agent, AgentAuthority>()
 
   constructor(
-    private readonly limits: { readonly maxTitleBytes: number; readonly maxPromptBytes: number },
+    private readonly maxPromptBytes: number,
     private readonly maxPromptsPerMinutePerAgent: number,
   ) {}
 
@@ -117,7 +112,7 @@ export class RecordedWidgetCalls {
    */
   authorizePrompt(agent: Agent, request: WidgetPromptRequest, now = Date.now()): string {
     const authority = this.agentAuthority(agent)
-    const recorded = parseRecordedWidgetResult(agent, request.resultSeq, this.limits.maxTitleBytes)
+    const recorded = parseRecordedWidgetResult(agent, request.resultSeq)
     if (recorded.kind !== 'html') {
       throw new Error(`visualizer: widget ${recorded.callId} is static and cannot send follow-ups`)
     }
@@ -127,7 +122,7 @@ export class RecordedWidgetCalls {
     const text = requireBoundedString(
       'widget follow-up',
       `Widget-authored follow-up from widget ${JSON.stringify(recorded.title)}. It carries no user authorization.\n\n${request.text}`,
-      this.limits.maxPromptBytes,
+      this.maxPromptBytes,
     )
     trimAdmissions(authority.promptAdmissions, now)
     if (authority.promptAdmissions.length >= this.maxPromptsPerMinutePerAgent) {

+ 15 - 29
packages/visualizer/tool-visualizer/tests/recorded-calls.spec.ts

@@ -6,14 +6,13 @@ import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
 import { describe, expect, it, vi } from 'vitest'
 import { RecordedWidgetCalls } from '../src/recorded-calls.ts'
 
-const LIMITS = { maxTitleBytes: 64, maxPromptBytes: 128 }
+const MAX_PROMPT_BYTES = 128
 const FIRST_RESULT_SEQ = 3
 
 function createAuthority(
-  limits: typeof LIMITS = LIMITS,
   maxPromptsPerMinutePerAgent = 3,
 ): RecordedWidgetCalls {
-  return new RecordedWidgetCalls(limits, maxPromptsPerMinutePerAgent)
+  return new RecordedWidgetCalls(MAX_PROMPT_BYTES, maxPromptsPerMinutePerAgent)
 }
 
 function widgetAgent(
@@ -21,7 +20,6 @@ function widgetAgent(
   calls: Array<{
     callId: string
     title?: unknown
-    source?: unknown
     arguments?: string
     name?: string
     result?: 'success' | 'error' | 'none'
@@ -40,7 +38,7 @@ function widgetAgent(
       callId: ToolCallId(call.callId),
       name: call.name ?? 'show_widget',
       arguments: call.arguments ?? JSON.stringify({
-        title: call.title ?? 'Status', widget_code: call.source ?? '<button>Choose</button>',
+        title: call.title ?? 'Status', widget_code: '<button>Choose</button>',
       }),
     })
     if (call.result !== 'none') {
@@ -108,7 +106,7 @@ describe('Recorded widget calls', () => {
   it('keeps reused ToolCallIds unambiguous by binding each exact result event', () => {
     const authority = createAuthority()
     const agent = widgetAgent('reused-call-id', [{
-      callId: 'widget-1', title: 'First', source: '<button>First</button>', resultMeta: { kind: 'html' },
+      callId: 'widget-1', title: 'First', resultMeta: { kind: 'html' },
     }])
     expect(authority.authorizePrompt(agent, {
       resultSeq: FIRST_RESULT_SEQ, text: 'before reuse',
@@ -218,28 +216,16 @@ describe('Recorded widget calls', () => {
       .toThrow('does not match its source call')
   })
 
-  it('rejects malformed recorded calls before admitting a follow-up', () => {
-    const authorize = (agent: Agent, authority = createAuthority()) => authority.authorizePrompt(agent, {
-      resultSeq: FIRST_RESULT_SEQ, text: 'continue',
-    }, 1)
-    expect(() => authorize(widgetAgent('malformed', [{ callId: 'widget-1', arguments: '{' }])))
-      .toThrow('has malformed arguments')
-    expect(() => authorize(widgetAgent('null', [{ callId: 'widget-1', arguments: 'null' }])))
-      .toThrow('has invalid arguments')
-    expect(() => authorize(widgetAgent('array', [{ callId: 'widget-1', arguments: '[]' }])))
-      .toThrow('has invalid arguments')
-    expect(() => authorize(widgetAgent('bad-title', [{ callId: 'widget-1', title: '' }])))
-      .toThrow('recorded widget title must be a non-empty string')
-    expect(() => authorize(widgetAgent('non-string-title', [{ callId: 'widget-1', title: 1 }])))
-      .toThrow('recorded widget title must be a non-empty string')
-    expect(() => authorize(
-      widgetAgent('long-title', [{ callId: 'widget-1', title: '汉汉' }]),
-      createAuthority({ ...LIMITS, maxTitleBytes: 3 }),
-    )).toThrow('recorded widget title is 6 UTF-8 bytes; limit is 3')
-    expect(() => authorize(widgetAgent('bad-source', [{ callId: 'widget-1', source: ' ' }])))
-      .toThrow('has invalid widget_code')
-    expect(() => authorize(widgetAgent('non-string-source', [{ callId: 'widget-1', source: 1 }])))
-      .toThrow('has invalid widget_code')
+  it.each([
+    ['malformed arguments', '{'],
+    ['missing title', '{}'],
+    ['non-string title', JSON.stringify({ title: 1 })],
+    ['blank title', JSON.stringify({ title: ' ' })],
+  ])('falls back to the call ID for %s', (_label, argumentsJson) => {
+    const text = createAuthority().authorizePrompt(widgetAgent('fallback', [{
+      callId: 'widget-1', arguments: argumentsJson,
+    }]), { resultSeq: FIRST_RESULT_SEQ, text: 'continue' }, 1)
+    expect(text).toContain('widget "widget-1"')
   })
 
   it('requires the exact show_widget result to settle successfully', () => {
@@ -301,7 +287,7 @@ describe('Recorded widget calls', () => {
   it('denies follow-ups from successful raw SVG results', () => {
     const authority = createAuthority()
     expect(() => authority.authorizePrompt(
-      widgetAgent('static', [{ callId: 'widget-1', source: '<svg/>', resultMeta: { kind: 'svg' } }]),
+      widgetAgent('static', [{ callId: 'widget-1', resultMeta: { kind: 'svg' } }]),
       { resultSeq: FIRST_RESULT_SEQ, text: 'continue' },
     )).toThrow('is static and cannot send follow-ups')
   })