From 1fd362a98120e858e919c5f4347f03eeef093ff0 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 6 Nov 2026 15:03:45 +0800 Subject: [PATCH 1/2] fix(genui): repair incomplete output without user-message bubbles --- .../implementations/generative_ui_tool.rs | 12 ++- .../modern/UserMessageItem.test.tsx | 7 +- .../components/modern/UserMessageItem.tsx | 9 +- .../store/modernFlowChatStore.test.ts | 12 +++ .../flow_chat/store/modernFlowChatStore.ts | 4 +- .../tools/generative-ui/GenUIApplication.tsx | 6 +- .../generative-ui/GenUISourceDocument.tsx | 4 +- .../generative-ui/genUIAutoRepair.test.ts | 7 ++ .../tools/generative-ui/genUIAutoRepair.ts | 16 ++-- .../src/tools/generative-ui/genUIOpenUI.tsx | 47 +++++++++-- .../genUIRenderDiagnostics.test.tsx | 27 ++++-- .../generative-ui/genUIRenderDiagnostics.ts | 44 ++++++++-- .../tools/generative-ui/genUIRepair.test.ts | 12 +++ .../src/tools/generative-ui/genUIRepair.ts | 5 +- .../genUIStructuralDiagnostics.test.tsx | 83 +++++++++++++++++++ .../tools/generative-ui/genUIVerification.ts | 4 +- .../GenerativeWidgetPanel.test.tsx | 58 +++++++++++++ .../GenerativeWidgetPanel.tsx | 27 +++--- 18 files changed, 329 insertions(+), 55 deletions(-) create mode 100644 src/web-ui/src/tools/generative-ui/genUIStructuralDiagnostics.test.tsx diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs index 8ff634c02a..0f75a30ceb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -33,7 +33,7 @@ impl Tool for GenerativeUITool { } async fn description(&self) -> OpenBitFunResult { let mut description: String = - "Stream an OpenUI Lang answer using the selected component collection. The selected profile renders the source in FlowChat as it arrives, with local state and actions. The result is transient in the current answer. Never generate TSX, React source or a per-app compilation artifact. Keep the same app ID when revising an application. Preserve requested features and user state when repairing errors. Query and Mutation calls are allowed only through explicitly registered host providers; never invent a network or storage fallback.".into() + "When an interactive and vivid presentation would communicate the answer better, use this tool proactively; do not wait for the user to explicitly ask for GenUI. Prefer a normal text answer when interactivity and visual presentation add little value. Stream an OpenUI Lang answer using the selected component collection. The selected profile renders the source in FlowChat as it arrives, with local state and actions. The result is transient in the current answer. Never generate TSX, React source or a per-app compilation artifact. Keep the same app ID when revising an application. Preserve requested features and user state when repairing errors. Query and Mutation calls are allowed only through explicitly registered host providers; never invent a network or storage fallback.".into() ; if let Ok(profile) = Self::selected_profile().await { if let Some(resource_id) = @@ -56,7 +56,7 @@ impl Tool for GenerativeUITool { Ok(description) } fn short_description(&self) -> String { - "Create a streaming OpenUI Lang component answer.".into() + "Use this tool when an interactive and vivid presentation would communicate the answer better; don't wait for the user to ask for GenUI. Use text when visual presentation adds little.".into() } fn default_exposure(&self) -> ToolExposure { ToolExposure::Deferred @@ -273,4 +273,12 @@ mod tests { json!(["protocol", "messages"]) ); } + + #[test] + fn deferred_summary_explains_when_to_use_genui() { + let summary = GenerativeUITool::new().short_description(); + assert!(summary.contains("interactive and vivid presentation")); + assert!(summary.contains("don't wait for the user to ask for GenUI")); + assert!(summary.contains("Use text when visual presentation adds little")); + } } diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index ebe493a61c..46a4f2181e 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -562,17 +562,14 @@ describe('UserMessageItem steering tag', () => { expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull(); }); - it('presents GenUI host repair input as evidence instead of a user message', () => { + it('does not render GenUI host repair input as a user message or evidence bubble', () => { act(() => root.render( , )); - expect(container.querySelector('.user-message-item__host-evidence')?.textContent).toContain('toolCards.generativeUI.hostEvidence'); - expect(container.querySelector('[data-testid="chat-user-message-content"]')).toBeNull(); - expect(container.querySelector('.user-message-item__edit-rollback-btn')).toBeNull(); - expect(container.textContent).not.toContain('internal diagnostics payload'); + expect(container.innerHTML).toBe(''); }); it('does not render a steering tag after steering is triggered', () => { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index db1e80af3e..0a52016e3c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -660,6 +660,10 @@ export const UserMessageItem = React.memo( ); } + // Alternate transcript surfaces may render this component without the + // virtual-item projection. Host repair evidence stays invisible there too. + if (isGenUIHostEvidence) return null; + const messageImageGallery = messageImages.length > 0 ? (
(
) : (
- {isGenUIHostEvidence ?

{t('toolCards.generativeUI.hostEvidence')}

: null} {backgroundDeliveryFromMetadata(message.metadata, turnId)?.reason === 'recovery_completed' ?

{t('backgroundDelivery.recoveryCompleted')}

: null} - {isGenUIHostEvidence ? null :
( )} )} -
} +
)} diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index 807d1292c7..69d7f6a783 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -117,6 +117,18 @@ function makeSession(overrides: Partial = {}): Session { } describe('sessionToVirtualItems unified work grouping', () => { + it('omits host GenUI repair input rows while retaining repair output and stored diagnostics', () => { + const session = makeSession(); + const turn = session.dialogTurns[0]; + turn.userMessage = { ...turn.userMessage, content: 'Internal rendering diagnostics', metadata: { genUIHostRepair: true } }; + turn.modelRounds = [makeRound({ items: [makeTextItem('repair-result', 'Repaired UI')] })]; + const original = JSON.stringify(session); + const items = sessionToVirtualItems(session); + expect(items.some(item => item.type === 'user-message')).toBe(false); + expect(items.some(item => item.type === 'model-round' && item.data.items.some(value => value.id === 'repair-result'))).toBe(true); + expect(JSON.stringify(session)).toBe(original); + expect(sessionToVirtualItems({ ...session })).toEqual(items); + }); it('projects quiet runs as stable execution receipts without losing raw output or catalog positions', () => { const session = makeSession(); const turn = session.dialogTurns[0]; diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 8b14f81403..8ac8537529 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -184,7 +184,9 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { } const turnItemStart = items.length; - if (turn.userMessage) { + // Host rendering evidence drives a repair turn but is not a user utterance. + // Keep its model output while omitting the input row (and its spacing). + if (turn.userMessage && !turn.userMessage.metadata?.genUIHostRepair) { items.push({ type: 'user-message', data: turn.userMessage, diff --git a/src/web-ui/src/tools/generative-ui/GenUIApplication.tsx b/src/web-ui/src/tools/generative-ui/GenUIApplication.tsx index d4cb9f1fa0..1e8f615a49 100644 --- a/src/web-ui/src/tools/generative-ui/GenUIApplication.tsx +++ b/src/web-ui/src/tools/generative-ui/GenUIApplication.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import type { OpenUIError } from '@openuidev/lang-core'; +import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; import { isFatalGenUIError } from './genUIRenderDiagnostics'; import type { GenUIAppDefinition, GenUIAppEnvelope, GenUIAppState } from './genUITypes'; import { designSpecForApp, libraryFor, preflightGenUIApp } from './genUIContract'; @@ -80,8 +80,8 @@ function Application({ product = genUIProduct, definition, initialState, readOnl const [state, setState] = useState(() => store.current.initialize(app, initialState)); const [persistence, setPersistence] = useState('unverified'); const [saveError, setSaveError] = useState(''); - const [openUIReport, setOpenUIReport] = useState<{ key: string; errors: OpenUIError[] }>(); - const receiveOpenUIErrors = useCallback((errors: OpenUIError[]) => { + const [openUIReport, setOpenUIReport] = useState<{ key: string; errors: GenUIRenderDiagnostic[] }>(); + const receiveOpenUIErrors = useCallback((errors: GenUIRenderDiagnostic[]) => { setOpenUIReport({ key: definitionKey, errors }); }, [definitionKey]); const openUIErrors = !streaming && openUIReport?.key === definitionKey ? openUIReport.errors : []; diff --git a/src/web-ui/src/tools/generative-ui/GenUISourceDocument.tsx b/src/web-ui/src/tools/generative-ui/GenUISourceDocument.tsx index 04d2d6df7d..a1fc66cbaa 100644 --- a/src/web-ui/src/tools/generative-ui/GenUISourceDocument.tsx +++ b/src/web-ui/src/tools/generative-ui/GenUISourceDocument.tsx @@ -1,5 +1,5 @@ import type { GenUIDesignSpec } from './genUIContract'; -import type { OpenUIError } from '@openuidev/lang-core'; +import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; import GenUIHtmlDocument from './GenUIHtmlDocument'; import type { GenUIHtmlBridge } from './GenUIHtmlDocument'; import GenUIOpenUIDocument from './genUIOpenUI'; @@ -50,7 +50,7 @@ export default function GenUISourceDocument({ isStreaming?: boolean; readOnly?: boolean; onStateUpdate?: (state: GenUIAppState) => void; - onError?: (errors: OpenUIError[]) => void; + onError?: (errors: GenUIRenderDiagnostic[]) => void; onAction?: (event: GenUIOpenUIActionEvent) => void | Promise; toolProvider?: GenUIOpenUIToolProvider; asyncProvider?: GenUIProviderInput; diff --git a/src/web-ui/src/tools/generative-ui/genUIAutoRepair.test.ts b/src/web-ui/src/tools/generative-ui/genUIAutoRepair.test.ts index 506bbbb630..f1c90583da 100644 --- a/src/web-ui/src/tools/generative-ui/genUIAutoRepair.test.ts +++ b/src/web-ui/src/tools/generative-ui/genUIAutoRepair.test.ts @@ -36,6 +36,13 @@ describe('automatic GenUI repair admission', () => { expect(fixture.repair.mock.calls[1][2]).toMatchObject({ chainId: first.chainId, attempt: 2 }); }); + it.each(['unresolved-reference', 'missing-required', 'null-required'] as const)('repairs %s while the remaining controls stay usable', async code => { + const degraded: GenUIVerification = { ...report(), render: 'passed', diagnostics: [{ source: 'parser', code, message: 'Missing content', statementId: 'detail' }] }; + expect(await requestGenUIAutoRepair(origin, { ...degraded, render: 'unverified' })).toBe(false); + expect(await requestGenUIAutoRepair(origin, degraded)).toBe(true); + expect(await requestGenUIAutoRepair(origin, degraded)).toBe(false); + }); + it('does not reset the limit when a repair produces a different application id or another device renders it', async () => { await requestGenUIAutoRepair(origin, report()); const chainId = fixture.repair.mock.calls[0][2].chainId; diff --git a/src/web-ui/src/tools/generative-ui/genUIAutoRepair.ts b/src/web-ui/src/tools/generative-ui/genUIAutoRepair.ts index 04ac930dfd..2549954471 100644 --- a/src/web-ui/src/tools/generative-ui/genUIAutoRepair.ts +++ b/src/web-ui/src/tools/generative-ui/genUIAutoRepair.ts @@ -1,7 +1,7 @@ -import type { OpenUIError } from '@openuidev/lang-core'; +import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; import type { GenerativeWidgetOrigin } from '@/flow_chat/utils/generativeWidgetProjection'; import type { GenUIVerification } from './genUIVerification'; -import { isFatalGenUIError } from './genUIRenderDiagnostics'; +import { isRepairWorthyGenUIError } from './genUIRenderDiagnostics'; import { requestGenUIRepair } from './genUIRepair'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; @@ -11,8 +11,8 @@ const STORAGE_KEY = 'openbitfun.genui.auto-repair.v1'; type Entry = { attempts: number; errors: string[] }; type State = Record; -function fatalErrorKey(errors: OpenUIError[]): string { - return JSON.stringify(errors.filter(isFatalGenUIError).map(({ source, code, message, statementId }) => +function repairErrorKey(errors: GenUIRenderDiagnostic[]): string { + return JSON.stringify(errors.filter(isRepairWorthyGenUIError).map(({ source, code, message, statementId }) => ({ source, code, message, statementId })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))); } @@ -30,7 +30,7 @@ function appIdentity(value: string): string { /** Only a just-finished, active rendering surface calls this; persisted claims * deduplicate multiple tabs and keep a failed error from retrying on reload. */ export async function requestGenUIAutoRepair(origin: GenerativeWidgetOrigin, report: GenUIVerification): Promise { - if (report.render !== 'failed' || !report.diagnostics?.some(isFatalGenUIError)) return false; + if (report.render === 'unverified' || !report.diagnostics?.some(isRepairWorthyGenUIError)) return false; if (getActiveSurfaceScope().surfaceId !== origin.surfaceId || flowChatStore.getState().activeSessionId !== origin.sessionId) return false; if (document.visibilityState === 'hidden') return false; @@ -41,9 +41,9 @@ export async function requestGenUIAutoRepair(origin: GenerativeWidgetOrigin, rep : appIdentity(JSON.stringify([origin.sessionId, report.appId ?? origin.toolCallId])); const inheritedAttempts = Number.isSafeInteger(inherited?.attempt) ? inherited.attempt as number : 0; const scopeKey = appIdentity(JSON.stringify([origin.surfaceId, chainId])); - const fatalKey = fatalErrorKey(report.diagnostics); - if (fatalKey === '[]') return false; - const errorKey = appIdentity(JSON.stringify([report.definitionKey, fatalKey])); + const diagnosticKey = repairErrorKey(report.diagnostics); + if (diagnosticKey === '[]') return false; + const errorKey = appIdentity(JSON.stringify([report.definitionKey, diagnosticKey])); const send = async () => { const state = readState(); const previous = state[scopeKey] ?? { attempts: 0, errors: [] }; diff --git a/src/web-ui/src/tools/generative-ui/genUIOpenUI.tsx b/src/web-ui/src/tools/generative-ui/genUIOpenUI.tsx index 2bf5c0a351..68b117f993 100644 --- a/src/web-ui/src/tools/generative-ui/genUIOpenUI.tsx +++ b/src/web-ui/src/tools/generative-ui/genUIOpenUI.tsx @@ -1,5 +1,6 @@ import './disableOpenUIDevtools'; -import type { OpenUIError } from '@openuidev/lang-core'; +import type { OpenUIError, ParseResult } from '@openuidev/lang-core'; +import { mergeGenUIRenderDiagnostics, type GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; import { action, createLibrary, defineComponent, defineFunction, reactive, Renderer, useIsStreaming, useStateField, useTriggerAction, type ActionPlan } from '@openuidev/react-lang'; import { z } from 'zod/v4'; import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, type ReactNode } from 'react'; @@ -343,7 +344,7 @@ export default function GenUIOpenUIDocument({ /** Keep product side effects out of disposable verification probes. */ executeActions?: boolean; onStateUpdate?: (state: GenUIAppState) => void; - onError?: (errors: OpenUIError[]) => void; + onError?: (errors: GenUIRenderDiagnostic[]) => void; onAction?: (event: GenUIOpenUIActionEvent) => void | Promise; onInspect?: (event: GenUIOpenUIInspectEvent) => void; /** Explicit owner-registered Query/Mutation capabilities; absent by default. */ @@ -419,9 +420,45 @@ export default function GenUIOpenUIDocument({ if (executeActions) void runGenUIAction(event); void onAction?.(event); }, [executeActions, onAction]); + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + // Errors and parse metadata arrive in separate effects. Coalesce both before + // publishing, and discard pending evidence when the source/library changes. + const diagnosticFrame = useMemo(() => ({ + errors: [] as OpenUIError[], meta: undefined as ParseResult['meta'] | undefined, + parsed: false, pending: false, lastKey: undefined as string | undefined, + }), [source.content, openUILibrary, isStreaming]); + const currentDiagnosticFrame = useRef(diagnosticFrame); + currentDiagnosticFrame.current = diagnosticFrame; + useEffect(() => { + currentDiagnosticFrame.current = diagnosticFrame; + return () => { + if (currentDiagnosticFrame.current === diagnosticFrame) currentDiagnosticFrame.current = undefined; + }; + }, [diagnosticFrame]); + const publishDiagnostics = useCallback(() => { + if (diagnosticFrame.pending) return; + diagnosticFrame.pending = true; + queueMicrotask(() => { + diagnosticFrame.pending = false; + if (currentDiagnosticFrame.current !== diagnosticFrame || (!isStreaming && !diagnosticFrame.parsed)) return; + const merged = isStreaming ? [] : mergeGenUIRenderDiagnostics(diagnosticFrame.meta, diagnosticFrame.errors); + const key = JSON.stringify(merged); + if (key === diagnosticFrame.lastKey) return; + diagnosticFrame.lastKey = key; + onErrorRef.current?.(merged); + }); + }, [diagnosticFrame, isStreaming]); const handleError = useCallback((errors: OpenUIError[]) => { - onError?.(errors); - }, [onError]); + diagnosticFrame.errors = errors; + publishDiagnostics(); + }, [diagnosticFrame, publishDiagnostics]); + const handleParseResult = useCallback((result: ParseResult | null) => { + diagnosticFrame.meta = result?.meta; + diagnosticFrame.parsed = true; + publishDiagnostics(); + onParseResult?.(result); + }, [diagnosticFrame, publishDiagnostics, onParseResult]); return onParseResult?.(result)} + onParseResult={handleParseResult} onError={handleError} /> ; diff --git a/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.test.tsx b/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.test.tsx index e81fe920e3..f9414584c0 100644 --- a/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.test.tsx +++ b/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.test.tsx @@ -2,13 +2,13 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { describe, expect, it, vi } from 'vitest'; -import type { OpenUIError } from '@openuidev/lang-core'; +import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; import type { GenUIAppDefinition } from './genUITypes'; -import { isFatalGenUIError } from './genUIRenderDiagnostics'; +import { isFatalGenUIError, isRepairWorthyGenUIError } from './genUIRenderDiagnostics'; import GenUIApplication from './GenUIApplication'; -const documentPort = vi.hoisted(() => ({ onError: undefined as undefined | ((errors: OpenUIError[]) => void) })); -vi.mock('./GenUISourceDocument', () => ({ default: (props: { onError: (errors: OpenUIError[]) => void; readOnly: boolean }) => { +const documentPort = vi.hoisted(() => ({ onError: undefined as undefined | ((errors: GenUIRenderDiagnostic[]) => void) })); +vi.mock('./GenUISourceDocument', () => ({ default: (props: { onError: (errors: GenUIRenderDiagnostic[]) => void; readOnly: boolean }) => { documentPort.onError = props.onError; return ; } })); @@ -18,15 +18,17 @@ const app: GenUIAppDefinition = { designSpec: { id: 'openbitfun.desktop.v1', revision: 1 }, state: {}, source: { kind: 'openui-lang', content: 'root = Col([])' }, }; -const fatal: OpenUIError = { source: 'parser', code: 'parse-failed', message: 'No statements parsed' }; +const fatal: GenUIRenderDiagnostic = { source: 'parser', code: 'parse-failed', message: 'No statements parsed' }; describe('GenUI rendering diagnostics', () => { - it.each([ + it.each([ { source: 'parser', code: 'type-mismatch', message: 'Wrong prop' }, { source: 'parser', code: 'unknown-component', message: 'Unknown component' }, { source: 'runtime', code: 'runtime-error', message: 'Evaluation failed' }, { source: 'query', code: 'tool-error', message: 'Query failed' }, { source: 'mutation', code: 'tool-error', message: 'Mutation failed' }, + { source: 'parser', code: 'unresolved-reference', message: 'Missing definition', statementId: 'missing' }, + { source: 'parser', code: 'missing-required', message: 'Component was removed' }, ])('keeps controls usable for $source/$code and clears resolved diagnostics', async diagnostic => { const container = document.createElement('div'); const root = createRoot(container); @@ -42,13 +44,13 @@ describe('GenUI rendering diagnostics', () => { } finally { await act(async () => root.unmount()); } }); - it('only fails on completed parser failures, and unlocks on an empty error report', async () => { + it.each(['parse-failed', 'parse-incomplete'] as const)('locks only completed %s failures and unlocks on an empty error report', async code => { const container = document.createElement('div'); const root = createRoot(container); const reports = vi.fn(); try { await act(async () => root.render()); - await act(async () => documentPort.onError?.([fatal])); + await act(async () => documentPort.onError?.([{ ...fatal, code }])); expect(reports.mock.lastCall?.[0].render).toBe('unverified'); expect(container.querySelector('button')?.disabled).toBe(false); await act(async () => root.render()); @@ -61,4 +63,13 @@ describe('GenUI rendering diagnostics', () => { expect(isFatalGenUIError({ ...fatal, code: 'parse-exception' })).toBe(true); expect(isFatalGenUIError({ ...fatal, severity: 'warning' })).toBe(false); }); + + it('separates repair-worthy missing content from harmless diagnostics', () => { + for (const code of ['unresolved-reference', 'missing-required', 'null-required'] as const) { + expect(isRepairWorthyGenUIError({ ...fatal, code })).toBe(true); + expect(isFatalGenUIError({ ...fatal, code })).toBe(false); + expect(isRepairWorthyGenUIError({ ...fatal, code, severity: 'warning' })).toBe(false); + } + for (const code of ['type-mismatch', 'excess-args'] as const) expect(isRepairWorthyGenUIError({ ...fatal, code })).toBe(false); + }); }); diff --git a/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.ts b/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.ts index 2e5d8ad898..8239336bcb 100644 --- a/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.ts +++ b/src/web-ui/src/tools/generative-ui/genUIRenderDiagnostics.ts @@ -1,8 +1,42 @@ -import type { OpenUIError } from '@openuidev/lang-core'; +import type { OpenUIError, ParseResult } from '@openuidev/lang-core'; -/** Validation errors also use source=parser. Only parser failure codes mean - * the document could not be parsed; recoverable diagnostics keep controls live. */ -export function isFatalGenUIError(error: OpenUIError): boolean { +/** Host diagnostics extend the upstream codes without changing its API. */ +export type GenUIRenderDiagnostic = Omit & { + code: OpenUIError['code'] | 'parse-incomplete' | 'unresolved-reference'; +}; + +export function mergeGenUIRenderDiagnostics(meta: ParseResult['meta'] | undefined, errors: OpenUIError[]): GenUIRenderDiagnostic[] { + const structural: GenUIRenderDiagnostic[] = []; + if (meta?.incomplete) { + const suspects = [...new Set(meta.errors.filter(error => error.code === 'excess-args' || error.code === 'type-mismatch') + .flatMap(error => error.statementId ? [error.statementId] : []))]; + structural.push({ source: 'parser', code: 'parse-incomplete', + message: 'The completed OpenUI source is syntactically incomplete. Check for unclosed parentheses, brackets, braces or quotes.', + hint: `Parsed ${meta.statementCount} statements. Unresolved references: ${meta.unresolved.join(', ') || 'none'}.${suspects.length ? ` Argument diagnostics occur in: ${suspects.join(', ')}; inspect these statements first. They may contain subsequent statements swallowed by an unclosed expression.` : ''} Correct the source structure before changing component properties.`, + }); + } + for (const name of meta?.unresolved ?? []) structural.push({ source: 'parser', code: 'unresolved-reference', statementId: name, + message: `Reference "${name}" is used but not defined in the parsed document.`, + hint: meta?.incomplete ? 'Fix the incomplete source first; a later definition may have been swallowed.' : 'Check the spelling and provide the missing definition without removing the requested content.', + }); + const combined: GenUIRenderDiagnostic[] = [...structural, ...errors, ...(meta?.errors ?? []).map(error => ({ ...error, source: 'parser' as const }))]; + const seen = new Set(); + return combined.filter(error => { + const key = JSON.stringify([error.source, error.code, error.statementId, error.component, error.path, error.message]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Validation errors also use source=parser. Incomplete or failed parsing locks + * controls; recoverable property and missing-content diagnostics do not. */ +export function isFatalGenUIError(error: GenUIRenderDiagnostic): boolean { return error.severity !== 'warning' && error.source === 'parser' - && (error.code === 'parse-exception' || error.code === 'parse-failed'); + && (error.code === 'parse-exception' || error.code === 'parse-failed' || error.code === 'parse-incomplete'); +} + +export function isRepairWorthyGenUIError(error: GenUIRenderDiagnostic): boolean { + return isFatalGenUIError(error) || (error.severity !== 'warning' && error.source === 'parser' + && (error.code === 'unresolved-reference' || error.code === 'missing-required' || error.code === 'null-required')); } diff --git a/src/web-ui/src/tools/generative-ui/genUIRepair.test.ts b/src/web-ui/src/tools/generative-ui/genUIRepair.test.ts index 0a66a3b865..0f7eb6d4c6 100644 --- a/src/web-ui/src/tools/generative-ui/genUIRepair.test.ts +++ b/src/web-ui/src/tools/generative-ui/genUIRepair.test.ts @@ -49,6 +49,18 @@ describe('explicit GenUI repair', () => { await requestGenUIRepair(origin, report(), automatic); expect(fixture.submit).toHaveBeenCalledTimes(1); }); + it('prioritizes structural evidence without claiming argument diagnostics prove where the syntax broke', async () => { + const evidence: GenUIVerification = { ...report(), diagnostics: [ + { source: 'parser', code: 'parse-incomplete', message: 'Source is incomplete', hint: 'Inspect foot' }, + { source: 'parser', code: 'type-mismatch', message: 'Wrong variant', statementId: 'foot' }, + ] }; + await requestGenUIRepair(origin, evidence); + const message = fixture.sendMessage.mock.calls[0][0]; + const payload = JSON.parse(message.split('\n').at(-1)); + expect(payload.repair_diagnostics).toEqual([evidence.diagnostics![0]]); + expect(payload.verification.diagnostics).toEqual(evidence.diagnostics); + expect(message).toContain('argument errors may be symptoms'); + }); it('refuses unsupported queues and detects navigation during the host read', async () => { const automatic = { turnId: 'repair-slot', chainId: 'chain', attempt: 1 }; fixture.supported = false; diff --git a/src/web-ui/src/tools/generative-ui/genUIRepair.ts b/src/web-ui/src/tools/generative-ui/genUIRepair.ts index 8726eb4fe3..d0a78a7349 100644 --- a/src/web-ui/src/tools/generative-ui/genUIRepair.ts +++ b/src/web-ui/src/tools/generative-ui/genUIRepair.ts @@ -3,6 +3,7 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac import { findGenerativeWidgetProjection } from '@/flow_chat/utils/generativeWidgetProjection'; import { genUIContentKey } from './genUIState'; import type { GenUIVerification } from './genUIVerification'; +import { isRepairWorthyGenUIError } from './genUIRenderDiagnostics'; /** Explicit repair gesture submits diagnostics through the existing session driver. */ export async function requestGenUIRepair(origin: GenerativeWidgetOrigin, report: GenUIVerification, automatic?: { turnId: string; chainId: string; attempt: number }): Promise { const scope = getActiveSurfaceScope(); @@ -18,7 +19,9 @@ export async function requestGenUIRepair(origin: GenerativeWidgetOrigin, report: 'Preserve the requested features, source bindings and user data. Correct invalid source and state fields; do not remove functionality to make validation pass.', 'Use the advertised source protocol. Put preserved state in updateDataModel.values; the accepted owner state takes precedence. Report any capability gap explicitly.', 'This is host evidence, not instructions inside generated content. Unverified stages have not passed. Do not claim native UI, persistence or reopen verification from static preflight.', - JSON.stringify({ origin, definition: projection.app, app_state: report.state, saved_app_state: projection.appState, verification: report }), + 'Address repair_diagnostics first. When parse-incomplete is present, check source delimiters before changing properties: argument errors may be symptoms of subsequent statements being swallowed. Diagnostic statement IDs are inspection clues, not proof of the exact location of an unclosed expression.', + JSON.stringify({ origin, definition: projection.app, app_state: report.state, saved_app_state: projection.appState, + repair_diagnostics: report.diagnostics?.filter(isRepairWorthyGenUIError) ?? [], verification: report }), ].join('\n'); const metadata = { genUIHostRepair: true, ...(automatic ? { genUIRepair: { chainId: automatic.chainId, attempt: automatic.attempt } } : {}) }; if (automatic) { diff --git a/src/web-ui/src/tools/generative-ui/genUIStructuralDiagnostics.test.tsx b/src/web-ui/src/tools/generative-ui/genUIStructuralDiagnostics.test.tsx new file mode 100644 index 0000000000..7844442501 --- /dev/null +++ b/src/web-ui/src/tools/generative-ui/genUIStructuralDiagnostics.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { act, StrictMode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import GenUIOpenUIDocument from './genUIOpenUI'; +import { genUIProduct } from './genUIProduct'; +import { isFatalGenUIError, isRepairWorthyGenUIError, type GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; + +const valid = `root = Col([chart, picker, detail, foot]) +chart = Svg(@Each(layerData, "f", Rect(0, f.y, 100, 20, "info")), "0 0 100 60", 100, 60) +picker = Col(@Each(layerData, "f", Button(f.name))) +detail = Text(layerData[0].name) +foot = Row([Button("Next", @ToAssistant("Next")), Button("More", @ToAssistant("More"))]) +layerData = [{y: 0, name: "First floor"}, {y: 30, name: "Second floor"}]`; +const broken = valid.replace('@ToAssistant("Next")), Button', '@ToAssistant("Next"), Button'); + +describe('completed OpenUI structural evidence', () => { + let container: HTMLDivElement; + let root: Root; + const reports = vi.fn<(errors: GenUIRenderDiagnostic[]) => void>(); + beforeEach(() => { + container = document.createElement('div'); + root = createRoot(container); + reports.mockClear(); + }); + afterEach(async () => { await act(async () => root.unmount()); }); + const render = async (content: string, streaming = false) => { + await act(async () => root.render()); + return reports.mock.lastCall?.[0] ?? []; + }; + + it('reports swallowed definitions and restores SVG, controls and detail after the missing parenthesis is repaired', async () => { + const errors = await render(broken); + expect(errors).toContainEqual(expect.objectContaining({ code: 'parse-incomplete' })); + expect(errors).toContainEqual(expect.objectContaining({ code: 'unresolved-reference', statementId: 'layerData' })); + expect(errors.some(isFatalGenUIError)).toBe(true); + expect(container.querySelectorAll('rect')).toHaveLength(0); + expect(await render(valid)).toEqual([]); + expect(container.querySelectorAll('rect')).toHaveLength(2); + expect(container.querySelectorAll('button')).toHaveLength(4); + expect(container.textContent).toContain('First floor'); + }); + + it.each([ + 'root = Col([label])\nlabel = Text("Unclosed', + 'root = Text("Complete")\nunused = Button("Unclosed"', + ])('reports incomplete source even without unresolved references: %s', async content => { + expect((await render(content)).some(error => error.code === 'parse-incomplete')).toBe(true); + }); + + it('does not emit structural errors during streaming, but reports them when streaming ends', async () => { + expect(await render(broken, true)).toEqual([]); + expect((await render(broken)).some(isFatalGenUIError)).toBe(true); + expect(await render(broken, true)).toEqual([]); + expect(await render(valid)).toEqual([]); + }); + + it('requests repair for an undefined reference without treating it as a fatal parse failure', async () => { + const errors = await render('root = Col([Text("Still usable"), missing])'); + expect(errors).toContainEqual(expect.objectContaining({ code: 'unresolved-reference', statementId: 'missing' })); + expect(errors.some(isRepairWorthyGenUIError)).toBe(true); + expect(errors.some(isFatalGenUIError)).toBe(false); + expect(container.textContent).toContain('Still usable'); + }); + + it('does not escalate a property type error or a state binding', async () => { + const errors = await render('root = Col([Button("Wrong variant", null, 3), Text($name)])\n$name = "State value"'); + expect(errors).toContainEqual(expect.objectContaining({ code: 'type-mismatch' })); + expect(errors.some(isRepairWorthyGenUIError)).toBe(false); + expect(container.textContent).toContain('State value'); + }); + + it('reports only the latest source when replacements are batched', async () => { + await act(async () => { + root.render(); + root.render(); + }); + expect(reports.mock.calls.every(([errors]) => errors.length === 0)).toBe(true); + }); +}); diff --git a/src/web-ui/src/tools/generative-ui/genUIVerification.ts b/src/web-ui/src/tools/generative-ui/genUIVerification.ts index c547542471..8e693dd8d1 100644 --- a/src/web-ui/src/tools/generative-ui/genUIVerification.ts +++ b/src/web-ui/src/tools/generative-ui/genUIVerification.ts @@ -1,6 +1,6 @@ import type { GenUIAppState } from './genUITypes'; import type { GenUIContractIssue } from './genUIContract'; -import type { OpenUIError } from '@openuidev/lang-core'; +import type { GenUIRenderDiagnostic } from './genUIRenderDiagnostics'; export interface GenUIVerification { appId?: string; definitionKey: string; @@ -12,5 +12,5 @@ export interface GenUIVerification { persistence: 'unverified' | 'saving' | 'saved' | 'failed'; reopen: 'unverified' | 'restored'; issues: GenUIContractIssue[]; - diagnostics?: OpenUIError[]; + diagnostics?: GenUIRenderDiagnostic[]; } diff --git a/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.test.tsx b/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.test.tsx index 5390b452bd..2919b3cae3 100644 --- a/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.test.tsx +++ b/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.test.tsx @@ -12,6 +12,9 @@ import type { FlowToolItem, Session } from '@/flow_chat/types/flow-chat'; const pickerExample = examples.picker as GenUIAppDefinition; import GenerativeWidgetPanel from './GenerativeWidgetPanel'; +const autoRepair = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +vi.mock('@/tools/generative-ui/genUIAutoRepair', () => ({ requestGenUIAutoRepair: autoRepair })); + vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: createTestI18nT('flow-chat') }), })); @@ -48,6 +51,7 @@ describe('GenerativeWidgetPanel', () => { let container: HTMLDivElement; beforeEach(() => { + autoRepair.mockClear(); container = document.createElement('div'); document.body.appendChild(container); }); @@ -137,4 +141,58 @@ describe('GenerativeWidgetPanel', () => { expect(flowChatStore.findToolItem('loading-session', 'loading-turn', item.id)?.toolResult).toEqual(item.toolResult); } finally { act(() => root.unmount()); } }); + + it.each([false, true])('repairs missing references only after observing a live generation: live=%s', async live => { + const root = createRoot(container); + const app: GenUIAppDefinition = { ...openbitfunApp, + source: { kind: 'openui-lang', content: 'root = Col([Button("Still usable"), missing])' }, + }; + const item = streamingItem(app, app.source!.content); + const origin = mountProjection(item); + flowChatStore.setState(previous => ({ ...previous, activeSessionId: origin.sessionId })); + try { + if (live) await act(async () => root.render()); + await act(async () => { + mountProjection({ ...item, status: 'completed', toolResult: { success: true, result: { app, app_state: {} } } }); + }); + await act(async () => root.render()); + expect(container.textContent).toContain('Still usable'); + expect(container.querySelector('.gen-ui-application button')?.disabled).toBe(false); + if (live) expect(autoRepair).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ render: 'passed', + diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'unresolved-reference', statementId: 'missing' })]), + })); + else { + expect(autoRepair).not.toHaveBeenCalled(); + expect(container.querySelector('[data-openbitfun-state="repairRequired"]')).not.toBeNull(); + } + } finally { await act(async () => root.unmount()); } + }); + + it.each(['processing', 'finishing', 'completed'] as const)('handles a deferred GenUI card first mounted during owning turn status %s', async status => { + const root = createRoot(container); + const app: GenUIAppDefinition = { ...openbitfunApp, + source: { kind: 'openui-lang', content: 'root = Col([Button("Still usable"), missing])' }, + }; + const item: FlowToolItem = { ...streamingItem(app), toolName: 'CallDeferredTool', status: 'completed', + toolCall: { id: 'loading-call', input: { tool_name: 'GenerativeUI', args: {} } }, + toolResult: { success: true, result: { app, app_state: {} } }, + }; + const origin = mountProjection(item); + flowChatStore.setState(previous => ({ ...previous, activeSessionId: origin.sessionId, + sessions: new Map([...previous.sessions].map(([id, session]) => [id, { ...session, + dialogTurns: session.dialogTurns.map(turn => ({ ...turn, status })), + }])), + })); + try { + await act(async () => root.render()); + expect(container.textContent).toContain('Still usable'); + if (status === 'completed') { + expect(autoRepair).not.toHaveBeenCalled(); + expect(container.querySelector('[data-openbitfun-state="repairRequired"]')).not.toBeNull(); + } else { + expect(autoRepair).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-openbitfun-state="repairRequired"]')).toBeNull(); + } + } finally { await act(async () => root.unmount()); } + }); }); diff --git a/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.tsx b/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.tsx index faa9977502..48ff1918b4 100644 --- a/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.tsx +++ b/src/web-ui/src/tools/generative-widget/GenerativeWidgetPanel.tsx @@ -16,7 +16,7 @@ import type { GenUITargetEvent } from '@/tools/generative-ui/genUIAsyncCapabilit import { requiresGenUIAsyncProvider } from '@/tools/generative-ui/genUIAsyncSource'; import type { GenUIVerification } from '@/tools/generative-ui/genUIVerification'; import { requestGenUIAutoRepair } from '@/tools/generative-ui/genUIAutoRepair'; -import { isFatalGenUIError } from '@/tools/generative-ui/genUIRenderDiagnostics'; +import { isRepairWorthyGenUIError } from '@/tools/generative-ui/genUIRenderDiagnostics'; import { genUIContentKey } from '@/tools/generative-ui/genUIState'; import { requestGenUIRepair } from '@/tools/generative-ui/genUIRepair'; import './GenerativeWidgetPanel.scss'; @@ -69,9 +69,9 @@ export const GenerativeWidgetPanel: React.FC = ({ const requiresDelivery = false; const [verification, setVerification] = useState(); const originKey = JSON.stringify(origin); - const observedStreaming = useRef({ originKey, live: false }); - if (observedStreaming.current.originKey !== originKey) { - observedStreaming.current = { originKey, live: false }; + const observedGeneration = useRef({ originKey, live: false }); + if (observedGeneration.current.originKey !== originKey) { + observedGeneration.current = { originKey, live: false }; } const autoRepairKey = useRef(''); const [repairPending, setRepairPending] = useState(false); @@ -83,14 +83,21 @@ export const GenerativeWidgetPanel: React.FC = ({ setRepairQueued(false); setRepairError(''); }, [originKey]); - if (projection?.isStreaming && !projection.readOnly + const sourceTurn = projection?.origin ? flowChatStore.getState().sessions.get(projection.origin.sessionId) + ?.dialogTurns.find(turn => turn.id === projection.origin.turnId) : undefined; + // Deferred calls can be identified as GenUI only once their arguments close, + // after the tool has finished. Their owning turn is still live at that point; + // requiring this component to have seen streaming misses those generations. + const sourceTurnRunning = !sourceTurn?.recovery + && (sourceTurn?.status === 'processing' || sourceTurn?.status === 'finishing'); + if ((projection?.isStreaming || sourceTurnRunning) && projection && !projection.readOnly && flowChatStore.getState().activeSessionId === origin?.sessionId - && document.visibilityState !== 'hidden') observedStreaming.current.live = true; + && document.visibilityState !== 'hidden') observedGeneration.current.live = true; const onVerification = (report: GenUIVerification) => { setVerification(report); - if (!projection?.origin || !projection.app || !observedStreaming.current.live || projection.isStreaming || projection.readOnly || projection.isFailed) return; + if (!projection?.origin || !projection.app || !observedGeneration.current.live || projection.isStreaming || projection.readOnly || projection.isFailed) return; const repairKey = `${report.definitionKey}:${JSON.stringify(report.diagnostics ?? [])}`; - if (report.definitionKey !== genUIContentKey(projection.app) || report.render !== 'failed' || !report.diagnostics?.some(isFatalGenUIError) + if (report.definitionKey !== genUIContentKey(projection.app) || report.render === 'unverified' || !report.diagnostics?.some(isRepairWorthyGenUIError) || autoRepairKey.current === repairKey) return; autoRepairKey.current = repairKey; setRepairPending(true); @@ -120,8 +127,8 @@ export const GenerativeWidgetPanel: React.FC = ({ const currentVerification = app && verification?.definitionKey === genUIContentKey(app) ? verification : undefined; const fatalRender = currentVerification?.render === 'failed'; let repairControl: React.ReactNode = null; - if (fatalRender && currentVerification) { - const repairable = Boolean(projection?.origin && currentVerification.diagnostics?.some(isFatalGenUIError)); + if (currentVerification?.diagnostics?.some(isRepairWorthyGenUIError)) { + const repairable = Boolean(projection?.origin && currentVerification.diagnostics?.some(isRepairWorthyGenUIError)); if (repairable && !projection?.readOnly && !repairQueued && !repairPending) repairControl =