From 0ca04cd1be63d3b886a22db624e49c149db4cf50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 01:57:58 +0000 Subject: [PATCH 1/8] Refactor MCP generated UI shell rendering Co-authored-by: Kent C. Dodds --- e2e/generated-ui-shell.spec.ts | 246 ++++++++++ .../mcp-apps/kody-ui-utils.node.test.ts | 39 +- .../worker/client/mcp-apps/kody-ui-utils.ts | 439 ++++++++++++++++-- .../client/mcp-apps/kody-widget-runtime.ts | 59 ++- 4 files changed, 721 insertions(+), 62 deletions(-) create mode 100644 e2e/generated-ui-shell.spec.ts diff --git a/e2e/generated-ui-shell.spec.ts b/e2e/generated-ui-shell.spec.ts new file mode 100644 index 0000000000..f2e0c13af5 --- /dev/null +++ b/e2e/generated-ui-shell.spec.ts @@ -0,0 +1,246 @@ +import { expect, test } from '@playwright/test' + +test('generated UI shell rerenders inline and saved apps without document rewrites', async ({ + page, + baseURL, +}) => { + if (!baseURL) { + throw new Error('Playwright baseURL is required for generated UI shell tests.') + } + + const runtimeUrl = new URL('/dev/generated-ui', baseURL).toString() + const inlineHtml = [ + '
', + '', + '', + ].join('') + const savedHtml = [ + '', + '', + '', + 'Saved app', + '', + '', + '', + '
', + '', + '', + '', + ].join('') + + await page.setContent(` + + + + + + + + `) + + await page.waitForSelector('#generated-ui-frame') + await page.evaluate(() => { + ;(window as typeof window & { + __generatedUiHostActions: { + renderInline: (params: Record) => void + } + }).__generatedUiHostActions.renderInline({ owner: 'alpha' }) + }) + + const frame = page.frameLocator('#generated-ui-frame') + await expect( + frame.getByRole('button', { name: 'First inline button' }), + ).toBeVisible() + await expect( + frame.getByRole('button', { name: 'Second inline button' }), + ).toBeVisible() + await expect(frame.locator('body')).toHaveAttribute( + 'data-inline-execute', + /alpha/, + ) + await expect + .poll(async () => { + return await page.evaluate(() => { + return ( + (window as typeof window & { + __generatedUiHostState: { lastSize?: { height?: number } | null } + }).__generatedUiHostState.lastSize?.height ?? 0 + ) + }) + }) + .toBeGreaterThan(0) + + await page.evaluate(() => { + ;(window as typeof window & { + __generatedUiHostActions: { + renderSaved: (params: Record) => void + } + }).__generatedUiHostActions.renderSaved({ owner: 'beta', count: 2 }) + }) + + await expect( + frame.getByRole('button', { name: 'Saved app' }), + ).toBeVisible() + await expect(frame.locator('html')).toHaveAttribute('data-shell-phase', 'saved') + await expect(frame.locator('body')).toHaveAttribute('data-shell-body', 'saved') + await expect(frame.locator('body')).toHaveAttribute('data-saved-execute', /beta/) + await expect(frame.locator('body')).toHaveAttribute('data-saved-params', /beta/) + await expect(frame.locator('html')).toHaveAttribute( + 'data-saved-html-attr', + 'saved', + ) + await expect + .poll(async () => { + return await page.evaluate(() => { + return ( + (window as typeof window & { + __generatedUiHostState: { + toolCalls: Array<{ name?: string }> + } + }).__generatedUiHostState.toolCalls.map((call) => call.name) + ) + }) + }) + .toEqual(['execute', 'ui_load_app_source', 'execute']) +}) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts b/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts index 41abda9ce4..149682e5d4 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.node.test.ts @@ -1,6 +1,9 @@ import { expect, test, vi } from 'vitest' import * as uiUtils from './kody-ui-utils.ts' -import { getKodyWidgetRuntimeStateForTest } from './kody-widget-runtime.ts' +import { + getKodyWidgetRuntimeStateForTest, + updateGeneratedUiRuntimeBootstrap, +} from './kody-widget-runtime.ts' const { absolutizeHtmlAttributeUrls, @@ -219,6 +222,40 @@ test('async helpers read installed runtime state', async () => { await expect(kodyWidget.executeCode('return "ok"')).resolves.toBe('ok') }) +test('runtime bootstrap updates refresh params and app session without reinit', () => { + const runtimeState = getKodyWidgetRuntimeStateForTest() + runtimeState.reset() + runtimeState.install({ + mode: 'mcp', + params: { owner: 'kody' }, + appSession: { + token: 'token-1', + endpoints: { + source: 'https://kody.example/ui-api/app/source', + execute: 'https://kody.example/ui-api/app/execute', + secrets: 'https://kody.example/ui-api/app/secrets', + deleteSecret: 'https://kody.example/ui-api/app/secrets/delete', + }, + }, + }) + + updateGeneratedUiRuntimeBootstrap({ + mode: 'mcp', + params: { owner: 'updated', limit: 3 }, + appSession: { + token: 'token-2', + endpoints: { + source: 'https://kody.example/ui-api/next/source', + execute: 'https://kody.example/ui-api/next/execute', + secrets: 'https://kody.example/ui-api/next/secrets', + deleteSecret: 'https://kody.example/ui-api/next/secrets/delete', + }, + }, + }) + + expect(kodyWidget.params).toEqual({ owner: 'updated', limit: 3 }) +}) + test('runtime-backed helpers time out if the runtime never becomes ready', async () => { vi.useFakeTimers() try { diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index 69391d55f4..6bcd80c512 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -4,6 +4,7 @@ import { createWidgetHostBridge } from './widget-host-bridge.js' import { initializeGeneratedUiRuntime, setGeneratedUiRuntimeHooks, + updateGeneratedUiRuntimeBootstrap, } from './kody-widget-runtime.ts' import { buildGeneratedUiRuntimeImportMap, @@ -91,6 +92,39 @@ type SizeMeasurementDocument = { documentElement?: SizeMeasurementElement | null } +type ShellScriptExecutionMode = + | 'module' + | 'native-classic' + | 'isolated-classic' + | 'ignore' + | 'data' + +type ShellScriptDescriptor = { + target: 'head' | 'body' + executionMode: ShellScriptExecutionMode + attributes: Array<{ name: string; value: string }> + src: string | null + textContent: string +} + +type ParsedShellRenderDocument = { + title: string | null + htmlAttributes: Array<{ name: string; value: string }> + bodyAttributes: Array<{ name: string; value: string }> + headNodes: Array + bodyNodes: Array + scripts: Array +} + +type GeneratedUiShellRenderState = { + headSlot: HTMLElement + bodySlot: HTMLElement + mountedHeadNodes: Array + managedHtmlAttributes: Set + managedBodyAttributes: Set + defaultTitle: string +} + type HostToolResult = { structuredContent?: unknown isError?: boolean @@ -561,12 +595,332 @@ async function observeRenderedDocumentSize( } } -function writeDocument(html: string) { +const generatedUiUserHeadSlotSelector = '[data-generated-ui-user-head-slot="true"]' +const generatedUiBodySlotSelector = '[data-generated-ui-body-slot="true"]' + +function ensureGeneratedUiShellRenderState(): + | GeneratedUiShellRenderState + | null { + const documentRef = globalThis.document + if (!documentRef?.head || !documentRef.body) { + return null + } + const existingHeadSlot = documentRef.head.querySelector( + generatedUiUserHeadSlotSelector, + ) + let headSlot: HTMLElement + if (existingHeadSlot instanceof HTMLElement) { + headSlot = existingHeadSlot + } else { + headSlot = documentRef.createElement('meta') + headSlot.setAttribute('data-generated-ui-user-head-slot', 'true') + documentRef.head.appendChild(headSlot) + } + const existingBodySlot = documentRef.body.querySelector( + generatedUiBodySlotSelector, + ) + let bodySlot: HTMLElement + if (existingBodySlot instanceof HTMLElement) { + bodySlot = existingBodySlot + } else { + bodySlot = documentRef.createElement('div') + bodySlot.setAttribute('id', 'app') + bodySlot.setAttribute('data-generated-ui-root', '') + bodySlot.setAttribute('data-generated-ui-body-slot', 'true') + documentRef.body.insertBefore(bodySlot, documentRef.body.firstChild) + } + return { + headSlot, + bodySlot, + mountedHeadNodes: [], + managedHtmlAttributes: new Set(), + managedBodyAttributes: new Set(), + defaultTitle: documentRef.title, + } +} + +function clearGeneratedUiShellHead(state: GeneratedUiShellRenderState) { + for (const node of state.mountedHeadNodes) { + node.parentNode?.removeChild(node) + } + state.mountedHeadNodes = [] +} + +function setManagedElementAttributes( + element: HTMLElement, + nextAttributes: Array<{ name: string; value: string }>, + managedAttributes: Set, +) { + for (const name of managedAttributes) { + element.removeAttribute(name) + } + managedAttributes.clear() + for (const attribute of nextAttributes) { + element.setAttribute(attribute.name, attribute.value) + managedAttributes.add(attribute.name) + } +} + +function mountGeneratedUiShellHeadNodes( + state: GeneratedUiShellRenderState, + nodes: Array, +) { + const documentRef = globalThis.document + if (!documentRef) return + const parentNode = state.headSlot.parentNode + if (!parentNode) return + for (const node of nodes) { + const clone = documentRef.importNode(node, true) + parentNode.insertBefore(clone, state.headSlot) + state.mountedHeadNodes.push(clone) + } +} + +function mountGeneratedUiShellBodyNodes( + state: GeneratedUiShellRenderState, + nodes: Array, +) { const documentRef = globalThis.document if (!documentRef) return - documentRef.open() - documentRef.write(html) - documentRef.close() + state.bodySlot.replaceChildren( + ...nodes.map((node) => documentRef.importNode(node, true)), + ) +} + +function preserveGeneratedUiHeadNode(node: Node) { + if (node instanceof HTMLTitleElement) return false + if (node instanceof HTMLBaseElement) return false + if (node instanceof HTMLMetaElement) { + return !node.hasAttribute('charset') && node.httpEquiv.length === 0 + } + if (node.nodeType === Node.TEXT_NODE) { + return (node.textContent ?? '').trim().length > 0 + } + return true +} + +function isClassicJavascriptScriptType(type: string) { + return ( + type === '' || + type === 'application/ecmascript' || + type === 'application/javascript' || + type === 'text/ecmascript' || + type === 'text/javascript' + ) +} + +function getShellScriptExecutionMode(script: HTMLScriptElement) { + const normalizedType = script.type.trim().toLowerCase() + if (normalizedType === 'importmap' || normalizedType === 'speculationrules') { + return 'ignore' as const + } + if (normalizedType === 'module') { + return 'module' as const + } + if (!isClassicJavascriptScriptType(normalizedType)) { + return 'data' as const + } + return script.src ? ('native-classic' as const) : ('isolated-classic' as const) +} + +function collectElementAttributes(element: Element) { + return Array.from(element.attributes, (attribute) => ({ + name: attribute.name, + value: attribute.value, + })) +} + +function classifyGeneratedUiShellRenderDocument(input: { + documentRef: Document + preserveDocumentChrome: boolean +}) { + const parsed: ParsedShellRenderDocument = { + title: input.preserveDocumentChrome ? (input.documentRef.title || null) : null, + htmlAttributes: input.preserveDocumentChrome + ? collectElementAttributes(input.documentRef.documentElement) + : [], + bodyAttributes: input.preserveDocumentChrome + ? collectElementAttributes(input.documentRef.body) + : [], + headNodes: [], + bodyNodes: [], + scripts: [], + } + + for (const node of Array.from(input.documentRef.head.childNodes)) { + if (node instanceof HTMLScriptElement) { + const executionMode = getShellScriptExecutionMode(node) + if (executionMode === 'data') { + if (preserveGeneratedUiHeadNode(node)) { + parsed.headNodes.push(node) + } + continue + } + if (executionMode === 'ignore') { + continue + } + parsed.scripts.push({ + target: 'head', + executionMode, + attributes: collectElementAttributes(node), + src: node.src || null, + textContent: node.textContent ?? '', + }) + continue + } + if (preserveGeneratedUiHeadNode(node)) { + parsed.headNodes.push(node) + } + } + + for (const node of Array.from(input.documentRef.body.childNodes)) { + if (node instanceof HTMLScriptElement) { + const executionMode = getShellScriptExecutionMode(node) + if (executionMode === 'data') { + parsed.bodyNodes.push(node) + continue + } + if (executionMode === 'ignore') { + continue + } + parsed.scripts.push({ + target: 'body', + executionMode, + attributes: collectElementAttributes(node), + src: node.src || null, + textContent: node.textContent ?? '', + }) + continue + } + parsed.bodyNodes.push(node) + } + + return parsed +} + +function wrapInlineClassicScriptForIsolation(source: string) { + return [';(function () {', source, '}).call(window);'].join('\n') +} + +async function executeGeneratedUiShellScript( + state: GeneratedUiShellRenderState, + scriptDescriptor: ShellScriptDescriptor, +) { + const documentRef = globalThis.document + if (!documentRef) return + const script = documentRef.createElement('script') + const insertScript = () => { + if (scriptDescriptor.target === 'head') { + state.headSlot.parentNode?.insertBefore(script, state.headSlot) + state.mountedHeadNodes.push(script) + return + } + state.bodySlot.appendChild(script) + } + for (const attribute of scriptDescriptor.attributes) { + if (attribute.name === 'src' || attribute.name === 'type') { + continue + } + script.setAttribute(attribute.name, attribute.value) + } + if (scriptDescriptor.executionMode === 'module') { + script.type = 'module' + } + if (scriptDescriptor.executionMode === 'isolated-classic') { + script.textContent = wrapInlineClassicScriptForIsolation( + scriptDescriptor.textContent, + ) + insertScript() + return + } + if (!scriptDescriptor.src) { + script.textContent = scriptDescriptor.textContent + if (scriptDescriptor.executionMode === 'module') { + const loading = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve(), { once: true }) + script.addEventListener( + 'error', + () => { + reject(new Error('Failed to execute generated UI module script.')) + }, + { once: true }, + ) + }) + insertScript() + await loading + return + } + insertScript() + return + } + const loading = new Promise((resolve, reject) => { + script.addEventListener('load', () => resolve(), { once: true }) + script.addEventListener( + 'error', + () => { + reject(new Error(`Failed to load generated UI script: ${script.src}`)) + }, + { once: true }, + ) + }) + if (scriptDescriptor.executionMode === 'native-classic') { + script.async = false + } + script.src = scriptDescriptor.src + insertScript() + await loading +} + +function buildGeneratedUiShellRenderSource(input: { + code: string + runtime: AppRuntime + baseHref: string | null +}) { + return { + htmlSource: renderGeneratedUiDocument({ + code: input.code, + runtime: input.runtime, + headInjection: '', + baseHref: input.baseHref, + }), + preserveDocumentChrome: + input.runtime === 'html' && /<(?:!doctype|html|head|body)\b/i.test(input.code), + } +} + +async function renderGeneratedUiShellDocument(input: { + state: GeneratedUiShellRenderState + htmlSource: string + preserveDocumentChrome: boolean +}) { + const documentRef = globalThis.document + if (!documentRef) return + const parsedDocument = new DOMParser().parseFromString( + input.htmlSource, + 'text/html', + ) + const classifiedDocument = classifyGeneratedUiShellRenderDocument({ + documentRef: parsedDocument, + preserveDocumentChrome: input.preserveDocumentChrome, + }) + clearGeneratedUiShellHead(input.state) + input.state.bodySlot.replaceChildren() + setManagedElementAttributes( + documentRef.documentElement, + classifiedDocument.htmlAttributes, + input.state.managedHtmlAttributes, + ) + setManagedElementAttributes( + documentRef.body, + classifiedDocument.bodyAttributes, + input.state.managedBodyAttributes, + ) + documentRef.title = classifiedDocument.title ?? input.state.defaultTitle + mountGeneratedUiShellHeadNodes(input.state, classifiedDocument.headNodes) + mountGeneratedUiShellBodyNodes(input.state, classifiedDocument.bodyNodes) + for (const scriptDescriptor of classifiedDocument.scripts) { + await executeGeneratedUiShellScript(input.state, scriptDescriptor) + } } export type BuildGeneratedUiHeadInjectionInput = { @@ -705,6 +1059,7 @@ async function initializeShellHostDocument() { }, } let latestEnvelope: RenderEnvelope | null = null + const shellRenderState = ensureGeneratedUiShellRenderState() const hostBridge = createWidgetHostBridge({ appInfo: { @@ -799,50 +1154,51 @@ async function initializeShellHostDocument() { return } - const buildDocument = (code: string, runtime: AppRuntime) => - renderGeneratedUiDocument({ + const mcpRuntimeBootstrap: GeneratedUiRuntimeBootstrap = { + mode: 'mcp', + params: envelope.params ?? {}, + ...(envelope.appSession ? { appSession: envelope.appSession } : {}), + } + updateGeneratedUiRuntimeBootstrap(mcpRuntimeBootstrap) + + const renderCode = async (code: string, runtime: AppRuntime) => { + if (!shellRenderState) { + return + } + const renderSource = buildGeneratedUiShellRenderSource({ code, runtime, - headInjection: buildGeneratedUiRuntimeHeadInjection({ - mode: 'mcp', - params: envelope.params, - appSession: envelope.appSession ?? undefined, - baseHref, - includeRuntimeScript: false, - }), baseHref, }) + await renderGeneratedUiShellDocument({ + state: shellRenderState, + htmlSource: renderSource.htmlSource, + preserveDocumentChrome: renderSource.preserveDocumentChrome, + }) + } - const mcpRuntimeBootstrap: GeneratedUiRuntimeBootstrap = { - mode: 'mcp', - params: envelope.params ?? {}, - ...(envelope.appSession ? { appSession: envelope.appSession } : {}), + const renderError = async (message: string) => { + if (!shellRenderState) { + return + } + await renderGeneratedUiShellDocument({ + state: shellRenderState, + htmlSource: renderGeneratedUiErrorDocument(message), + preserveDocumentChrome: true, + }) } if (envelope.mode === 'inline_code') { if (!envelope.code) { - writeDocument( - renderGeneratedUiErrorDocument( - 'The tool result did not include inline code.', - ), - ) + await renderError('The tool result did not include inline code.') return } - writeDocument(buildDocument(envelope.code, envelope.runtime ?? 'html')) - activateMcpGeneratedUiRuntime( - hostBridge, - mcpRuntimeBootstrap, - latestRenderDataRef, - ) + await renderCode(envelope.code, envelope.runtime ?? 'html') return } if (!envelope.appId) { - writeDocument( - renderGeneratedUiErrorDocument( - 'The tool result did not include an app_id.', - ), - ) + await renderError('The tool result did not include an app_id.') return } @@ -852,17 +1208,12 @@ async function initializeShellHostDocument() { envelope.appSession, ) if (latestEnvelope !== envelope) return - writeDocument(buildDocument(resolved.code, resolved.runtime)) - activateMcpGeneratedUiRuntime( - hostBridge, - mcpRuntimeBootstrap, - latestRenderDataRef, - ) + await renderCode(resolved.code, resolved.runtime) } catch (error) { if (latestEnvelope !== envelope) return const message = error instanceof Error ? error.message : 'Unknown app loading error.' - writeDocument(renderGeneratedUiErrorDocument(message)) + await renderError(message) } } @@ -870,6 +1221,14 @@ async function initializeShellHostDocument() { hostBridge.handleHostMessage(event.data) }) + activateMcpGeneratedUiRuntime( + hostBridge, + { + mode: 'mcp', + params: {}, + }, + latestRenderDataRef, + ) void hostBridge.initialize() hostBridge.requestRenderData() void renderEnvelope(getEnvelopeFromRenderData(latestRenderData)) diff --git a/packages/worker/client/mcp-apps/kody-widget-runtime.ts b/packages/worker/client/mcp-apps/kody-widget-runtime.ts index e58c24112d..08e2390300 100644 --- a/packages/worker/client/mcp-apps/kody-widget-runtime.ts +++ b/packages/worker/client/mcp-apps/kody-widget-runtime.ts @@ -884,35 +884,28 @@ function coerceSessionEndpoints(value: unknown): SessionEndpoints | null { } } -function getBootstrap(): WidgetRuntimeBootstrap { - if (!isRecord(kodyWindow.__kodyGeneratedUiBootstrap)) { +function normalizeBootstrap(value: unknown): WidgetRuntimeBootstrap { + if (!isRecord(value)) { return { mode: 'entry', params: {}, appSession: null, } } - const params = isRecord(kodyWindow.__kodyGeneratedUiBootstrap.params) - ? kodyWindow.__kodyGeneratedUiBootstrap.params - : {} - const appSession = isRecord(kodyWindow.__kodyGeneratedUiBootstrap.appSession) + const params = isRecord(value.params) ? value.params : {} + const appSession = isRecord(value.appSession) ? { token: - typeof kodyWindow.__kodyGeneratedUiBootstrap.appSession.token === - 'string' - ? kodyWindow.__kodyGeneratedUiBootstrap.appSession.token + typeof value.appSession.token === 'string' + ? value.appSession.token : undefined, - endpoints: coerceSessionEndpoints( - kodyWindow.__kodyGeneratedUiBootstrap.appSession.endpoints, - ), + endpoints: coerceSessionEndpoints(value.appSession.endpoints), } : null return { mode: - kodyWindow.__kodyGeneratedUiBootstrap.mode === 'entry' || - kodyWindow.__kodyGeneratedUiBootstrap.mode === 'hosted' || - kodyWindow.__kodyGeneratedUiBootstrap.mode === 'mcp' - ? kodyWindow.__kodyGeneratedUiBootstrap.mode + value.mode === 'entry' || value.mode === 'hosted' || value.mode === 'mcp' + ? value.mode : 'entry', params, appSession: @@ -925,6 +918,34 @@ function getBootstrap(): WidgetRuntimeBootstrap { } } +function applyBootstrapToRuntimeState(bootstrap: WidgetRuntimeBootstrap) { + const state = getOrCreateKodyWidgetRuntimeState() + state.mode = bootstrap.mode + state.params = bootstrap.params + state.appSession = bootstrap.appSession + kodyWindow.__kodyGeneratedUiBootstrap = { + mode: bootstrap.mode, + params: bootstrap.params, + appSession: bootstrap.appSession, + } + syncKodyWidgetParams() + return state +} + +function getBootstrap(): WidgetRuntimeBootstrap { + return normalizeBootstrap(kodyWindow.__kodyGeneratedUiBootstrap) +} + +export function updateGeneratedUiRuntimeBootstrap( + bootstrap: GeneratedUiRuntimeBootstrap, +) { + const normalizedBootstrap = normalizeBootstrap(bootstrap) + const state = applyBootstrapToRuntimeState(normalizedBootstrap) + if (normalizedBootstrap.mode !== 'entry' && state.initialized) { + markKodyWidgetRuntimeReady() + } +} + function getApiErrorMessage(payload: JsonRecord | null, fallback: string) { return typeof payload?.error === 'string' ? payload.error : fallback } @@ -1200,13 +1221,9 @@ export function initializeGeneratedUiRuntime() { if (bootstrap.mode === 'entry') { return } - const state = getOrCreateKodyWidgetRuntimeState() + const state = applyBootstrapToRuntimeState(bootstrap) state.initialized = true - state.mode = bootstrap.mode - state.params = bootstrap.params - state.appSession = bootstrap.appSession kodyWindow.__kodyGeneratedUiRuntimeInitialized = true - syncKodyWidgetParams() markKodyWidgetRuntimeReady() window.addEventListener('error', (event) => { From cbfd27dd899fd9a7461cca7b5d2b12e8dea2de3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 02:21:26 +0000 Subject: [PATCH 2/8] Fix generated UI runtime bootstrap usage Co-authored-by: Kent C. Dodds --- packages/worker/client/mcp-apps/kody-ui-utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index 6bcd80c512..6ecf83262f 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -1002,13 +1002,15 @@ function activateMcpGeneratedUiRuntime( } return (await hostBridge.requestDisplayMode(nextMode)) ?? null } + updateGeneratedUiRuntimeBootstrap(bootstrap) installGeneratedUiRuntimeHooks({ sendMessage: (text) => hostBridge.sendUserMessageWithFallback(text), openLink: (url) => hostBridge.openLink(url), requestDisplayMode, executeCode: async (code, params) => { + const { appSession } = readGeneratedUiBootstrap() const viaHttp = await executeCodeWithHttp( - bootstrap.appSession, + appSession, code, params, ) From 21023b45e6a3225c23c865e1e77e45737e948aff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 02:34:10 +0000 Subject: [PATCH 3/8] Fix generated UI script assembly and app session parsing Co-authored-by: Kent C. Dodds --- e2e/generated-ui-shell.spec.ts | 4 ++-- packages/worker/client/mcp-apps/kody-ui-utils.ts | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/e2e/generated-ui-shell.spec.ts b/e2e/generated-ui-shell.spec.ts index f2e0c13af5..f6ce03aea5 100644 --- a/e2e/generated-ui-shell.spec.ts +++ b/e2e/generated-ui-shell.spec.ts @@ -26,7 +26,7 @@ test('generated UI shell rerenders inline and saved apps without document rewrit "\tdocument.body.dataset.inlineExecute = JSON.stringify(result)", '})', '', - ].join('') + ].join('\n') const savedHtml = [ '', '', @@ -44,7 +44,7 @@ test('generated UI shell rerenders inline and saved apps without document rewrit '', '', '', - ].join('') + ].join('\n') await page.setContent(` diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index 6ecf83262f..ed307e253f 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -39,8 +39,8 @@ type AppRuntime = 'html' | 'javascript' type DisplayMode = 'inline' | 'fullscreen' | 'pip' type AppSessionEnvelope = { - sessionId: string - expiresAt: string + sessionId?: string + expiresAt?: string endpoints: { source: string execute: string @@ -206,19 +206,13 @@ function coerceRuntime(value: unknown): AppRuntime | undefined { function coerceAppSession(value: unknown): AppSessionEnvelope | null { if (!isRecord(value)) return null - if ( - typeof value.sessionId !== 'string' || - typeof value.expiresAt !== 'string' - ) { - return null - } const endpoints = coerceGeneratedUiEndpoints(value.endpoints) if (!endpoints) { return null } return { - sessionId: value.sessionId, - expiresAt: value.expiresAt, + sessionId: typeof value.sessionId === 'string' ? value.sessionId : undefined, + expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined, endpoints, token: typeof value.token === 'string' ? value.token : undefined, } From acb057f861bb552e7caeeeb7cff01b26bf1895ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 03:06:00 +0000 Subject: [PATCH 4/8] Fix generated UI shell validation coverage Co-authored-by: Kent C. Dodds --- e2e/generated-ui-shell.spec.ts | 91 +++++++++++++------ .../worker/client/mcp-apps/kody-ui-utils.ts | 30 +++--- 2 files changed, 78 insertions(+), 43 deletions(-) diff --git a/e2e/generated-ui-shell.spec.ts b/e2e/generated-ui-shell.spec.ts index f6ce03aea5..9c8b14be95 100644 --- a/e2e/generated-ui-shell.spec.ts +++ b/e2e/generated-ui-shell.spec.ts @@ -1,11 +1,17 @@ import { expect, test } from '@playwright/test' +function escapeHtmlScriptContent(value: string) { + return value.replaceAll('', '<\\/script>') +} + test('generated UI shell rerenders inline and saved apps without document rewrites', async ({ page, baseURL, }) => { if (!baseURL) { - throw new Error('Playwright baseURL is required for generated UI shell tests.') + throw new Error( + 'Playwright baseURL is required for generated UI shell tests.', + ) } const runtimeUrl = new URL('/dev/generated-ui', baseURL).toString() @@ -22,8 +28,8 @@ test('generated UI shell rerenders inline and saved apps without document rewrit "btn.id = 'second-inline-button'", "btn.textContent = 'Second inline button'", "document.querySelector('#inline-app')?.append(btn)", - "window.kodyWidget.executeCode('async () => ({ phase: \"inline\", owner: window.kodyWidget.params.owner })').then((result) => {", - "\tdocument.body.dataset.inlineExecute = JSON.stringify(result)", + 'window.kodyWidget.executeCode(\'async () => ({ phase: "inline", owner: window.kodyWidget.params.owner })\').then((result) => {', + '\tdocument.body.dataset.inlineExecute = JSON.stringify(result)', '})', '', ].join('\n') @@ -37,14 +43,16 @@ test('generated UI shell rerenders inline and saved apps without document rewrit '', '
', '', '', '', ].join('\n') + const inlineHtmlJson = escapeHtmlScriptContent(JSON.stringify(inlineHtml)) + const savedHtmlJson = escapeHtmlScriptContent(JSON.stringify(savedHtml)) await page.setContent(` @@ -62,7 +70,7 @@ test('generated UI shell rerenders inline and saved apps without document rewrit lastSize: null, toolCalls: [], savedSource: { - code: ${JSON.stringify(savedHtml)}, + code: ${savedHtmlJson}, runtime: 'html', }, } @@ -83,7 +91,7 @@ test('generated UI shell rerenders inline and saved apps without document rewrit hostState.renderData = { toolOutput: { renderSource: 'inline_code', - code: ${JSON.stringify(inlineHtml)}, + code: ${inlineHtmlJson}, runtime: 'html', params, }, @@ -180,12 +188,23 @@ test('generated UI shell rerenders inline and saved apps without document rewrit `) await page.waitForSelector('#generated-ui-frame') + await page.waitForFunction(() => { + return Boolean( + ( + window as typeof window & { + __generatedUiHostActions?: unknown + } + ).__generatedUiHostActions, + ) + }) await page.evaluate(() => { - ;(window as typeof window & { - __generatedUiHostActions: { - renderInline: (params: Record) => void + ;( + window as typeof window & { + __generatedUiHostActions: { + renderInline: (params: Record) => void + } } - }).__generatedUiHostActions.renderInline({ owner: 'alpha' }) + ).__generatedUiHostActions.renderInline({ owner: 'alpha' }) }) const frame = page.frameLocator('#generated-ui-frame') @@ -203,29 +222,43 @@ test('generated UI shell rerenders inline and saved apps without document rewrit .poll(async () => { return await page.evaluate(() => { return ( - (window as typeof window & { - __generatedUiHostState: { lastSize?: { height?: number } | null } - }).__generatedUiHostState.lastSize?.height ?? 0 + ( + window as typeof window & { + __generatedUiHostState: { lastSize?: { height?: number } | null } + } + ).__generatedUiHostState.lastSize?.height ?? 0 ) }) }) .toBeGreaterThan(0) await page.evaluate(() => { - ;(window as typeof window & { - __generatedUiHostActions: { - renderSaved: (params: Record) => void + ;( + window as typeof window & { + __generatedUiHostActions: { + renderSaved: (params: Record) => void + } } - }).__generatedUiHostActions.renderSaved({ owner: 'beta', count: 2 }) + ).__generatedUiHostActions.renderSaved({ owner: 'beta', count: 2 }) }) - await expect( - frame.getByRole('button', { name: 'Saved app' }), - ).toBeVisible() - await expect(frame.locator('html')).toHaveAttribute('data-shell-phase', 'saved') - await expect(frame.locator('body')).toHaveAttribute('data-shell-body', 'saved') - await expect(frame.locator('body')).toHaveAttribute('data-saved-execute', /beta/) - await expect(frame.locator('body')).toHaveAttribute('data-saved-params', /beta/) + await expect(frame.getByRole('button', { name: 'Saved app' })).toBeVisible() + await expect(frame.locator('html')).toHaveAttribute( + 'data-shell-phase', + 'saved', + ) + await expect(frame.locator('body')).toHaveAttribute( + 'data-shell-body', + 'saved', + ) + await expect(frame.locator('body')).toHaveAttribute( + 'data-saved-execute', + /beta/, + ) + await expect(frame.locator('body')).toHaveAttribute( + 'data-saved-params', + /beta/, + ) await expect(frame.locator('html')).toHaveAttribute( 'data-saved-html-attr', 'saved', @@ -234,12 +267,12 @@ test('generated UI shell rerenders inline and saved apps without document rewrit .poll(async () => { return await page.evaluate(() => { return ( - (window as typeof window & { + window as typeof window & { __generatedUiHostState: { toolCalls: Array<{ name?: string }> } - }).__generatedUiHostState.toolCalls.map((call) => call.name) - ) + } + ).__generatedUiHostState.toolCalls.map((call) => call.name) }) }) .toEqual(['execute', 'ui_load_app_source', 'execute']) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index ed307e253f..f6c64b9d53 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -211,8 +211,10 @@ function coerceAppSession(value: unknown): AppSessionEnvelope | null { return null } return { - sessionId: typeof value.sessionId === 'string' ? value.sessionId : undefined, - expiresAt: typeof value.expiresAt === 'string' ? value.expiresAt : undefined, + sessionId: + typeof value.sessionId === 'string' ? value.sessionId : undefined, + expiresAt: + typeof value.expiresAt === 'string' ? value.expiresAt : undefined, endpoints, token: typeof value.token === 'string' ? value.token : undefined, } @@ -589,12 +591,11 @@ async function observeRenderedDocumentSize( } } -const generatedUiUserHeadSlotSelector = '[data-generated-ui-user-head-slot="true"]' +const generatedUiUserHeadSlotSelector = + '[data-generated-ui-user-head-slot="true"]' const generatedUiBodySlotSelector = '[data-generated-ui-body-slot="true"]' -function ensureGeneratedUiShellRenderState(): - | GeneratedUiShellRenderState - | null { +function ensureGeneratedUiShellRenderState(): GeneratedUiShellRenderState | null { const documentRef = globalThis.document if (!documentRef?.head || !documentRef.body) { return null @@ -714,7 +715,9 @@ function getShellScriptExecutionMode(script: HTMLScriptElement) { if (!isClassicJavascriptScriptType(normalizedType)) { return 'data' as const } - return script.src ? ('native-classic' as const) : ('isolated-classic' as const) + return script.src + ? ('native-classic' as const) + : ('isolated-classic' as const) } function collectElementAttributes(element: Element) { @@ -729,7 +732,9 @@ function classifyGeneratedUiShellRenderDocument(input: { preserveDocumentChrome: boolean }) { const parsed: ParsedShellRenderDocument = { - title: input.preserveDocumentChrome ? (input.documentRef.title || null) : null, + title: input.preserveDocumentChrome + ? input.documentRef.title || null + : null, htmlAttributes: input.preserveDocumentChrome ? collectElementAttributes(input.documentRef.documentElement) : [], @@ -878,7 +883,8 @@ function buildGeneratedUiShellRenderSource(input: { baseHref: input.baseHref, }), preserveDocumentChrome: - input.runtime === 'html' && /<(?:!doctype|html|head|body)\b/i.test(input.code), + input.runtime === 'html' && + /<(?:!doctype|html|head|body)\b/i.test(input.code), } } @@ -1003,11 +1009,7 @@ function activateMcpGeneratedUiRuntime( requestDisplayMode, executeCode: async (code, params) => { const { appSession } = readGeneratedUiBootstrap() - const viaHttp = await executeCodeWithHttp( - appSession, - code, - params, - ) + const viaHttp = await executeCodeWithHttp(appSession, code, params) if (viaHttp.handled) { return viaHttp.result } From 4cdbe03364f595a93f4aaa68200cecabbbe1151f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 03:15:25 +0000 Subject: [PATCH 5/8] Fix generated UI shell rendering Co-authored-by: Kent C. Dodds --- .../worker/client/mcp-apps/kody-ui-utils.ts | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index f6c64b9d53..a46ef17657 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -835,18 +835,7 @@ async function executeGeneratedUiShellScript( if (!scriptDescriptor.src) { script.textContent = scriptDescriptor.textContent if (scriptDescriptor.executionMode === 'module') { - const loading = new Promise((resolve, reject) => { - script.addEventListener('load', () => resolve(), { once: true }) - script.addEventListener( - 'error', - () => { - reject(new Error('Failed to execute generated UI module script.')) - }, - { once: true }, - ) - }) insertScript() - await loading return } insertScript() @@ -1058,6 +1047,14 @@ async function initializeShellHostDocument() { } let latestEnvelope: RenderEnvelope | null = null const shellRenderState = ensureGeneratedUiShellRenderState() + let renderQueue: Promise = Promise.resolve() + + const scheduleRenderEnvelope = (envelope: RenderEnvelope | null) => { + renderQueue = renderQueue + .catch(() => undefined) + .then(() => renderEnvelope(envelope)) + .catch(() => undefined) + } const hostBridge = createWidgetHostBridge({ appInfo: { @@ -1069,7 +1066,7 @@ async function initializeShellHostDocument() { ? (renderData as RenderDataEnvelope) : undefined latestRenderData = nextRenderData - void renderEnvelope(getEnvelopeFromRenderData(nextRenderData)) + scheduleRenderEnvelope(getEnvelopeFromRenderData(nextRenderData)) }, }) @@ -1146,7 +1143,7 @@ async function initializeShellHostDocument() { } } - const renderEnvelope = async (envelope: RenderEnvelope | null) => { + async function renderEnvelope(envelope: RenderEnvelope | null) { latestEnvelope = envelope if (!envelope) { return @@ -1229,7 +1226,7 @@ async function initializeShellHostDocument() { ) void hostBridge.initialize() hostBridge.requestRenderData() - void renderEnvelope(getEnvelopeFromRenderData(latestRenderData)) + scheduleRenderEnvelope(getEnvelopeFromRenderData(latestRenderData)) } async function initializeGeneratedUiRuntimeEntry() { From 2b1f20394e82813ab44d0c30fc187ba1a93cbe0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 03:56:29 +0000 Subject: [PATCH 6/8] Clean up generated UI script loader Co-authored-by: Kent C. Dodds --- packages/worker/client/mcp-apps/kody-ui-utils.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index a46ef17657..291e3b0a70 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -834,10 +834,6 @@ async function executeGeneratedUiShellScript( } if (!scriptDescriptor.src) { script.textContent = scriptDescriptor.textContent - if (scriptDescriptor.executionMode === 'module') { - insertScript() - return - } insertScript() return } From 587c44f2ec4a7fdd0bed2f1d8799283cd7170147 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 03:58:45 +0000 Subject: [PATCH 7/8] Skip stale queued generated UI renders Co-authored-by: Kent C. Dodds --- .../worker/client/mcp-apps/kody-ui-utils.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index 291e3b0a70..a994fec2f4 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -1041,14 +1041,15 @@ async function initializeShellHostDocument() { return latestRenderData }, } - let latestEnvelope: RenderEnvelope | null = null const shellRenderState = ensureGeneratedUiShellRenderState() let renderQueue: Promise = Promise.resolve() + let latestScheduledRenderId = 0 const scheduleRenderEnvelope = (envelope: RenderEnvelope | null) => { + const renderId = ++latestScheduledRenderId renderQueue = renderQueue .catch(() => undefined) - .then(() => renderEnvelope(envelope)) + .then(() => renderEnvelope(envelope, renderId)) .catch(() => undefined) } @@ -1139,8 +1140,15 @@ async function initializeShellHostDocument() { } } - async function renderEnvelope(envelope: RenderEnvelope | null) { - latestEnvelope = envelope + const isStaleRender = (renderId: number) => renderId !== latestScheduledRenderId + + async function renderEnvelope( + envelope: RenderEnvelope | null, + renderId: number, + ) { + if (isStaleRender(renderId)) { + return + } if (!envelope) { return } @@ -1194,14 +1202,17 @@ async function initializeShellHostDocument() { } try { + if (isStaleRender(renderId)) { + return + } const resolved = await resolveSavedAppCode( envelope.appId, envelope.appSession, ) - if (latestEnvelope !== envelope) return + if (isStaleRender(renderId)) return await renderCode(resolved.code, resolved.runtime) } catch (error) { - if (latestEnvelope !== envelope) return + if (isStaleRender(renderId)) return const message = error instanceof Error ? error.message : 'Unknown app loading error.' await renderError(message) From 2ffd963c43f20685a32088996b100274d57ef1bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Apr 2026 04:14:24 +0000 Subject: [PATCH 8/8] Await inline generated UI module scripts Co-authored-by: Kent C. Dodds --- packages/worker/client/mcp-apps/kody-ui-utils.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/worker/client/mcp-apps/kody-ui-utils.ts b/packages/worker/client/mcp-apps/kody-ui-utils.ts index a994fec2f4..279501139b 100644 --- a/packages/worker/client/mcp-apps/kody-ui-utils.ts +++ b/packages/worker/client/mcp-apps/kody-ui-utils.ts @@ -832,10 +832,15 @@ async function executeGeneratedUiShellScript( insertScript() return } + const shouldAwaitLoad = + scriptDescriptor.executionMode === 'module' || + scriptDescriptor.src != null if (!scriptDescriptor.src) { script.textContent = scriptDescriptor.textContent - insertScript() - return + if (!shouldAwaitLoad) { + insertScript() + return + } } const loading = new Promise((resolve, reject) => { script.addEventListener('load', () => resolve(), { once: true }) @@ -850,7 +855,9 @@ async function executeGeneratedUiShellScript( if (scriptDescriptor.executionMode === 'native-classic') { script.async = false } - script.src = scriptDescriptor.src + if (scriptDescriptor.src) { + script.src = scriptDescriptor.src + } insertScript() await loading }