From 383c282e04bc5d2a890fd5ec759be2ae0bceac55 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:53:41 -0500 Subject: [PATCH 01/20] feat(agent): OpenUI library model, fragment builder, and plugin helper (DEV-773) The SDK half of the OpenUI backport from Noetic (DEV-765). Adds packages/agent/src/lib/openui/: defineComponent/createLibrary (Zod props with normative declaration order), the typed fragment() builder with uiRef/uiState/uiBuiltin, OpenUI Lang expression serialization, and the openui(library) helper producing the wire-shaped plugin preference (Zod -> JSON Schema). Co-Authored-By: Claude Fable 5 --- packages/agent/src/index.ts | 29 +++ packages/agent/src/lib/openui/document.ts | 84 +++++++ packages/agent/src/lib/openui/fragment.ts | 143 ++++++++++++ packages/agent/src/lib/openui/index.ts | 36 +++ packages/agent/src/lib/openui/library.ts | 93 ++++++++ packages/agent/src/lib/openui/plugin.ts | 63 ++++++ packages/agent/tests/unit/openui.test.ts | 259 ++++++++++++++++++++++ 7 files changed, 707 insertions(+) create mode 100644 packages/agent/src/lib/openui/document.ts create mode 100644 packages/agent/src/lib/openui/fragment.ts create mode 100644 packages/agent/src/lib/openui/index.ts create mode 100644 packages/agent/src/lib/openui/library.ts create mode 100644 packages/agent/src/lib/openui/plugin.ts create mode 100644 packages/agent/tests/unit/openui.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 6f791d22..ebfc6615 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -182,6 +182,35 @@ export { buildNextTurnParamsContext, executeNextTurnParamsFunctions, } from './lib/next-turn-params.js'; +export type { + ComponentDefinition, + CreateLibraryOptions, + FragmentArg, + FragmentBuilder, + FragmentNode, + OpenUiPlugin, + OpenUiWireComponent, + PropSignature, + UiExpr, + UiFragment, + UiLibrary, + UiLiteralValue, +} from './lib/openui/index.js'; +// OpenUI (generative UI) bindings: library model, fragment builder, plugin helper +export { + componentProps, + createLibrary, + defineComponent, + fragment, + OPENUI_BUILTIN_COMPONENTS, + OPENUI_LANG_DIALECT, + OPENUI_ROOT_REF, + openui, + serializeExpr, + uiBuiltin, + uiRef, + uiState, +} from './lib/openui/index.js'; // Stop condition helpers export { finishReasonIs, diff --git a/packages/agent/src/lib/openui/document.ts b/packages/agent/src/lib/openui/document.ts new file mode 100644 index 00000000..7efb8aeb --- /dev/null +++ b/packages/agent/src/lib/openui/document.ts @@ -0,0 +1,84 @@ +/** + * OpenUI Lang expression model + serialization. + * + * OpenUI Lang is a line-oriented assignment language: one statement per line, + * `name = expression`. The SDK only *authors* OpenUI Lang (tool-authored + * fragments, wire-format libraries) — parsing, validation, and prompt + * injection are API-side responsibilities. This module is therefore the + * minimal expression tree and serializer shared by the fragment builder. + */ + +/** The OpenUI Lang dialect this package emits. */ +export const OPENUI_LANG_DIALECT = 'openui-lang/0.5'; + +/** The reserved assignment ref that designates the document root. */ +export const OPENUI_ROOT_REF = 'root'; + +export type UiLiteralValue = string | number | boolean | null; + +/** Expression tree for one assignment's right-hand side. */ +export type UiExpr = + | { + kind: 'literal'; + value: UiLiteralValue; + } + | { + kind: 'ref'; + name: string; + } + | { + kind: 'state-ref'; + name: string; + } + | { + kind: 'member'; + base: UiExpr; + path: string[]; + } + | { + kind: 'array'; + items: UiExpr[]; + } + | { + kind: 'object'; + entries: Array<{ + key: string; + value: UiExpr; + }>; + } + | { + kind: 'call'; + fn: string; + builtin: boolean; + args: UiExpr[]; + }; + +/** + * A renderable piece of UI: the dialect it's expressed in plus its serialized + * OpenUI Lang source. This is the shape carried on `tool.ui_fragment` stream + * events and (for server tools) `response.openui.fragment` wire events. + */ +export interface UiFragment { + dialect: string; + source: string; +} + +/** Serialize an expression to OpenUI Lang source. */ +export function serializeExpr(expr: UiExpr): string { + switch (expr.kind) { + case 'literal': + return typeof expr.value === 'string' ? JSON.stringify(expr.value) : String(expr.value); + case 'ref': + return expr.name; + case 'state-ref': + return `$${expr.name}`; + case 'member': + return `${serializeExpr(expr.base)}.${expr.path.join('.')}`; + case 'array': + return `[${expr.items.map(serializeExpr).join(', ')}]`; + case 'object': + return `{${expr.entries.map((e) => `${e.key}: ${serializeExpr(e.value)}`).join(', ')}}`; + case 'call': + return `${expr.builtin ? '@' : ''}${expr.fn}(${expr.args.map(serializeExpr).join(', ')})`; + } +} diff --git a/packages/agent/src/lib/openui/fragment.ts b/packages/agent/src/lib/openui/fragment.ts new file mode 100644 index 00000000..3e86185a --- /dev/null +++ b/packages/agent/src/lib/openui/fragment.ts @@ -0,0 +1,143 @@ +/** + * Typed fragment builder for tool-authored UI. + * + * `fragment(library)` compiles a constructor per registered component from the + * library's own Zod prop schemas, so tool render functions build fragments in + * plain TypeScript and get validation at construction time — a typo'd + * component name fails typecheck, a bad literal prop fails before the client + * renderer ever sees it. Constructors return a `FragmentNode` (dialect + + * serialized source) that also composes as a child of other constructors. + */ +import * as z4 from 'zod/v4'; +import type { UiExpr, UiFragment, UiLiteralValue } from './document.js'; +import { OPENUI_LANG_DIALECT, OPENUI_ROOT_REF, serializeExpr } from './document.js'; +import type { UiLibrary } from './library.js'; +import { componentProps } from './library.js'; + +const FRAGMENT_EXPR: unique symbol = Symbol.for('openrouter.openui.fragment-expr'); + +/** A composable fragment node: a {@link UiFragment} that also nests as a child argument. */ +export interface FragmentNode extends UiFragment { + [FRAGMENT_EXPR]: UiExpr; +} + +/** Any value accepted as a fragment constructor argument. */ +export type FragmentArg = + | UiLiteralValue + | FragmentNode + | FragmentArg[] + | { + [key: string]: FragmentArg; + }; + +function isFragmentNode(value: unknown): value is FragmentNode { + return typeof value === 'object' && value !== null && FRAGMENT_EXPR in value; +} + +function toExpr(arg: FragmentArg): UiExpr { + if (isFragmentNode(arg)) { + return arg[FRAGMENT_EXPR]; + } + if (Array.isArray(arg)) { + return { + kind: 'array', + items: arg.map(toExpr), + }; + } + if (typeof arg === 'object' && arg !== null) { + return { + kind: 'object', + entries: Object.entries(arg).map(([key, value]) => ({ + key, + value: toExpr(value), + })), + }; + } + return { + kind: 'literal', + value: arg, + }; +} + +function makeNode(dialect: string, expr: UiExpr): FragmentNode { + return { + dialect, + source: `${OPENUI_ROOT_REF} = ${serializeExpr(expr)}`, + [FRAGMENT_EXPR]: expr, + }; +} + +/** Reference another statement by ref (`uiRef('chart')` → `chart`). */ +export function uiRef(name: string, dialect?: string): FragmentNode { + return makeNode(dialect ?? OPENUI_LANG_DIALECT, { + kind: 'ref', + name, + }); +} + +/** Reference a reactive state variable (`uiState('tab')` → `$tab`). */ +export function uiState(name: string, dialect?: string): FragmentNode { + return makeNode(dialect ?? OPENUI_LANG_DIALECT, { + kind: 'state-ref', + name, + }); +} + +/** A built-in function step (`uiBuiltin('Run', uiRef('save'))` → `@Run(save)`). */ +export function uiBuiltin(fn: string, ...args: FragmentArg[]): FragmentNode { + return makeNode(OPENUI_LANG_DIALECT, { + kind: 'call', + fn, + builtin: true, + args: args.map(toExpr), + }); +} + +/** One constructor per component: builds a validated fragment node. */ +export type FragmentBuilder = Record FragmentNode>; + +/** + * Compile a typed fragment builder from a library. + * + * @example + * ```typescript + * const ui = fragment(library); + * const card = ui.Card('Usage', [ui.Text('$12.30 across 42 requests')]); + * // card.source === 'root = Card("Usage", [Text("$12.30 across 42 requests")])' + * ``` + */ +export function fragment(library: UiLibrary): FragmentBuilder { + const builder: Record FragmentNode> = {}; + for (const def of library.components.values()) { + const props = componentProps(def); + builder[def.name] = (...args: FragmentArg[]) => { + if (args.length > props.length) { + throw new Error( + `${def.name}() takes at most ${props.length} argument(s) (${props.map((p) => p.name).join(', ')}), got ${args.length}`, + ); + } + const exprs = args.map((arg, i) => { + const expr = toExpr(arg); + const prop = props[i]; + if (prop && expr.kind === 'literal') { + const parsed = z4.safeParse(prop.schema, expr.value); + if (!parsed.success) { + throw new Error( + `${def.name}() prop '${prop.name}' rejects ${JSON.stringify(expr.value)}: ${parsed.error.issues[0]?.message ?? 'invalid'}`, + ); + } + } + return expr; + }); + return makeNode(library.dialect, { + kind: 'call', + fn: def.name, + builtin: false, + args: exprs, + }); + }; + } + // Keys are exactly the library's component names; TS can't see that through + // the Map iteration, so cast at the boundary. + return builder as FragmentBuilder; +} diff --git a/packages/agent/src/lib/openui/index.ts b/packages/agent/src/lib/openui/index.ts new file mode 100644 index 00000000..bc5df714 --- /dev/null +++ b/packages/agent/src/lib/openui/index.ts @@ -0,0 +1,36 @@ +/** + * OpenUI (generative UI) bindings for the Agent SDK. + * + * The API owns the heavy lifting — prompt injection, streaming OpenUI Lang + * parsing, and library validation (see DEV-765). This module ships the thin + * client half: the component-library model, the typed fragment builder for + * tool-authored UI, and the `openui()` plugin helper for `callModel()`. + */ +export { + OPENUI_LANG_DIALECT, + OPENUI_ROOT_REF, + serializeExpr, + type UiExpr, + type UiFragment, + type UiLiteralValue, +} from './document.js'; +export { + type FragmentArg, + type FragmentBuilder, + type FragmentNode, + fragment, + uiBuiltin, + uiRef, + uiState, +} from './fragment.js'; +export { + type ComponentDefinition, + type CreateLibraryOptions, + componentProps, + createLibrary, + defineComponent, + OPENUI_BUILTIN_COMPONENTS, + type PropSignature, + type UiLibrary, +} from './library.js'; +export { type OpenUiPlugin, type OpenUiWireComponent, openui } from './plugin.js'; diff --git a/packages/agent/src/lib/openui/library.ts b/packages/agent/src/lib/openui/library.ts new file mode 100644 index 00000000..67188739 --- /dev/null +++ b/packages/agent/src/lib/openui/library.ts @@ -0,0 +1,93 @@ +/** + * Component library model: `defineComponent` / `createLibrary`. + * + * A library is the vocabulary an agent may render — component names plus + * Zod prop schemas whose *declaration order is normative* (positional args in + * OpenUI Lang map to props by declared order). The SDK ships the library to + * the API via the `openui` plugin (see `plugin.ts`); prompt generation and + * document validation happen API-side. + */ + +import type { ZodObject, ZodRawShape } from 'zod/v4'; +import * as z4 from 'zod/v4'; +import type { $ZodType } from 'zod/v4/core'; +import { OPENUI_LANG_DIALECT } from './document.js'; + +/** One registered component: its name, docs, and ordered prop schemas. */ +export interface ComponentDefinition { + name: N; + description?: string; + /** + * Prop schemas. Positional arguments in OpenUI Lang map to props by key + * declaration order (Zod preserves shape insertion order). + */ + props?: ZodObject; +} + +/** Declare a component the model (or a tool) may render. */ +export function defineComponent( + def: ComponentDefinition, +): ComponentDefinition { + return def; +} + +/** + * Components every library accepts implicitly: data bindings, action blocks, + * and the slot that mounts a tool-owned region into a model-authored layout. + */ +export const OPENUI_BUILTIN_COMPONENTS = [ + 'Action', + 'Query', + 'Mutation', + 'ToolView', +] as const; + +/** A registered component library — the vocabulary a surface renders. */ +export interface UiLibrary { + dialect: string; + components: ReadonlyMap; + componentNames: readonly N[]; +} + +/** Options for {@link createLibrary}. */ +export interface CreateLibraryOptions { + dialect?: string; +} + +/** Build a library from component definitions. */ +export function createLibrary( + definitions: D, + options?: CreateLibraryOptions, +): UiLibrary { + const components = new Map(); + for (const def of definitions) { + if (components.has(def.name)) { + throw new Error(`duplicate component name '${def.name}' in library`); + } + components.set(def.name, def); + } + return { + dialect: options?.dialect ?? OPENUI_LANG_DIALECT, + components, + componentNames: definitions.map((d) => d.name), + }; +} + +/** An ordered prop signature for a component (declaration order). */ +export interface PropSignature { + name: string; + optional: boolean; + schema: $ZodType; +} + +/** Ordered prop signatures for a component (declaration order). */ +export function componentProps(def: ComponentDefinition): PropSignature[] { + if (!def.props) { + return []; + } + return Object.entries(def.props.shape).map(([name, schema]) => ({ + name, + optional: z4.safeParse(schema as $ZodType, undefined).success, + schema: schema as $ZodType, + })); +} diff --git a/packages/agent/src/lib/openui/plugin.ts b/packages/agent/src/lib/openui/plugin.ts new file mode 100644 index 00000000..204dcf9d --- /dev/null +++ b/packages/agent/src/lib/openui/plugin.ts @@ -0,0 +1,63 @@ +/** + * The `openui()` request helper: turn a component library into the `openui` + * plugin preference carried on a Responses request. Zod prop schemas are + * converted to JSON Schema at this boundary — the API owns prompt generation + * and validation, so the wire shape is renderer- and SDK-agnostic. + * + * Note: until `@openrouter/sdk` regenerates with the `openui` plugin member + * (DEV-772), the SDK's closed `plugins` union will not accept this shape — + * gate usage on that release. + */ +import { convertZodToJsonSchema } from '../tool-executor.js'; +import type { UiLibrary } from './library.js'; + +/** Wire shape of one component definition inside the plugin preference. */ +export interface OpenUiWireComponent { + name: string; + description?: string; + /** + * JSON Schema for the component's props. Property declaration order is + * normative: positional arguments in OpenUI Lang map to props in order. + */ + props?: Record; +} + +/** Wire shape of the `openui` plugin preference. */ +export interface OpenUiPlugin { + id: 'openui'; + library: OpenUiWireComponent[]; + dialect?: string; +} + +/** + * Build the `openui` plugin preference from a component library. + * + * @example + * ```typescript + * const result = callModel(client, { + * model: 'anthropic/claude-sonnet-5', + * input: 'Show me a dashboard', + * plugins: [openui(library)], + * }); + * ``` + */ +export function openui(library: UiLibrary): OpenUiPlugin { + return { + id: 'openui', + library: [ + ...library.components.values(), + ].map((def) => { + const component: OpenUiWireComponent = { + name: def.name, + }; + if (def.description !== undefined) { + component.description = def.description; + } + if (def.props !== undefined) { + component.props = convertZodToJsonSchema(def.props); + } + return component; + }), + dialect: library.dialect, + }; +} diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts new file mode 100644 index 00000000..74758d84 --- /dev/null +++ b/packages/agent/tests/unit/openui.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import type { UiExpr } from '../../src/lib/openui/document.js'; +import { OPENUI_LANG_DIALECT, serializeExpr } from '../../src/lib/openui/document.js'; +import { fragment, uiBuiltin, uiRef, uiState } from '../../src/lib/openui/fragment.js'; +import { componentProps, createLibrary, defineComponent } from '../../src/lib/openui/library.js'; +import { openui } from '../../src/lib/openui/plugin.js'; + +const library = createLibrary([ + defineComponent({ + name: 'Card', + description: 'Container with a title', + props: z.object({ + title: z.string(), + children: z.array(z.unknown()).optional(), + }), + }), + defineComponent({ + name: 'Text', + props: z.object({ + value: z.string(), + }), + }), + defineComponent({ + name: 'Divider', + }), +]); + +describe('serializeExpr', () => { + it('serializes literals with JSON string quoting', () => { + expect( + serializeExpr({ + kind: 'literal', + value: 'a "quoted" string', + }), + ).toBe('"a \\"quoted\\" string"'); + expect( + serializeExpr({ + kind: 'literal', + value: 42, + }), + ).toBe('42'); + expect( + serializeExpr({ + kind: 'literal', + value: true, + }), + ).toBe('true'); + expect( + serializeExpr({ + kind: 'literal', + value: null, + }), + ).toBe('null'); + }); + + it('serializes refs, state refs, and member access', () => { + expect( + serializeExpr({ + kind: 'ref', + name: 'chart', + }), + ).toBe('chart'); + expect( + serializeExpr({ + kind: 'state-ref', + name: 'tab', + }), + ).toBe('$tab'); + expect( + serializeExpr({ + kind: 'member', + base: { + kind: 'ref', + name: 'data', + }, + path: [ + 'rows', + 'title', + ], + }), + ).toBe('data.rows.title'); + }); + + it('serializes arrays, objects, and calls (builtin vs component)', () => { + const expr: UiExpr = { + kind: 'call', + fn: 'Action', + builtin: false, + args: [ + { + kind: 'array', + items: [ + { + kind: 'call', + fn: 'Run', + builtin: true, + args: [ + { + kind: 'ref', + name: 'save', + }, + ], + }, + ], + }, + { + kind: 'object', + entries: [ + { + key: 'once', + value: { + kind: 'literal', + value: true, + }, + }, + ], + }, + ], + }; + expect(serializeExpr(expr)).toBe('Action([@Run(save)], {once: true})'); + }); +}); + +describe('createLibrary / componentProps', () => { + it('preserves component order and rejects duplicates', () => { + expect(library.componentNames).toEqual([ + 'Card', + 'Text', + 'Divider', + ]); + expect(library.dialect).toBe(OPENUI_LANG_DIALECT); + expect(() => + createLibrary([ + defineComponent({ + name: 'A', + }), + defineComponent({ + name: 'A', + }), + ]), + ).toThrow(/duplicate component name 'A'/); + }); + + it('reports prop signatures in declaration order with optionality', () => { + const card = library.components.get('Card'); + expect(card).toBeDefined(); + const props = componentProps(card!); + expect(props.map((p) => p.name)).toEqual([ + 'title', + 'children', + ]); + expect(props.map((p) => p.optional)).toEqual([ + false, + true, + ]); + }); + + it('supports a custom dialect', () => { + const custom = createLibrary( + [ + defineComponent({ + name: 'X', + }), + ], + { + dialect: 'openui-lang/0.6', + }, + ); + expect(custom.dialect).toBe('openui-lang/0.6'); + }); +}); + +describe('fragment builder', () => { + const ui = fragment(library); + + it('builds a serialized fragment rooted at `root`', () => { + const node = ui.Card('Usage', [ + ui.Text('hello'), + ]); + expect(node.dialect).toBe(OPENUI_LANG_DIALECT); + expect(node.source).toBe('root = Card("Usage", [Text("hello")])'); + }); + + it('composes refs, state, and builtins', () => { + const node = ui.Card('Tabs', [ + uiState('tab'), + uiBuiltin('Run', uiRef('load')), + ]); + expect(node.source).toBe('root = Card("Tabs", [$tab, @Run(load)])'); + }); + + it('accepts plain objects and arrays as args', () => { + const node = ui.Text('ok'); + const wrapped = ui.Card('W', [ + node, + { + nested: [ + 1, + true, + null, + ], + } as never, + ]); + expect(wrapped.source).toBe('root = Card("W", [Text("ok"), {nested: [1, true, null]}])'); + }); + + it('validates literal props at construction time', () => { + expect(() => ui.Text(42)).toThrow(/Text\(\) prop 'value' rejects 42/); + }); + + it('rejects arity overflow', () => { + expect(() => ui.Divider('extra')).toThrow(/Divider\(\) takes at most 0 argument\(s\)/); + }); + + it('skips validation for dynamic args (refs resolve at render time)', () => { + expect(() => ui.Text(uiRef('someRef'))).not.toThrow(); + expect(ui.Text(uiState('value')).source).toBe('root = Text($value)'); + }); +}); + +describe('openui() plugin helper', () => { + it('produces the wire-shaped plugin preference with JSON Schema props', () => { + const plugin = openui(library); + expect(plugin.id).toBe('openui'); + expect(plugin.dialect).toBe(OPENUI_LANG_DIALECT); + expect(plugin.library.map((c) => c.name)).toEqual([ + 'Card', + 'Text', + 'Divider', + ]); + + const card = plugin.library[0]; + expect(card?.description).toBe('Container with a title'); + expect(card?.props).toMatchObject({ + type: 'object', + required: [ + 'title', + ], + }); + // Property declaration order is normative for positional-arg mapping. + expect( + Object.keys( + ( + card?.props as { + properties: object; + } + ).properties, + ), + ).toEqual([ + 'title', + 'children', + ]); + + const divider = plugin.library[2]; + expect(divider?.props).toBeUndefined(); + expect(divider?.description).toBeUndefined(); + }); +}); From a3347b5e2182f77bd483403da418a5735242b819 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:05:08 -0500 Subject: [PATCH 02/20] feat(agent): toUIOutput on tool(), tool.ui_fragment events, and getUiStream() (DEV-773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools can now author OpenUI render fragments: an optional toUIOutput sibling of toModelOutput on every executable tool shape. Fragments are broadcast as tool.ui_fragment stream events after successful execution (render-only, never sent to the model; a throwing toUIOutput degrades to no-fragment). getUiStream() on ModelResult surfaces UI events across all turns: tool-authored fragments plus the API's response.openui.* wire events (statement/fragment/document) from the openui plugin. Wire events not yet in the SDK's stream-event union arrive via its forward-compat Unknown catch-all, so translation reads the raw payload — the stream works both before and after the SDK regen (DEV-772). Co-Authored-By: Claude Fable 5 --- packages/agent/src/index.ts | 9 + packages/agent/src/lib/model-result.ts | 100 ++++ packages/agent/src/lib/openui/index.ts | 8 + packages/agent/src/lib/openui/ui-stream.ts | 194 +++++++ packages/agent/src/lib/tool-types.ts | 44 ++ packages/agent/src/lib/tool.ts | 23 + .../agent/tests/unit/openui-stream.test.ts | 489 ++++++++++++++++++ 7 files changed, 867 insertions(+) create mode 100644 packages/agent/src/lib/openui/ui-stream.ts create mode 100644 packages/agent/tests/unit/openui-stream.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ebfc6615..fc094e20 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -191,10 +191,14 @@ export type { OpenUiPlugin, OpenUiWireComponent, PropSignature, + UiDocumentEvent, UiExpr, UiFragment, + UiFragmentEvent, UiLibrary, UiLiteralValue, + UiStatementEvent, + UiStreamEvent, } from './lib/openui/index.js'; // OpenUI (generative UI) bindings: library model, fragment builder, plugin helper export { @@ -205,8 +209,10 @@ export { OPENUI_BUILTIN_COMPONENTS, OPENUI_LANG_DIALECT, OPENUI_ROOT_REF, + OPENUI_WIRE_EVENT, openui, serializeExpr, + translateUiEvent, uiBuiltin, uiRef, uiState, @@ -277,8 +283,10 @@ export type { ToolResultEvent, ToolResultItem, ToolStreamEvent, + ToolUiFragmentEvent, ToolWithExecute, ToolWithGenerator, + ToUIOutputFunction, TurnContext, TurnEndEvent, TurnStartEvent, @@ -301,6 +309,7 @@ export { isToolCallOutputEvent, isToolPreliminaryResultEvent, isToolResultEvent, + isToolUiFragmentEvent, isTurnEndEvent, isTurnStartEvent, ToolType, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index acab9dce..8940f7d5 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -31,6 +31,8 @@ import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js'; +import type { UiStreamEvent } from './openui/ui-stream.js'; +import { translateUiEvent } from './openui/ui-stream.js'; import { ReusableReadableStream } from './reusable-stream.js'; import { isStopConditionMet } from './stop-conditions.js'; import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; @@ -78,6 +80,7 @@ import type { ToolContextMapWithShared, ToolResultItem, ToolStreamEvent, + ToolUiFragmentEvent, TurnContext, TurnEndEvent, TurnStartEvent, @@ -2670,6 +2673,8 @@ export class ModelResult< output: executedOutput, timestamp: Date.now(), } satisfies ToolCallOutputEvent); + + await this.broadcastUiFragment(value); } return { @@ -2678,6 +2683,54 @@ export class ModelResult< }; } + /** + * Compute and broadcast a tool-authored OpenUI fragment for a successful + * execution. Render-only: the fragment never reaches the model, so a + * throwing `toUIOutput` degrades to "no fragment" instead of failing the + * round — the model-facing output has already been pushed. + */ + private async broadcastUiFragment(value: { + toolCall: ParsedToolCall; + tool: Tool; + result: { + result: unknown; + error?: Error; + }; + }): Promise { + if ( + value.result.error || + !isAutoResolvableTool(value.tool) || + !value.tool.function.toUIOutput + ) { + return; + } + const rawArgs: unknown = value.toolCall.arguments; + if (!isRecord(rawArgs)) { + return; + } + try { + const fragment = await value.tool.function.toUIOutput({ + output: value.result.result, + input: rawArgs, + }); + if (!fragment) { + return; + } + this.turnBroadcaster?.push({ + type: 'tool.ui_fragment' as const, + toolCallId: value.toolCall.id, + toolName: value.toolCall.name, + fragment: { + dialect: fragment.dialect, + source: fragment.source, + }, + timestamp: Date.now(), + } satisfies ToolUiFragmentEvent); + } catch { + // Fragment construction failed — drop it; rendering is best-effort. + } + } + /** * Resolve async functions for the current turn. * Updates the resolved request with turn-specific parameter values. @@ -4592,6 +4645,53 @@ export class ModelResult< }.call(this); } + /** + * Stream OpenUI events from all turns: completed OpenUI Lang statements + * authored by the model (`response.openui.*` wire events from the `openui` + * plugin) and tool-authored fragments (`tool.ui_fragment` events produced + * by tools declaring `toUIOutput`). + * + * Wire events not yet in the SDK's stream-event union arrive through its + * forward-compat catch-all; translation reads the raw payload, so this + * stream works both before and after the SDK regen picks them up. + */ + getUiStream(): AsyncIterableIterator { + return async function* (this: ModelResult) { + await this.initStreamGuarded(); + + if (!this.options.tools?.length) { + let streamFailed = false; + try { + if (this.reusableStream) { + for await (const event of this.reusableStream.createConsumer()) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } + } + } + } catch (error) { + streamFailed = true; + throw error; + } finally { + await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete'); + } + return; + } + + const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); + + for await (const event of consumer) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } + } + + await executionPromise; + }.call(this); + } + /** * Stream tool call argument deltas and preliminary results from all turns. * Preliminary results are streamed in REAL-TIME as generator tools yield. diff --git a/packages/agent/src/lib/openui/index.ts b/packages/agent/src/lib/openui/index.ts index bc5df714..78cf91f1 100644 --- a/packages/agent/src/lib/openui/index.ts +++ b/packages/agent/src/lib/openui/index.ts @@ -34,3 +34,11 @@ export { type UiLibrary, } from './library.js'; export { type OpenUiPlugin, type OpenUiWireComponent, openui } from './plugin.js'; +export { + OPENUI_WIRE_EVENT, + translateUiEvent, + type UiDocumentEvent, + type UiFragmentEvent, + type UiStatementEvent, + type UiStreamEvent, +} from './ui-stream.js'; diff --git a/packages/agent/src/lib/openui/ui-stream.ts b/packages/agent/src/lib/openui/ui-stream.ts new file mode 100644 index 00000000..6414fe12 --- /dev/null +++ b/packages/agent/src/lib/openui/ui-stream.ts @@ -0,0 +1,194 @@ +/** + * UI stream event model: the events `getUiStream()` yields, plus the + * translation from raw response-stream events. + * + * Two sources feed the UI stream: + * - `tool.ui_fragment` — SDK-synthetic events broadcast when a local tool's + * `toUIOutput` produces a fragment. + * - `response.openui.*` — API wire events emitted by the `openui` plugin. + * Until `@openrouter/sdk` regenerates with these union members (DEV-772), + * they arrive through the SDK's forward-compat catch-all as + * `{ type: 'UNKNOWN', raw: {...}, isUnknown: true }` — so translation reads + * the raw payload, never the outer discriminant. + */ + +/** One completed OpenUI Lang statement authored by the model. */ +export interface UiStatementEvent { + type: 'statement'; + /** Assignment target ref (state refs keep their `$` prefix). */ + ref: string; + /** Statement classification: component | state | query | mutation | value. */ + kind: string; + /** OpenUI Lang source of the single completed statement. */ + source: string; +} + +/** A tool-authored fragment (local `toUIOutput` or API `response.openui.fragment`). */ +export interface UiFragmentEvent { + type: 'fragment'; + /** The tool call this fragment belongs to, when tool-authored. */ + toolCallId?: string; + /** The tool that authored the fragment, when known (local tools only). */ + toolName?: string; + dialect: string; + source: string; +} + +/** Turn-end document summary from the API (root ref + diagnostics). */ +export interface UiDocumentEvent { + type: 'document'; + root: string | null; + dialect: string; + diagnostics: Array<{ + line?: number; + message: string; + source?: string; + }>; +} + +/** Every event {@link ModelResult.getUiStream} yields. */ +export type UiStreamEvent = UiStatementEvent | UiFragmentEvent | UiDocumentEvent; + +/** Wire event types the `openui` plugin emits on the Responses stream. */ +export const OPENUI_WIRE_EVENT = { + Statement: 'response.openui.statement', + Fragment: 'response.openui.fragment', + Document: 'response.openui.document', +} as const; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Unwrap the SDK's forward-compat catch-all: unrecognized SSE event types + * parse to `{ type: 'UNKNOWN', raw: , isUnknown: true }`. Returns + * the payload carrying the real `type` either way. + */ +function unwrapEvent(event: unknown): Record | null { + if (!isRecord(event)) { + return null; + } + if (event['isUnknown'] === true && isRecord(event['raw'])) { + return event['raw']; + } + return event; +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** + * Translate one response-stream event into a UI stream event, or null when + * the event carries nothing the UI renders. Handles both the SDK-synthetic + * `tool.ui_fragment` and the API's `response.openui.*` wire events (including + * their pre-regen `Unknown` encoding). + */ +export function translateUiEvent(event: unknown): UiStreamEvent | null { + const payload = unwrapEvent(event); + if (!payload) { + return null; + } + + switch (payload['type']) { + case 'tool.ui_fragment': { + const fragment = payload['fragment']; + if (!isRecord(fragment)) { + return null; + } + const dialect = str(fragment['dialect']); + const source = str(fragment['source']); + if (dialect === undefined || source === undefined) { + return null; + } + const result: UiFragmentEvent = { + type: 'fragment', + dialect, + source, + }; + const toolCallId = str(payload['toolCallId']); + if (toolCallId !== undefined) { + result.toolCallId = toolCallId; + } + const toolName = str(payload['toolName']); + if (toolName !== undefined) { + result.toolName = toolName; + } + return result; + } + + case OPENUI_WIRE_EVENT.Statement: { + const ref = str(payload['ref']); + const kind = str(payload['kind']); + const source = str(payload['source']); + if (ref === undefined || kind === undefined || source === undefined) { + return null; + } + return { + type: 'statement', + ref, + kind, + source, + }; + } + + case OPENUI_WIRE_EVENT.Fragment: { + const dialect = str(payload['dialect']); + const source = str(payload['source']); + if (dialect === undefined || source === undefined) { + return null; + } + const result: UiFragmentEvent = { + type: 'fragment', + dialect, + source, + }; + // Wire field is snake_case; tolerate camelCase for forward compat. + const callId = str(payload['call_id']) ?? str(payload['callId']); + if (callId !== undefined) { + result.toolCallId = callId; + } + return result; + } + + case OPENUI_WIRE_EVENT.Document: { + const dialect = str(payload['dialect']); + if (dialect === undefined) { + return null; + } + const root = str(payload['root']) ?? null; + const rawDiagnostics = payload['diagnostics']; + const diagnostics: UiDocumentEvent['diagnostics'] = Array.isArray(rawDiagnostics) + ? rawDiagnostics.filter(isRecord).flatMap((d) => { + const message = str(d['message']); + if (message === undefined) { + return []; + } + const diagnostic: UiDocumentEvent['diagnostics'][number] = { + message, + }; + if (typeof d['line'] === 'number') { + diagnostic.line = d['line']; + } + const source = str(d['source']); + if (source !== undefined) { + diagnostic.source = source; + } + return [ + diagnostic, + ]; + }) + : []; + return { + type: 'document', + root, + dialect, + diagnostics, + }; + } + + default: + return null; + } +} diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 6656c7c3..5c8e798e 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -2,6 +2,7 @@ import type * as models from '@openrouter/sdk/models'; import type { StreamEvents } from '@openrouter/sdk/models'; import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; import type { DoomLoopSerializedState } from './doom-loop.js'; +import type { UiFragment } from './openui/document.js'; /** * Tool type enum for enhanced tools @@ -265,6 +266,22 @@ export type ToModelOutputFunction = { }): ToModelOutputResult | Promise; }['bivarianceHack']; +/** + * Function to convert tool execution output to a renderable UI fragment + * (OpenUI). Runs after a successful execution alongside `toModelOutput`; the + * fragment is broadcast as a `tool.ui_fragment` stream event and never sent + * to the model. Returning `null`/`undefined` emits nothing for this call. + * @template TInput - The tool's input type + * @template TOutput - The tool's output type + */ +// Object-with-method form for bivariant param checking — see ToModelOutputFunction. +export type ToUIOutputFunction = { + bivarianceHack(params: { + output: TOutput; + input: TInput; + }): UiFragment | null | undefined | Promise; +}['bivarianceHack']; + /** * Base tool function interface with inputSchema * @template TInput - Zod schema for tool input @@ -319,6 +336,8 @@ export interface ToolFunctionWithExecute< ): Promise> | zodInfer; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; } /** @@ -360,6 +379,8 @@ export interface ToolFunctionWithGenerator< ): AsyncGenerator | zodInfer, zodInfer | undefined>; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; } /** @@ -409,6 +430,8 @@ export interface HITLToolFunction< context?: ToolExecuteContext, ): Promise> | zodInfer; toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; } /** @@ -964,6 +987,19 @@ export type ToolCallOutputEvent = { timestamp: number; }; +/** + * Tool UI fragment event carrying a tool-authored OpenUI fragment. + * Broadcast by executeToolRound after a successful execution when the tool + * declares `toUIOutput`. Client-render only — never sent to the model. + */ +export type ToolUiFragmentEvent = { + type: 'tool.ui_fragment'; + toolCallId: string; + toolName: string; + fragment: UiFragment; + timestamp: number; +}; + /** * Turn start event emitted at the beginning of each API turn * Turn 0 is the initial request, subsequent turns follow tool execution @@ -995,6 +1031,7 @@ export type ResponseStreamEvent = | ToolPreliminaryResultEvent | ToolResultEvent | ToolCallOutputEvent + | ToolUiFragmentEvent | TurnStartEvent | TurnEndEvent; @@ -1023,6 +1060,13 @@ export function isToolCallOutputEvent(event: ResponseStreamEvent): event is Tool return event.type === 'tool.call_output'; } +/** + * Type guard to check if an event is a tool UI fragment event + */ +export function isToolUiFragmentEvent(event: ResponseStreamEvent): event is ToolUiFragmentEvent { + return event.type === 'tool.ui_fragment'; +} + /** * Type guard to check if an event is a turn start event */ diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 911667e9..72a76cf9 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -15,6 +15,7 @@ import type { ToolLoopKey, ToolWithExecute, ToolWithGenerator, + ToUIOutputFunction, } from './tool-types.js'; import { isClientTool, SHARED_CONTEXT_KEY, ToolType } from './tool-types.js'; @@ -48,6 +49,8 @@ type RegularToolConfigWithOutput< ) => Promise> | zodInfer; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; }; /** @@ -76,6 +79,8 @@ type RegularToolConfigWithoutOutput< ) => Promise | TReturn; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, TReturn>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, TReturn>; }; /** @@ -105,6 +110,8 @@ type GeneratorToolConfig< ) => AsyncGenerator | zodInfer>; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; }; /** @@ -171,6 +178,8 @@ type HITLToolConfig< ) => Promise> | zodInfer; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, zodInfer>; }; /** @@ -203,6 +212,8 @@ type ToolConfigWithSharedContext< | false; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, unknown>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUIOutput?: ToUIOutputFunction, unknown>; }; //#endregion @@ -379,6 +390,10 @@ export function tool( fn.toModelOutput = config.toModelOutput; } + if (config.toUIOutput !== undefined) { + fn.toUIOutput = config.toUIOutput; + } + return { type: ToolType.Function, function: fn, @@ -464,6 +479,10 @@ export function tool( fn.toModelOutput = config.toModelOutput; } + if ('toUIOutput' in config && config.toUIOutput !== undefined) { + fn.toUIOutput = config.toUIOutput; + } + return { type: ToolType.Function, function: fn, @@ -497,6 +516,10 @@ export function tool( config.toModelOutput !== undefined && { toModelOutput: config.toModelOutput, }), + ...('toUIOutput' in config && + config.toUIOutput !== undefined && { + toUIOutput: config.toUIOutput, + }), }; return { diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts new file mode 100644 index 00000000..a13ea237 --- /dev/null +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -0,0 +1,489 @@ +/** + * Tests for the OpenUI streaming half: toUIOutput plumbing through tool(), + * the tool.ui_fragment broadcast, translateUiEvent (including the SDK's + * forward-compat Unknown encoding of response.openui.* wire events), and + * getUiStream()'s no-tools fast path. + */ +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { ModelResult } from '../../src/lib/model-result.js'; +import { fragment } from '../../src/lib/openui/fragment.js'; +import { createLibrary, defineComponent } from '../../src/lib/openui/library.js'; +import { translateUiEvent } from '../../src/lib/openui/ui-stream.js'; +import { ReusableReadableStream } from '../../src/lib/reusable-stream.js'; +import { tool } from '../../src/lib/tool.js'; +import type { ParsedToolCall, Tool } from '../../src/lib/tool-types.js'; +import { isToolUiFragmentEvent } from '../../src/lib/tool-types.js'; + +const library = createLibrary([ + defineComponent({ + name: 'Card', + props: z.object({ + title: z.string(), + }), + }), + defineComponent({ + name: 'Text', + props: z.object({ + value: z.string(), + }), + }), +]); +const ui = fragment(library); + +describe('tool() carries toUIOutput', () => { + it('regular tool', () => { + const t = tool({ + name: 'usage', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => ({ + total: 12, + }), + toUIOutput: ({ output }) => ui.Card(`$${output.total}`), + }); + expect(t.function.toUIOutput).toBeTypeOf('function'); + }); + + it('generator tool', () => { + const t = tool({ + name: 'gen', + inputSchema: z.object({}), + eventSchema: z.object({ + status: z.string(), + }), + outputSchema: z.object({ + done: z.boolean(), + }), + execute: async function* () { + yield { + done: true, + }; + }, + toUIOutput: () => ui.Text('done'), + }); + expect(t.function.toUIOutput).toBeTypeOf('function'); + }); + + it('HITL tool', () => { + const t = tool({ + name: 'hitl', + inputSchema: z.object({}), + outputSchema: z.object({ + ok: z.boolean(), + }), + onToolCalled: () => null, + toUIOutput: () => ui.Text('pending'), + }); + expect(t.function.toUIOutput).toBeTypeOf('function'); + }); + + it('omitted stays absent', () => { + const t = tool({ + name: 'plain', + inputSchema: z.object({}), + execute: async () => 'ok', + }); + expect('toUIOutput' in t.function && t.function.toUIOutput !== undefined).toBe(false); + }); +}); + +describe('translateUiEvent', () => { + it('translates tool.ui_fragment synthetic events', () => { + const event = translateUiEvent({ + type: 'tool.ui_fragment', + toolCallId: 'c1', + toolName: 'usage', + fragment: { + dialect: 'openui-lang/0.5', + source: 'root = Card("hi")', + }, + timestamp: 1, + }); + expect(event).toEqual({ + type: 'fragment', + toolCallId: 'c1', + toolName: 'usage', + dialect: 'openui-lang/0.5', + source: 'root = Card("hi")', + }); + }); + + it('translates response.openui.statement wire events', () => { + const event = translateUiEvent({ + type: 'response.openui.statement', + ref: 'root', + kind: 'component', + source: 'root = Card("Usage")', + }); + expect(event).toEqual({ + type: 'statement', + ref: 'root', + kind: 'component', + source: 'root = Card("Usage")', + }); + }); + + it("unwraps the SDK's Unknown forward-compat encoding", () => { + const event = translateUiEvent({ + type: 'UNKNOWN', + isUnknown: true, + raw: { + type: 'response.openui.statement', + ref: '$tab', + kind: 'state', + source: '$tab = "overview"', + }, + }); + expect(event).toEqual({ + type: 'statement', + ref: '$tab', + kind: 'state', + source: '$tab = "overview"', + }); + }); + + it('translates response.openui.fragment with snake_case call_id', () => { + const event = translateUiEvent({ + type: 'response.openui.fragment', + call_id: 'srv_1', + dialect: 'openui-lang/0.5', + source: 'root = Text("x")', + }); + expect(event).toEqual({ + type: 'fragment', + toolCallId: 'srv_1', + dialect: 'openui-lang/0.5', + source: 'root = Text("x")', + }); + }); + + it('translates response.openui.document with diagnostics', () => { + const event = translateUiEvent({ + type: 'response.openui.document', + root: 'root', + dialect: 'openui-lang/0.5', + diagnostics: [ + { + line: 3, + message: 'prose line', + source: 'Here is your UI:', + }, + { + message: 'no line', + }, + 'garbage', + ], + }); + expect(event).toEqual({ + type: 'document', + root: 'root', + dialect: 'openui-lang/0.5', + diagnostics: [ + { + line: 3, + message: 'prose line', + source: 'Here is your UI:', + }, + { + message: 'no line', + }, + ], + }); + }); + + it('returns null for everything else', () => { + expect( + translateUiEvent({ + type: 'response.output_text.delta', + delta: 'hi', + }), + ).toBeNull(); + expect( + translateUiEvent({ + type: 'turn.start', + turnNumber: 0, + timestamp: 1, + }), + ).toBeNull(); + expect(translateUiEvent(null)).toBeNull(); + expect(translateUiEvent('text')).toBeNull(); + // Malformed payloads degrade to null, never throw. + expect( + translateUiEvent({ + type: 'response.openui.statement', + ref: 'r', + }), + ).toBeNull(); + expect( + translateUiEvent({ + type: 'tool.ui_fragment', + fragment: 'not-an-object', + }), + ).toBeNull(); + }); +}); + +describe('getUiStream (no-tools fast path)', () => { + function makeModelResult(events: unknown[]): ModelResult { + const modelResult = new ModelResult({ + request: { + model: 'test-model', + input: 'test', + }, + client: {} as OpenRouterCore, + }); + const readable = new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(event); + } + controller.close(); + }, + }); + const internal = modelResult as unknown as Record; + internal['reusableStream'] = new ReusableReadableStream(readable); + internal['initPromise'] = Promise.resolve(); + return modelResult; + } + + it('yields only UI events, in order, from a mixed stream', async () => { + const modelResult = makeModelResult([ + { + type: 'response.output_text.delta', + delta: 'Here ', + }, + { + type: 'UNKNOWN', + isUnknown: true, + raw: { + type: 'response.openui.statement', + ref: 'a', + kind: 'component', + source: 'a = Text("1")', + }, + }, + { + type: 'response.output_text.delta', + delta: 'you go', + }, + { + type: 'UNKNOWN', + isUnknown: true, + raw: { + type: 'response.openui.document', + root: 'a', + dialect: 'openui-lang/0.5', + diagnostics: [], + }, + }, + { + type: 'response.completed', + response: { + id: 'r1', + }, + }, + ]); + + const events = []; + for await (const event of modelResult.getUiStream()) { + events.push(event); + } + expect(events).toEqual([ + { + type: 'statement', + ref: 'a', + kind: 'component', + source: 'a = Text("1")', + }, + { + type: 'document', + root: 'a', + dialect: 'openui-lang/0.5', + diagnostics: [], + }, + ]); + }); + + it('yields nothing for a stream with no UI events', async () => { + const modelResult = makeModelResult([ + { + type: 'response.output_text.delta', + delta: 'plain text', + }, + { + type: 'response.completed', + response: { + id: 'r1', + }, + }, + ]); + const events = []; + for await (const event of modelResult.getUiStream()) { + events.push(event); + } + expect(events).toEqual([]); + }); +}); + +describe('broadcastUiFragment', () => { + type Internal = { + turnBroadcaster: { + push: (event: unknown) => void; + } | null; + broadcastUiFragment: (value: { + toolCall: ParsedToolCall; + tool: Tool; + result: { + result: unknown; + error?: Error; + }; + }) => Promise; + }; + + function makeHarness() { + const pushed: unknown[] = []; + const modelResult = new ModelResult({ + request: { + model: 'test-model', + input: 'test', + }, + client: {} as OpenRouterCore, + }); + const internal = modelResult as unknown as Internal; + internal.turnBroadcaster = { + push: (event: unknown) => { + pushed.push(event); + }, + }; + return { + internal, + pushed, + }; + } + + function makeCall( + t: Tool, + result: { + result: unknown; + error?: Error; + }, + ) { + return { + toolCall: { + id: 'c1', + name: t.type === 'function' ? t.function.name : 'server', + arguments: { + days: 7, + }, + } as unknown as ParsedToolCall, + tool: t, + result, + }; + } + + it('pushes a tool.ui_fragment event for a successful execution', async () => { + const { internal, pushed } = makeHarness(); + const t = tool({ + name: 'usage', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => ({ + total: 12, + }), + toUIOutput: ({ output, input }) => ui.Card(`$${output.total} over ${input.days}d`), + }); + + await internal.broadcastUiFragment( + makeCall(t, { + result: { + total: 12, + }, + }), + ); + + expect(pushed).toHaveLength(1); + const event = pushed[0]; + expect(isToolUiFragmentEvent(event as never)).toBe(true); + expect(event).toMatchObject({ + type: 'tool.ui_fragment', + toolCallId: 'c1', + toolName: 'usage', + fragment: { + dialect: 'openui-lang/0.5', + source: 'root = Card("$12 over 7d")', + }, + }); + }); + + it('skips tools without toUIOutput and errored executions', async () => { + const { internal, pushed } = makeHarness(); + const plain = tool({ + name: 'plain', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'ok', + }); + await internal.broadcastUiFragment( + makeCall(plain, { + result: 'ok', + }), + ); + + const withUi = tool({ + name: 'ui_tool', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'ok', + toUIOutput: () => ui.Text('never'), + }); + await internal.broadcastUiFragment( + makeCall(withUi, { + result: undefined, + error: new Error('boom'), + }), + ); + + expect(pushed).toEqual([]); + }); + + it('drops the fragment when toUIOutput returns null or throws', async () => { + const { internal, pushed } = makeHarness(); + const nullTool = tool({ + name: 'null_ui', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'ok', + toUIOutput: () => null, + }); + await internal.broadcastUiFragment( + makeCall(nullTool, { + result: 'ok', + }), + ); + + const throwingTool = tool({ + name: 'throwing_ui', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'ok', + toUIOutput: () => { + throw new Error('render bug'); + }, + }); + await internal.broadcastUiFragment( + makeCall(throwingTool, { + result: 'ok', + }), + ); + + expect(pushed).toEqual([]); + }); +}); From 978cab95f497ed369daae9d6a5691c535c0ac508 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:23:50 -0500 Subject: [PATCH 03/20] feat(playground): OpenUI test/bench/eval webapp (DEV-773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New private package @openrouter/openui-playground: a local webapp for testing, benching, and evaluating OpenUI generative-UI support. - Progressive renderer over the demo component library (Stack/Card/ Heading/Text/Stat/Badge/Table/Input/Select/Button/Progress) — UI materializes statement-by-statement mid-stream - Two modes with identical event shapes: emulate (local library prompt + reference streaming parser over the text stream — works today) and native (openui() plugin + getUiStream() — flips on when DEV-771/772 land), so the paths can be A/B'd from the history table - Bench stats per run: TTFB, first-statement latency, total time, statement/diagnostic counts, token usage, cost; session history for comparing models and prompts - Reference incremental OpenUI Lang parser (the same logic DEV-770 ports into openrouter-web) with 11 conformance tests - Plain node:http + static client; no build step Verified end-to-end against live models: single-card and 12-statement dashboard prompts parse clean (0 diagnostics) and render progressively. Co-Authored-By: Claude Fable 5 --- packages/openui-playground/README.md | 46 ++ packages/openui-playground/package.json | 26 + packages/openui-playground/public/app.js | 576 ++++++++++++++++++ packages/openui-playground/public/index.html | 149 +++++ .../openui-playground/src/demo-library.ts | 124 ++++ packages/openui-playground/src/generate.ts | 278 +++++++++ packages/openui-playground/src/lang/parser.ts | 551 +++++++++++++++++ packages/openui-playground/src/lang/prompt.ts | 69 +++ packages/openui-playground/src/server.ts | 192 ++++++ .../openui-playground/tests/parser.test.ts | 170 ++++++ packages/openui-playground/tsconfig.json | 9 + packages/openui-playground/vitest.config.ts | 7 + pnpm-lock.yaml | 318 +++++++++- 13 files changed, 2507 insertions(+), 8 deletions(-) create mode 100644 packages/openui-playground/README.md create mode 100644 packages/openui-playground/package.json create mode 100644 packages/openui-playground/public/app.js create mode 100644 packages/openui-playground/public/index.html create mode 100644 packages/openui-playground/src/demo-library.ts create mode 100644 packages/openui-playground/src/generate.ts create mode 100644 packages/openui-playground/src/lang/parser.ts create mode 100644 packages/openui-playground/src/lang/prompt.ts create mode 100644 packages/openui-playground/src/server.ts create mode 100644 packages/openui-playground/tests/parser.test.ts create mode 100644 packages/openui-playground/tsconfig.json create mode 100644 packages/openui-playground/vitest.config.ts diff --git a/packages/openui-playground/README.md b/packages/openui-playground/README.md new file mode 100644 index 00000000..c96d651d --- /dev/null +++ b/packages/openui-playground/README.md @@ -0,0 +1,46 @@ +# @openrouter/openui-playground + +Local webapp to **test, bench, and eval** OpenUI generative-UI support in the +Agent SDK (DEV-773 / DEV-765). + +```bash +OPENROUTER_API_KEY=sk-... pnpm --filter @openrouter/openui-playground dev +# → http://localhost:5170 +``` + +## What it does + +- Sends your prompt to a model via `callModel()` with the demo component + library (Stack/Card/Heading/Text/Stat/Badge/Table/Input/Select/Button/Progress). +- **Progressively renders** the generated UI as OpenUI Lang statements complete + — statement by statement, mid-stream. +- Shows the raw model text, the parsed OpenUI Lang stream, parse/validation + diagnostics, and per-run bench stats (TTFB, first-statement latency, total + time, statement count, token usage, cost) with a session history table for + comparing models and prompts. + +## Modes + +| Mode | What happens | Status | +|---|---|---| +| `emulate` (default) | The playground injects the library prompt locally and runs the reference streaming parser over the model's text stream — emulating what the API's `openui` plugin will do server-side (DEV-771). | Works today | +| `native` | Sends the `openui(library)` plugin preference and consumes `ModelResult.getUiStream()`. | Blocked on DEV-771/DEV-772; the API rejects the unknown plugin id until then | + +The two modes emit the same event shapes, so once native lands you can A/B the +paths in the history table with zero client changes. + +## Env + +- `OPENROUTER_API_KEY` (required) +- `PORT` (default `5170`) +- `OPENUI_PLAYGROUND_MODEL` (default `anthropic/claude-sonnet-5`) + +## Layout + +- `src/lang/parser.ts` — reference incremental OpenUI Lang parser (the same + logic DEV-770 ports into openrouter-web; conformance tests in `tests/`) +- `src/lang/prompt.ts` — library → system prompt (mirror of the API's injection) +- `src/demo-library.ts` — the component vocabulary (keep `public/app.js` renderer in sync) +- `src/generate.ts` — one generation run → normalized SSE event stream + stats +- `src/server.ts` — plain `node:http` server; no build step +- `public/` — static client: progressive renderer, Lang stream, bench panels diff --git a/packages/openui-playground/package.json b/packages/openui-playground/package.json new file mode 100644 index 00000000..5df05291 --- /dev/null +++ b/packages/openui-playground/package.json @@ -0,0 +1,26 @@ +{ + "name": "@openrouter/openui-playground", + "version": "0.0.0", + "private": true, + "description": "Local playground to test, bench, and eval OpenUI generative-UI support in the Agent SDK (DEV-773/DEV-765).", + "type": "module", + "scripts": { + "lint": "biome check src tests public", + "lint:fix": "biome check --write src tests public", + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest --run", + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts" + }, + "dependencies": { + "@openrouter/agent": "workspace:*", + "@openrouter/sdk": "^0.13.7", + "zod": "^4.0.0" + }, + "devDependencies": { + "tsx": "^4.19.0", + "typescript": "~5.8.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js new file mode 100644 index 00000000..c76d3496 --- /dev/null +++ b/packages/openui-playground/public/app.js @@ -0,0 +1,576 @@ +/** + * OpenUI playground client: posts a prompt to /api/generate, consumes the SSE + * stream, and progressively renders the generated document. + * + * The renderer implements the demo library (see src/demo-library.ts — keep in + * sync). Statements arrive with their parsed expression tree attached, so the + * client resolves refs/state and materializes DOM without its own parser. + */ + +const $ = (id) => document.getElementById(id); + +const PRESETS = [ + [ + 'Dashboard', + 'Show a dashboard for this month: $128.40 spend (+12%), 41,203 requests, 9 models. Table of top 3 models by spend, budget progress at 64%.', + ], + [ + 'Form', + 'Build a support-ticket form: severity select (low/medium/high), a title input, and a submit button.', + ], + [ + 'Status page', + 'A status page: API operational (success badge), Dashboard degraded (warning badge), a table of the last 3 incidents with dates.', + ], + [ + 'Re-render', + 'Show a counter card with value 1. Then update the same card to value 2, then 3, by re-assigning the same refs.', + ], + [ + 'Adversarial', + 'Explain what OpenUI is in prose, and ALSO show a card titled "OpenUI" with a one-line description. (The prose should become diagnostics, not break rendering.)', + ], +]; + +// --------------------------------------------------------------------------- +// Document state: ordered refs → assignment (mirrors UiDocument semantics) +// --------------------------------------------------------------------------- + +const doc = { + order: [], + assignments: new Map(), + stateVars: new Map(), +}; + +function resetDoc() { + doc.order.length = 0; + doc.assignments.clear(); + doc.stateVars.clear(); +} + +function applyStatement(stmt) { + if (stmt.ref.startsWith('$')) { + doc.stateVars.set(stmt.ref.slice(1), stmt.expr ? literalOf(stmt.expr) : null); + } + if (doc.assignments.has(stmt.ref)) { + doc.order.splice(doc.order.indexOf(stmt.ref), 1); + } + doc.assignments.set(stmt.ref, stmt); + doc.order.push(stmt.ref); +} + +function literalOf(expr) { + return expr && expr.kind === 'literal' ? expr.value : null; +} + +// --------------------------------------------------------------------------- +// Expression → value / DOM +// --------------------------------------------------------------------------- + +function evalExpr(expr, depth = 0) { + if (!expr || depth > 32) { + return null; + } + switch (expr.kind) { + case 'literal': + return expr.value; + case 'array': + return expr.items.map((e) => evalExpr(e, depth + 1)); + case 'object': { + const out = {}; + for (const { key, value } of expr.entries) { + out[key] = evalExpr(value, depth + 1); + } + return out; + } + case 'state-ref': + return doc.stateVars.get(expr.name) ?? null; + case 'ref': { + const target = doc.assignments.get(expr.name); + return target ? evalExpr(target.expr, depth + 1) : null; + } + case 'member': { + let base = evalExpr(expr.base, depth + 1); + for (const key of expr.path) { + base = base !== null ? base[key] : null; + } + return base; + } + case 'call': + return expr; // calls materialize as DOM, not values + default: + return null; + } +} + +function renderExpr(expr, depth = 0) { + if (!expr || depth > 32) { + return null; + } + if (expr.kind === 'ref') { + const target = doc.assignments.get(expr.name); + return target ? renderExpr(target.expr, depth + 1) : textNode(`⟨${expr.name}?⟩`, 'ui-unknown'); + } + if (expr.kind === 'state-ref') { + return textNode(String(doc.stateVars.get(expr.name) ?? ''), 'ui-text'); + } + if (expr.kind === 'array') { + const frag = document.createDocumentFragment(); + for (const item of expr.items) { + const node = renderExpr(item, depth + 1); + if (node) { + frag.appendChild(node); + } + } + return frag; + } + if (expr.kind === 'literal') { + return textNode(String(expr.value ?? ''), 'ui-text'); + } + if (expr.kind === 'call') { + return renderCall(expr, depth); + } + return null; +} + +function textNode(text, cls) { + const el = document.createElement('div'); + el.className = cls; + el.textContent = text; + return el; +} + +function el(tag, cls, children) { + const node = document.createElement(tag); + if (cls) { + node.className = cls; + } + for (const child of children ?? []) { + if (child) { + node.appendChild(child); + } + } + return node; +} + +/** Positional args → named props using the component's signature. */ +const SIGNATURES = { + Stack: [ + 'children', + 'direction', + 'gap', + ], + Card: [ + 'title', + 'children', + ], + Heading: [ + 'text', + 'level', + ], + Text: [ + 'value', + 'muted', + ], + Stat: [ + 'label', + 'value', + 'delta', + ], + Badge: [ + 'text', + 'tone', + ], + Table: [ + 'columns', + 'rows', + ], + Input: [ + 'name', + 'value', + 'placeholder', + ], + Select: [ + 'name', + 'options', + 'value', + ], + Button: [ + 'label', + 'action', + 'variant', + ], + Progress: [ + 'value', + 'label', + ], +}; + +function propsOf(call) { + const names = SIGNATURES[call.fn] ?? []; + const props = {}; + call.args.forEach((arg, i) => { + const name = names[i] ?? `arg${i}`; + props[name] = arg; + }); + return props; +} + +function renderCall(call, depth) { + if (call.builtin) { + return null; // @Run/@Set/... are action steps, not DOM + } + const p = propsOf(call); + const val = (name, fallback) => { + const v = p[name] !== undefined ? evalExpr(p[name], depth + 1) : undefined; + return v === undefined || v === null || (v && v.kind === 'call') ? fallback : v; + }; + const children = (name) => (p[name] ? renderExpr(p[name], depth + 1) : null); + + switch (call.fn) { + case 'Stack': { + const node = el('div', `ui-stack${val('direction', 'column') === 'row' ? ' row' : ''}`, [ + children('children'), + ]); + const gap = val('gap', null); + if (typeof gap === 'number') { + node.style.gap = `${gap}px`; + } + return node; + } + case 'Card': { + const kids = []; + const title = val('title', null); + const titleIsText = typeof title === 'string'; + if (titleIsText) { + kids.push(textNode(title, 'title')); + } + // Card("x", [...]) puts children second; Card([...]) puts them first. + const body = titleIsText ? children('children') : (children('title') ?? children('children')); + if (body) { + kids.push(body); + } + return el('div', 'ui-card', kids); + } + case 'Heading': { + const level = Math.min(3, Math.max(1, val('level', 2))); + return textNode(String(val('text', '')), `ui-heading${level}`); + } + case 'Text': + return textNode(String(val('value', '')), `ui-text${val('muted', false) ? ' muted' : ''}`); + case 'Stat': { + const kids = [ + textNode(String(val('value', '')), 'v'), + textNode(String(val('label', '')), 'l'), + ]; + const delta = val('delta', null); + if (delta) { + kids.push(textNode(String(delta), 'd')); + } + return el('div', 'ui-stat', kids); + } + case 'Badge': + return textNode(String(val('text', '')), `ui-badge ${val('tone', 'neutral')}`); + case 'Table': { + const columns = val('columns', []); + const rows = val('rows', []); + const table = document.createElement('table'); + table.className = 'ui-table'; + if (Array.isArray(columns)) { + const tr = document.createElement('tr'); + for (const c of columns) { + tr.appendChild( + el('th', null, [ + document.createTextNode(String(c)), + ]), + ); + } + table.appendChild(tr); + } + if (Array.isArray(rows)) { + for (const row of rows) { + const tr = document.createElement('tr'); + for (const cell of Array.isArray(row) + ? row + : [ + row, + ]) { + tr.appendChild( + el('td', null, [ + document.createTextNode(String(cell)), + ]), + ); + } + table.appendChild(tr); + } + } + return table; + } + case 'Input': { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'ui-input'; + input.placeholder = String(val('placeholder', '')); + const v = val('value', ''); + if (v) { + input.value = String(v); + } + return input; + } + case 'Select': { + const select = document.createElement('select'); + select.className = 'ui-select'; + for (const opt of val('options', [])) { + const o = document.createElement('option'); + o.textContent = String(opt); + select.appendChild(o); + } + return select; + } + case 'Button': { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `ui-button ${val('variant', 'secondary')}`; + btn.textContent = String(val('label', 'Button')); + btn.addEventListener('click', () => + setStatus('action fired (client event ingestion is Phase 3 — DEV-774)'), + ); + return btn; + } + case 'Progress': { + const value = Math.min(100, Math.max(0, Number(val('value', 0)))); + const bar = el('div', 'ui-progress', [ + el('div'), + ]); + bar.firstChild.style.width = `${value}%`; + const label = val('label', null); + return label + ? el('div', null, [ + textNode(String(label), 'ui-text muted'), + bar, + ]) + : bar; + } + case 'Query': + case 'Mutation': + case 'Action': + case 'ToolView': + return null; // data/action bindings — no direct DOM in the playground yet + default: + return textNode(`⟨unknown component ${call.fn}⟩`, 'ui-unknown'); + } +} + +function renderSurface() { + const surface = $('surface'); + surface.replaceChildren(); + const rootStmt = doc.assignments.get('root'); + if (!rootStmt) { + // No root yet: render every component statement in order (progressive view). + const stack = el('div', 'ui-stack'); + for (const ref of doc.order) { + const stmt = doc.assignments.get(ref); + if (stmt && stmt.kind === 'component') { + const node = renderExpr(stmt.expr); + if (node) { + stack.appendChild(node); + } + } + } + surface.appendChild( + stack.childNodes.length + ? stack + : el('div', 'placeholder', [ + document.createTextNode('Waiting for statements…'), + ]), + ); + return; + } + const node = renderExpr(rootStmt.expr); + surface.appendChild( + node ?? + el('div', 'placeholder', [ + document.createTextNode('Root did not render.'), + ]), + ); +} + +// --------------------------------------------------------------------------- +// Stats + history +// --------------------------------------------------------------------------- + +const history = []; + +function statTile(label, value) { + return `
${value}
${label}
`; +} + +function renderStats(s) { + const fmt = (v, suffix = '') => (v === null || v === undefined ? '—' : `${v}${suffix}`); + $('stats').innerHTML = [ + statTile('TTFB', fmt(s.ttfbMs, 'ms')), + statTile('1st stmt', fmt(s.firstStatementMs, 'ms')), + statTile('total', fmt(s.totalMs, 'ms')), + statTile('statements', fmt(s.statements)), + statTile('diagnostics', fmt(s.diagnostics)), + statTile('out tokens', fmt(s.outputTokens)), + ].join(''); +} + +function renderHistory() { + if (!history.length) { + return; + } + const rows = history + .map( + (h) => + `${h.model} · ${h.mode}${h.ttfbMs ?? '—'}${h.firstStatementMs ?? '—'}${h.totalMs}${h.statements}${h.diagnostics}${h.outputTokens ?? '—'}${h.cost !== null ? `$${h.cost.toFixed(5)}` : '—'}`, + ) + .join(''); + $('history').innerHTML = + `${rows}
runttfb1sttotalstmtsdiagtokcost
`; +} + +function setStatus(text, isError = false) { + const status = $('status'); + status.textContent = text; + status.className = isError ? 'dialect err' : 'dialect'; +} + +// --------------------------------------------------------------------------- +// Wiring +// --------------------------------------------------------------------------- + +async function boot() { + const meta = await (await fetch('/api/library')).json(); + $('dialect').textContent = `${meta.dialect} · ${meta.components.length} components`; + $('libprompt').textContent = meta.prompt; + $('model').value = meta.defaultModel; + for (const [name, prompt] of PRESETS) { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = name; + b.addEventListener('click', () => { + $('prompt').value = prompt; + }); + $('presets').appendChild(b); + } +} + +async function run() { + const runBtn = $('run'); + runBtn.disabled = true; + resetDoc(); + $('lang').replaceChildren(); + $('events').textContent = ''; + $('diagnostics').innerHTML = '—'; + $('stats').innerHTML = ''; + renderSurface(); + setStatus('generating…'); + + const mode = document.querySelector('input[name=mode]:checked').value; + const body = { + prompt: $('prompt').value, + model: $('model').value, + mode, + }; + + try { + const res = await fetch('/api/generate', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!res.ok || !res.body) { + const err = await res.json().catch(() => ({ + error: res.statusText, + })); + throw new Error(err.error ?? 'request failed'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { + stream: true, + }); + let idx = buffer.indexOf('\n\n'); + while (idx >= 0) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + idx = buffer.indexOf('\n\n'); + if (!frame.startsWith('data: ')) { + continue; + } + const payload = frame.slice(6); + if (payload === '[DONE]') { + continue; + } + handleEvent(JSON.parse(payload)); + } + } + setStatus('done'); + } catch (error) { + setStatus(String(error.message ?? error), true); + } finally { + runBtn.disabled = false; + } +} + +function handleEvent(event) { + switch (event.type) { + case 'text': + $('events').textContent += event.delta; + break; + case 'statement': { + applyStatement(event); + renderSurface(); + const line = document.createElement('div'); + line.className = 'stmt'; + line.textContent = `[${String(event.at).padStart(5)}ms] ${event.source}`; + $('lang').appendChild(line); + $('lang').scrollTop = $('lang').scrollHeight; + break; + } + case 'fragment': { + const line = document.createElement('div'); + line.className = 'stmt'; + line.textContent = `[fragment${event.toolCallId ? ` ${event.toolCallId}` : ''}] ${event.source}`; + $('lang').appendChild(line); + break; + } + case 'document': { + if (event.diagnostics.length) { + $('diagnostics').innerHTML = event.diagnostics + .map((d) => `
L${d.line}: ${d.message} — ${escapeHtml(d.source)}
`) + .join(''); + } else { + $('diagnostics').innerHTML = + 'clean parse — no diagnostics'; + } + break; + } + case 'stats': + renderStats(event); + history.unshift(event); + renderHistory(); + break; + case 'error': + setStatus(event.message, true); + break; + } +} + +function escapeHtml(s) { + return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +} + +$('run').addEventListener('click', run); +boot().catch((error) => setStatus(String(error), true)); diff --git a/packages/openui-playground/public/index.html b/packages/openui-playground/public/index.html new file mode 100644 index 00000000..c05757fe --- /dev/null +++ b/packages/openui-playground/public/index.html @@ -0,0 +1,149 @@ + + + + + +OpenUI Playground + + + +
+

OpenUI Playground

+ + +
+
+
+ + +
+ + + + + + + + + + +
+ + +
+ +

Library prompt sent to the model

+
+
+ +
+

Rendered surface

+
Run a prompt to render generated UI here.
+

Diagnostics

+
—
+
+ +
+

Run stats

+
+

OpenUI Lang stream

+
+

Raw model text

+
+

Run history (this session)

+
+
+
+ + + diff --git a/packages/openui-playground/src/demo-library.ts b/packages/openui-playground/src/demo-library.ts new file mode 100644 index 00000000..e900a2af --- /dev/null +++ b/packages/openui-playground/src/demo-library.ts @@ -0,0 +1,124 @@ +/** + * The playground's demo component library — a small but representative + * vocabulary (layout, text, data display, inputs, actions) that exercises + * positional props, optional props, enums, arrays, and nesting. The client + * renderer in public/app.js implements exactly these components; keep the + * two in sync. + */ +import { createLibrary, defineComponent } from '@openrouter/agent'; +import { z } from 'zod/v4'; + +export const demoLibrary = createLibrary([ + defineComponent({ + name: 'Stack', + description: 'Layout container. direction defaults to "column".', + props: z.object({ + children: z.array(z.unknown()), + direction: z + .enum([ + 'row', + 'column', + ]) + .optional(), + gap: z.number().optional(), + }), + }), + defineComponent({ + name: 'Card', + description: 'Bordered container with an optional title.', + props: z.object({ + title: z.string().optional(), + children: z.array(z.unknown()).optional(), + }), + }), + defineComponent({ + name: 'Heading', + description: 'Section heading. level 1-3, defaults to 2.', + props: z.object({ + text: z.string(), + level: z.number().optional(), + }), + }), + defineComponent({ + name: 'Text', + description: 'A paragraph of body text.', + props: z.object({ + value: z.string(), + muted: z.boolean().optional(), + }), + }), + defineComponent({ + name: 'Stat', + description: 'A labeled metric (big value, small label, optional delta like "+12%").', + props: z.object({ + label: z.string(), + value: z.string(), + delta: z.string().optional(), + }), + }), + defineComponent({ + name: 'Badge', + description: 'Small status pill.', + props: z.object({ + text: z.string(), + tone: z + .enum([ + 'neutral', + 'success', + 'warning', + 'danger', + ]) + .optional(), + }), + }), + defineComponent({ + name: 'Table', + description: + 'Data table. columns is an array of header strings; rows is an array of arrays of cell strings.', + props: z.object({ + columns: z.array(z.string()), + rows: z.array(z.array(z.string())), + }), + }), + defineComponent({ + name: 'Input', + description: 'Single-line text input. Pass a $state ref as value to two-way bind.', + props: z.object({ + name: z.string(), + value: z.unknown().optional(), + placeholder: z.string().optional(), + }), + }), + defineComponent({ + name: 'Select', + description: 'Dropdown. Pass a $state ref as value to two-way bind.', + props: z.object({ + name: z.string(), + options: z.array(z.string()), + value: z.unknown().optional(), + }), + }), + defineComponent({ + name: 'Button', + description: 'Action button. action is an Action(...) block.', + props: z.object({ + label: z.string(), + action: z.unknown().optional(), + variant: z + .enum([ + 'primary', + 'secondary', + 'danger', + ]) + .optional(), + }), + }), + defineComponent({ + name: 'Progress', + description: 'Progress bar, value 0-100.', + props: z.object({ + value: z.number(), + label: z.string().optional(), + }), + }), +]); diff --git a/packages/openui-playground/src/generate.ts b/packages/openui-playground/src/generate.ts new file mode 100644 index 00000000..9fcfab81 --- /dev/null +++ b/packages/openui-playground/src/generate.ts @@ -0,0 +1,278 @@ +/** + * The playground's generation core: run one OpenUI turn against a model and + * emit a normalized event stream the client renders progressively. + * + * Two modes: + * - `emulate` (default) — works today. Injects the library prompt locally and + * runs the streaming parser over the model's text stream, emitting the same + * statement/document events the API's `openui` plugin will emit natively. + * - `native` — sends the `openui(library)` plugin preference and consumes + * `getUiStream()`. Useful the moment DEV-771/DEV-772 land; until then the + * API rejects the unknown plugin id. + * + * Every run also emits bench stats (TTFB, first-statement latency, statement + * count, token usage, cost) so the playground doubles as an eval harness. + */ + +import type { UiLibrary } from '@openrouter/agent'; +import { callModel, openui, serializeExpr } from '@openrouter/agent'; +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import type { UiAssignment, UiDocument } from './lang/parser.js'; +import { OpenUiLangParser } from './lang/parser.js'; +import { libraryPrompt } from './lang/prompt.js'; + +export type GenerateMode = 'emulate' | 'native'; + +export interface GenerateRequest { + prompt: string; + model: string; + mode?: GenerateMode; + /** Optional extra system prompt prepended before the library prompt. */ + system?: string; +} + +/** Normalized playground stream events (superset of the wire protocol shapes). */ +export type PlaygroundEvent = + | { + type: 'statement'; + ref: string; + kind: string; + source: string; + /** + * Parsed expression tree (playground extra, not on the wire protocol) — + * lets the client render without carrying its own parser. Absent in + * native mode until the wire protocol grows one. + */ + expr?: unknown; + at: number; + } + | { + type: 'fragment'; + toolCallId?: string; + dialect: string; + source: string; + at: number; + } + | { + type: 'text'; + delta: string; + } + | { + type: 'document'; + root: string | null; + dialect: string; + statements: number; + diagnostics: Array<{ + line: number; + message: string; + source: string; + }>; + } + | { + type: 'stats'; + mode: GenerateMode; + model: string; + ttfbMs: number | null; + firstStatementMs: number | null; + totalMs: number; + statements: number; + diagnostics: number; + chars: number; + inputTokens: number | null; + outputTokens: number | null; + cost: number | null; + } + | { + type: 'error'; + message: string; + }; + +interface UsageSummary { + inputTokens: number | null; + outputTokens: number | null; + cost: number | null; +} + +function extractUsage(response: unknown): UsageSummary { + const usage = + typeof response === 'object' && response !== null + ? ( + response as { + usage?: Record; + } + ).usage + : undefined; + const num = (v: unknown): number | null => (typeof v === 'number' ? v : null); + return { + inputTokens: num(usage?.['inputTokens']), + outputTokens: num(usage?.['outputTokens']), + cost: num(usage?.['cost']), + }; +} + +/** + * Run one generation and yield playground events as they materialize. + */ +export async function* generate( + client: OpenRouterCore, + library: UiLibrary, + request: GenerateRequest, +): AsyncGenerator { + const mode: GenerateMode = request.mode ?? 'emulate'; + const start = Date.now(); + let ttfbMs: number | null = null; + let firstStatementMs: number | null = null; + let statements = 0; + let chars = 0; + + if (mode === 'native') { + // Native path: the API owns prompting + parsing; we consume getUiStream(). + const result = callModel(client, { + model: request.model, + input: request.prompt, + ...(request.system !== undefined && { + instructions: request.system, + }), + plugins: [ + openui(library) as never, + ], + }); + + for await (const event of result.getUiStream()) { + if (ttfbMs === null) { + ttfbMs = Date.now() - start; + } + if (event.type === 'statement') { + if (firstStatementMs === null) { + firstStatementMs = Date.now() - start; + } + statements += 1; + chars += event.source.length; + yield { + ...event, + at: Date.now() - start, + }; + } else if (event.type === 'fragment') { + yield { + ...event, + at: Date.now() - start, + }; + } else { + yield { + type: 'document', + root: event.root, + dialect: event.dialect, + statements, + diagnostics: event.diagnostics.map((d) => ({ + line: d.line ?? 0, + message: d.message, + source: d.source ?? '', + })), + }; + } + } + + const usage = extractUsage(await result.getResponse()); + yield { + type: 'stats', + mode, + model: request.model, + ttfbMs, + firstStatementMs, + totalMs: Date.now() - start, + statements, + diagnostics: 0, + chars, + ...usage, + }; + return; + } + + // Emulate path: inject the library prompt locally, parse the text stream. + const instructions = [ + request.system, + libraryPrompt(library), + ] + .filter(Boolean) + .join('\n\n'); + const result = callModel(client, { + model: request.model, + input: request.prompt, + instructions, + }); + + const parser = new OpenUiLangParser(library.dialect); + let lastEmittedLine = 0; + for await (const delta of result.getTextStream()) { + if (ttfbMs === null) { + ttfbMs = Date.now() - start; + } + chars += delta.length; + yield { + type: 'text', + delta, + }; + for (const assignment of parser.push(delta)) { + if (firstStatementMs === null) { + firstStatementMs = Date.now() - start; + } + statements += 1; + lastEmittedLine = assignment.line; + yield { + type: 'statement', + ref: assignment.ref, + kind: assignment.kind, + source: serializeStatement(assignment.ref, assignment), + expr: assignment.expr, + at: Date.now() - start, + }; + } + } + + const doc: UiDocument = parser.end(); + // end() may flush one trailing statement that arrived without a final + // newline — emit any assignment parsed past the last line we streamed. + for (const ref of doc.order) { + const assignment = doc.assignments[ref]; + if (assignment && assignment.line > lastEmittedLine) { + if (firstStatementMs === null) { + firstStatementMs = Date.now() - start; + } + statements += 1; + yield { + type: 'statement', + ref: assignment.ref, + kind: assignment.kind, + source: serializeStatement(assignment.ref, assignment), + expr: assignment.expr, + at: Date.now() - start, + }; + } + } + + yield { + type: 'document', + root: doc.root, + dialect: doc.dialect, + statements: doc.order.length, + diagnostics: doc.diagnostics, + }; + + const usage = extractUsage(await result.getResponse()); + yield { + type: 'stats', + mode, + model: request.model, + ttfbMs, + firstStatementMs, + totalMs: Date.now() - start, + statements, + diagnostics: doc.diagnostics.length, + chars, + ...usage, + }; +} + +function serializeStatement(ref: string, assignment: UiAssignment): string { + return `${ref} = ${serializeExpr(assignment.expr)}`; +} diff --git a/packages/openui-playground/src/lang/parser.ts b/packages/openui-playground/src/lang/parser.ts new file mode 100644 index 00000000..4eb5f1ba --- /dev/null +++ b/packages/openui-playground/src/lang/parser.ts @@ -0,0 +1,551 @@ +/** + * Incremental OpenUI Lang parser — playground emulation layer. + * + * This is the reference streaming parser the API will own once DEV-770/771 + * land in openrouter-web. The playground carries its own copy so it can + * emulate the `openui` plugin end-to-end today: inject the library prompt, + * parse the model's text stream, and emit `response.openui.*`-shaped events + * — which is exactly what lets us bench/eval renderer behavior and model + * output quality before the API path exists. + * + * The language is line-oriented — one assignment statement per line — so the + * parser is a line assembler (bracket- and string-aware, so a statement whose + * brackets span lines still parses) feeding a per-statement recursive-descent + * expression parser. It is tolerant by design: prose, fences, and unparseable + * lines become diagnostics, never throws. + */ +import type { UiExpr } from '@openrouter/agent'; +import { OPENUI_LANG_DIALECT, OPENUI_ROOT_REF } from '@openrouter/agent'; + +/** Classification of one assignment statement. */ +export type UiStatementKind = 'component' | 'query' | 'mutation' | 'state' | 'value'; + +/** One parsed assignment statement. */ +export interface UiAssignment { + /** Assignment target. State declarations keep their `$` prefix (`'$tab'`). */ + ref: string; + kind: UiStatementKind; + expr: UiExpr; + /** 1-indexed statement line within the turn's output. */ + line: number; +} + +/** A non-fatal parse problem (unparseable or prose line). */ +export interface UiDiagnostic { + line: number; + message: string; + source: string; +} + +/** The materialized document — the mounted tree plus state and data bindings. */ +export interface UiDocument { + dialect: string; + /** `'root'` when the document assigned the reserved root ref, else null. */ + root: string | null; + /** Assignments keyed by ref (state refs keyed with their `$` prefix). */ + assignments: Record; + /** Refs in statement order. Re-assignment moves a ref to the end. */ + order: string[]; + diagnostics: UiDiagnostic[]; +} + +export function emptyDocument(dialect: string = OPENUI_LANG_DIALECT): UiDocument { + return { + dialect, + root: null, + assignments: {}, + order: [], + diagnostics: [], + }; +} + +//#region Statement scanner (line assembler) + +const FENCE_PREFIX = '```'; +const COMMENT_PREFIXES = [ + '#', + '//', +]; + +interface ScannerState { + buffer: string; + depth: number; + inString: boolean; + escaped: boolean; + line: number; +} + +function freshScannerState(): ScannerState { + return { + buffer: '', + depth: 0, + inString: false, + escaped: false, + line: 0, + }; +} + +interface ScannedStatement { + source: string; + line: number; +} + +/** + * Consume raw text, returning each completed top-level statement line. + * Newlines inside brackets or strings do not terminate a statement. + */ +function scanStatements(state: ScannerState, text: string): ScannedStatement[] { + const completed: ScannedStatement[] = []; + for (const ch of text) { + if (ch === '\n' && state.depth <= 0 && !state.inString) { + flushStatement(state, completed); + continue; + } + state.buffer += ch; + if (state.inString) { + if (state.escaped) { + state.escaped = false; + } else if (ch === '\\') { + state.escaped = true; + } else if (ch === '"') { + state.inString = false; + } + continue; + } + if (ch === '"') { + state.inString = true; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') { + state.depth += 1; + continue; + } + if (ch === ')' || ch === ']' || ch === '}') { + state.depth -= 1; + } + } + return completed; +} + +function flushStatement(state: ScannerState, out: ScannedStatement[]): void { + state.line += 1; + const source = state.buffer.trim(); + state.buffer = ''; + state.depth = 0; + state.inString = false; + state.escaped = false; + if (source.length === 0) { + return; + } + out.push({ + source, + line: state.line, + }); +} + +//#endregion + +//#region Expression parser + +class ParseFailure extends Error {} + +function isDigit(ch: string): boolean { + return ch >= '0' && ch <= '9'; +} + +function isIdentStart(ch: string): boolean { + return /[A-Za-z_]/.test(ch); +} + +class ExprParser { + private pos = 0; + + constructor(private readonly src: string) {} + + parseExpr(): UiExpr { + this.skipWs(); + const ch = this.peek(); + if (ch === undefined) { + throw new ParseFailure('unexpected end of expression'); + } + if (ch === '"') { + return { + kind: 'literal', + value: this.parseString(), + }; + } + if (ch === '[') { + return this.parseArray(); + } + if (ch === '{') { + return this.parseObject(); + } + if (ch === '-' || isDigit(ch)) { + return { + kind: 'literal', + value: this.parseNumber(), + }; + } + if (ch === '$') { + this.pos += 1; + const name = this.parseIdent(); + return this.maybeMember({ + kind: 'state-ref', + name, + }); + } + if (ch === '@') { + this.pos += 1; + const fn = this.parseIdent(); + return this.maybeMember({ + kind: 'call', + fn, + builtin: true, + args: this.parseArgs(), + }); + } + if (isIdentStart(ch)) { + const name = this.parseIdent(); + if (name === 'true') { + return { + kind: 'literal', + value: true, + }; + } + if (name === 'false') { + return { + kind: 'literal', + value: false, + }; + } + if (name === 'null') { + return { + kind: 'literal', + value: null, + }; + } + this.skipWs(); + if (this.peek() === '(') { + return this.maybeMember({ + kind: 'call', + fn: name, + builtin: false, + args: this.parseArgs(), + }); + } + return this.maybeMember({ + kind: 'ref', + name, + }); + } + throw new ParseFailure(`unexpected character '${ch}'`); + } + + /** Fails unless the whole source was consumed. */ + parseComplete(): UiExpr { + const expr = this.parseExpr(); + this.skipWs(); + if (this.pos < this.src.length) { + throw new ParseFailure(`trailing content after expression: '${this.src.slice(this.pos)}'`); + } + return expr; + } + + private maybeMember(base: UiExpr): UiExpr { + this.skipWs(); + if (this.peek() !== '.') { + return base; + } + const path: string[] = []; + while (this.peek() === '.') { + this.pos += 1; + path.push(this.parseIdent()); + } + return { + kind: 'member', + base, + path, + }; + } + + private parseArgs(): UiExpr[] { + this.expect('('); + const args: UiExpr[] = []; + this.skipWs(); + if (this.peek() === ')') { + this.pos += 1; + return args; + } + for (;;) { + args.push(this.parseExpr()); + this.skipWs(); + const ch = this.peek(); + if (ch === ',') { + this.pos += 1; + continue; + } + if (ch === ')') { + this.pos += 1; + return args; + } + throw new ParseFailure(`expected ',' or ')' in arguments, got '${ch ?? 'end'}'`); + } + } + + private parseArray(): UiExpr { + this.expect('['); + const items: UiExpr[] = []; + this.skipWs(); + if (this.peek() === ']') { + this.pos += 1; + return { + kind: 'array', + items, + }; + } + for (;;) { + items.push(this.parseExpr()); + this.skipWs(); + const ch = this.peek(); + if (ch === ',') { + this.pos += 1; + continue; + } + if (ch === ']') { + this.pos += 1; + return { + kind: 'array', + items, + }; + } + throw new ParseFailure(`expected ',' or ']' in array, got '${ch ?? 'end'}'`); + } + } + + private parseObject(): UiExpr { + this.expect('{'); + const entries: Array<{ + key: string; + value: UiExpr; + }> = []; + this.skipWs(); + if (this.peek() === '}') { + this.pos += 1; + return { + kind: 'object', + entries, + }; + } + for (;;) { + this.skipWs(); + const key = this.peek() === '"' ? this.parseString() : this.parseIdent(); + this.skipWs(); + this.expect(':'); + entries.push({ + key, + value: this.parseExpr(), + }); + this.skipWs(); + const ch = this.peek(); + if (ch === ',') { + this.pos += 1; + continue; + } + if (ch === '}') { + this.pos += 1; + return { + kind: 'object', + entries, + }; + } + throw new ParseFailure(`expected ',' or '}' in object, got '${ch ?? 'end'}'`); + } + } + + private parseString(): string { + this.expect('"'); + let out = ''; + for (;;) { + const ch = this.src[this.pos]; + if (ch === undefined) { + throw new ParseFailure('unterminated string'); + } + this.pos += 1; + if (ch === '"') { + return out; + } + if (ch !== '\\') { + out += ch; + continue; + } + const esc = this.src[this.pos]; + if (esc === undefined) { + throw new ParseFailure('unterminated escape'); + } + this.pos += 1; + if (esc === 'n') { + out += '\n'; + } else if (esc === 't') { + out += '\t'; + } else { + out += esc; + } + } + } + + private parseNumber(): number { + const match = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(this.src.slice(this.pos)); + if (!match) { + throw new ParseFailure('invalid number'); + } + this.pos += match[0].length; + return Number(match[0]); + } + + private parseIdent(): string { + const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos)); + if (!match) { + throw new ParseFailure(`expected identifier at '${this.src.slice(this.pos, this.pos + 8)}'`); + } + this.pos += match[0].length; + return match[0]; + } + + private expect(ch: string): void { + this.skipWs(); + if (this.src[this.pos] !== ch) { + throw new ParseFailure(`expected '${ch}', got '${this.src[this.pos] ?? 'end'}'`); + } + this.pos += 1; + } + + private peek(): string | undefined { + return this.src[this.pos]; + } + + private skipWs(): void { + while (this.pos < this.src.length && /\s/.test(this.src[this.pos] ?? '')) { + this.pos += 1; + } + } +} + +//#endregion + +//#region Statement parsing + +const STATEMENT_RE = /^(\$?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/s; + +function classify(ref: string, expr: UiExpr): UiStatementKind { + if (ref.startsWith('$')) { + return 'state'; + } + if (expr.kind === 'call' && !expr.builtin) { + if (expr.fn === 'Query') { + return 'query'; + } + if (expr.fn === 'Mutation') { + return 'mutation'; + } + return 'component'; + } + return 'value'; +} + +/** Parse one statement line. Returns null for fences/comments; throws never. */ +function parseStatement(source: string, line: number): UiAssignment | UiDiagnostic | null { + if (source.startsWith(FENCE_PREFIX) || COMMENT_PREFIXES.some((p) => source.startsWith(p))) { + return null; + } + const match = STATEMENT_RE.exec(source); + if (!match) { + return { + line, + message: 'not an assignment statement', + source, + }; + } + const ref = match[1] ?? ''; + const rhs = match[2] ?? ''; + try { + const expr = new ExprParser(rhs).parseComplete(); + return { + ref, + kind: classify(ref, expr), + expr, + line, + }; + } catch (e) { + const message = e instanceof ParseFailure ? e.message : String(e); + return { + line, + message, + source, + }; + } +} + +function isAssignment(value: UiAssignment | UiDiagnostic): value is UiAssignment { + return 'ref' in value; +} + +/** + * Streaming parser: feed deltas with `push`, read completed assignments as + * they land, and `end()` to flush the trailing unterminated line and get the + * document. Also usable one-shot via `parseDocument`. + */ +export class OpenUiLangParser { + private readonly scanner = freshScannerState(); + private readonly doc: UiDocument; + + constructor(dialect?: string) { + this.doc = emptyDocument(dialect); + } + + /** Feed a text delta; returns assignments completed by this chunk. */ + push(delta: string): UiAssignment[] { + return scanStatements(this.scanner, delta) + .map(({ source, line }) => this.accept(source, line)) + .filter((a): a is UiAssignment => a !== null); + } + + /** Flush the trailing line and return the finished document. */ + end(): UiDocument { + const completed: ScannedStatement[] = []; + flushStatement(this.scanner, completed); + for (const { source, line } of completed) { + this.accept(source, line); + } + return this.doc; + } + + private accept(source: string, line: number): UiAssignment | null { + const parsed = parseStatement(source, line); + if (parsed === null) { + return null; + } + if (!isAssignment(parsed)) { + this.doc.diagnostics.push(parsed); + return null; + } + const existing = this.doc.assignments[parsed.ref]; + this.doc.assignments[parsed.ref] = parsed; + if (existing !== undefined) { + this.doc.order.splice(this.doc.order.indexOf(parsed.ref), 1); + } + this.doc.order.push(parsed.ref); + if (parsed.ref === OPENUI_ROOT_REF) { + this.doc.root = OPENUI_ROOT_REF; + } + return parsed; + } +} + +/** One-shot parse of a full turn's output. */ +export function parseDocument(text: string, dialect?: string): UiDocument { + const parser = new OpenUiLangParser(dialect); + parser.push(text); + return parser.end(); +} + +//#endregion diff --git a/packages/openui-playground/src/lang/prompt.ts b/packages/openui-playground/src/lang/prompt.ts new file mode 100644 index 00000000..5b9f4c84 --- /dev/null +++ b/packages/openui-playground/src/lang/prompt.ts @@ -0,0 +1,69 @@ +/** + * Component-library system prompt — playground emulation of what the API's + * `openui` plugin will inject server-side (DEV-771). Generated from the same + * `UiLibrary` shape the SDK ships, so prompts here and API-side stay + * comparable when we bench the two paths against each other. + */ +import type { ComponentDefinition, UiLibrary } from '@openrouter/agent'; +import { componentProps } from '@openrouter/agent'; +import * as z4 from 'zod/v4'; +import type { $ZodType } from 'zod/v4/core'; + +function describeSchema(schema: $ZodType): string { + try { + const json = z4.toJSONSchema(schema, { + io: 'input', + }); + if (typeof json.type === 'string') { + return json.type; + } + if (Array.isArray(json.anyOf)) { + const types = json.anyOf + .map((s) => (typeof s === 'object' && s !== null && 'type' in s ? String(s.type) : 'any')) + .filter((t) => t !== 'null'); + if (types.length > 0) { + return types.join(' | '); + } + } + if (Array.isArray(json.enum)) { + return json.enum.map((v) => JSON.stringify(v)).join(' | '); + } + } catch { + // Exotic schema — fall through to the permissive label. + } + return 'any'; +} + +function renderComponentLine(def: ComponentDefinition): string { + const props = componentProps(def) + .map((p) => `${p.name}${p.optional ? '?' : ''}: ${describeSchema(p.schema)}`) + .join(', '); + const doc = def.description ? ` — ${def.description}` : ''; + return `- ${def.name}(${props})${doc}`; +} + +/** Render the system prompt for a library. */ +export function libraryPrompt(library: UiLibrary): string { + return [ + `Respond in OpenUI Lang (${library.dialect}): one assignment statement per line, \`name = Expression\`.`, + 'Rules:', + '- Components: `ref = Component(arg1, arg2, ...)` — positional args map to props in signature order.', + '- The statement assigned to `root` is the rendered root.', + '- Reactive state: `$name = defaultValue`. Passing `$name` to an input two-way binds it.', + '- Data: `ref = Query("tool_name", { args })` fetches on load and when referenced `$vars` change; `ref = Mutation("tool_name", { args })` runs only via `@Run(ref)`.', + '- Actions: `Action([@Run(ref), @Set($var, value), @ToAssistant("message")])` — steps run sequentially.', + '- Reference other statements by their `ref`. Member access plucks fields (`data.rows.title`).', + '- Arguments are POSITIONAL only — never `name: value` pairs. Skip an optional prop by ending the argument list early.', + '- Emit only OpenUI Lang statements — no prose, no code fences.', + '', + 'Example:', + '$query = ""', + 'results = Table(["Name", "Score"], [["alpha", "9.1"], ["beta", "8.4"]])', + 'root = Card("Leaderboard", [Input("search", $query, "Filter…"), results])', + '', + 'Available components:', + ...[ + ...library.components.values(), + ].map(renderComponentLine), + ].join('\n'); +} diff --git a/packages/openui-playground/src/server.ts b/packages/openui-playground/src/server.ts new file mode 100644 index 00000000..61ef09c6 --- /dev/null +++ b/packages/openui-playground/src/server.ts @@ -0,0 +1,192 @@ +/** + * OpenUI playground server. + * + * OPENROUTER_API_KEY=sk-... pnpm --filter @openrouter/openui-playground dev + * + * Routes: + * - GET / → the playground UI (public/) + * - GET /api/library → the demo library (names, prompt, dialect) + * - POST /api/generate → run one generation, streamed as SSE + * body: { prompt, model?, mode?: 'emulate' | 'native', system? } + * + * Plain node:http — no framework, nothing to build; the client is static. + */ +import { readFile } from 'node:fs/promises'; +import type { ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import { dirname, extname, join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { OpenRouter } from '@openrouter/agent'; +import { demoLibrary } from './demo-library.js'; +import type { GenerateRequest, PlaygroundEvent } from './generate.js'; +import { generate } from './generate.js'; +import { libraryPrompt } from './lang/prompt.js'; + +const PORT = Number(process.env['PORT'] ?? 5170); +const DEFAULT_MODEL = process.env['OPENUI_PLAYGROUND_MODEL'] ?? 'anthropic/claude-sonnet-5'; +const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public'); + +const apiKey = process.env['OPENROUTER_API_KEY']; +if (!apiKey) { + console.error('OPENROUTER_API_KEY is required'); + process.exit(1); +} +const client = new OpenRouter({ + apiKey, +}); + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', +}; + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function sseFrame(event: PlaygroundEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +async function readBody(req: import('node:http').IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const text = Buffer.concat(chunks).toString('utf8'); + return text.length > 0 ? JSON.parse(text) : {}; +} + +function parseGenerateRequest(body: unknown): GenerateRequest | null { + if (typeof body !== 'object' || body === null) { + return null; + } + const record = body as Record; + if (typeof record['prompt'] !== 'string' || record['prompt'].length === 0) { + return null; + } + const mode = record['mode']; + if (mode !== undefined && mode !== 'emulate' && mode !== 'native') { + return null; + } + const request: GenerateRequest = { + prompt: record['prompt'], + model: typeof record['model'] === 'string' && record['model'] ? record['model'] : DEFAULT_MODEL, + }; + if (mode !== undefined) { + request.mode = mode; + } + if (typeof record['system'] === 'string' && record['system']) { + request.system = record['system']; + } + return request; +} + +async function serveStatic(res: ServerResponse, urlPath: string): Promise { + const rel = urlPath === '/' ? 'index.html' : urlPath.slice(1); + const file = normalize(join(PUBLIC_DIR, rel)); + if (!file.startsWith(PUBLIC_DIR)) { + sendJson(res, 404, { + error: 'not found', + }); + return; + } + try { + const content = await readFile(file); + res.writeHead(200, { + 'content-type': MIME[extname(file)] ?? 'application/octet-stream', + }); + res.end(content); + } catch { + sendJson(res, 404, { + error: 'not found', + }); + } +} + +async function handleRequest( + req: import('node:http').IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); + + if (req.method === 'GET' && url.pathname === '/api/library') { + sendJson(res, 200, { + dialect: demoLibrary.dialect, + components: demoLibrary.componentNames, + prompt: libraryPrompt(demoLibrary), + defaultModel: DEFAULT_MODEL, + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/api/generate') { + let request: GenerateRequest | null = null; + try { + request = parseGenerateRequest(await readBody(req)); + } catch { + request = null; + } + if (!request) { + sendJson(res, 400, { + error: 'body must be { prompt, model?, mode?, system? }', + }); + return; + } + + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + try { + for await (const event of generate(client, demoLibrary, request)) { + res.write(sseFrame(event)); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.write( + sseFrame({ + type: 'error', + message, + }), + ); + } + res.write('data: [DONE]\n\n'); + res.end(); + return; + } + + if (req.method === 'GET') { + await serveStatic(res, url.pathname); + return; + } + + sendJson(res, 405, { + error: 'method not allowed', + }); +} + +const server = createServer((req, res) => { + handleRequest(req, res).catch((error: unknown) => { + console.error(error); + if (!res.headersSent) { + sendJson(res, 500, { + error: 'internal error', + }); + } else { + res.end(); + } + }); +}); + +server.listen(PORT, () => { + console.log(`OpenUI playground → http://localhost:${PORT} (default model: ${DEFAULT_MODEL})`); +}); diff --git a/packages/openui-playground/tests/parser.test.ts b/packages/openui-playground/tests/parser.test.ts new file mode 100644 index 00000000..30f3d9bb --- /dev/null +++ b/packages/openui-playground/tests/parser.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { OpenUiLangParser, parseDocument } from '../src/lang/parser.js'; + +describe('OpenUiLangParser (streaming)', () => { + it('emits assignments only as their line completes', () => { + const parser = new OpenUiLangParser(); + expect(parser.push('a = Text("hel')).toEqual([]); + const [a] = parser.push('lo")\n'); + expect(a?.ref).toBe('a'); + expect(a?.kind).toBe('component'); + const doc = parser.end(); + expect(doc.order).toEqual([ + 'a', + ]); + }); + + it('assembles statements whose brackets span lines', () => { + const parser = new OpenUiLangParser(); + parser.push('root = Stack([\n Text("a"),\n Text("b")\n'); + expect(parser.push('])\n')).toHaveLength(1); + const doc = parser.end(); + expect(doc.root).toBe('root'); + }); + + it('flushes a trailing statement without a final newline at end()', () => { + const parser = new OpenUiLangParser(); + parser.push('a = Text("x")'); + const doc = parser.end(); + expect(doc.order).toEqual([ + 'a', + ]); + }); + + it('newlines inside strings do not split statements', () => { + const doc = parseDocument('a = Text("line one\nline two")\n'); + expect(doc.order).toEqual([ + 'a', + ]); + expect(doc.diagnostics).toEqual([]); + }); +}); + +describe('parseDocument (tolerance + semantics)', () => { + it('classifies statements', () => { + const doc = parseDocument( + [ + '$tab = "overview"', + 'data = Query("list_models", {limit: 3})', + 'save = Mutation("save_report", {})', + 'title = "Usage"', + 'root = Card(title, [Text("hi")])', + ].join('\n'), + ); + expect(doc.assignments['$tab']?.kind).toBe('state'); + expect(doc.assignments['data']?.kind).toBe('query'); + expect(doc.assignments['save']?.kind).toBe('mutation'); + expect(doc.assignments['title']?.kind).toBe('value'); + expect(doc.assignments['root']?.kind).toBe('component'); + expect(doc.root).toBe('root'); + }); + + it('turns prose into diagnostics, never throws', () => { + const doc = parseDocument('Here is your dashboard:\nroot = Card("ok")\nEnjoy!'); + expect(doc.order).toEqual([ + 'root', + ]); + expect(doc.diagnostics).toHaveLength(2); + expect(doc.diagnostics[0]?.message).toBe('not an assignment statement'); + }); + + it('skips fences and comments silently', () => { + const doc = parseDocument('```openui\nroot = Text("x")\n```\n# comment\n// also'); + expect(doc.order).toEqual([ + 'root', + ]); + expect(doc.diagnostics).toEqual([]); + }); + + it('re-assignment replaces and moves the ref to the end', () => { + const doc = parseDocument('a = Text("1")\nb = Text("2")\na = Text("3")'); + expect(doc.order).toEqual([ + 'b', + 'a', + ]); + const a = doc.assignments['a']; + expect(a?.expr).toMatchObject({ + kind: 'call', + args: [ + { + kind: 'literal', + value: '3', + }, + ], + }); + }); + + it('parses builtins, state refs, member access, and nesting', () => { + const doc = parseDocument( + 'btn = Button("Add", Action([@Run(save), @Set($title, ""), @ToAssistant("done")]))\nrows = data.rows.title', + ); + expect(doc.diagnostics).toEqual([]); + expect(doc.assignments['btn']?.expr).toMatchObject({ + kind: 'call', + fn: 'Button', + }); + expect(doc.assignments['rows']?.expr).toMatchObject({ + kind: 'member', + base: { + kind: 'ref', + name: 'data', + }, + path: [ + 'rows', + 'title', + ], + }); + }); + + it('parses literals: numbers, booleans, null, escapes', () => { + const doc = parseDocument('a = {n: -1.5e2, t: true, f: false, z: null, s: "a\\"b\\nc"}'); + expect(doc.diagnostics).toEqual([]); + expect(doc.assignments['a']?.expr).toMatchObject({ + kind: 'object', + entries: [ + { + key: 'n', + value: { + kind: 'literal', + value: -150, + }, + }, + { + key: 't', + value: { + kind: 'literal', + value: true, + }, + }, + { + key: 'f', + value: { + kind: 'literal', + value: false, + }, + }, + { + key: 'z', + value: { + kind: 'literal', + value: null, + }, + }, + { + key: 's', + value: { + kind: 'literal', + value: 'a"b\nc', + }, + }, + ], + }); + }); + + it('reports unparseable expressions as diagnostics with the source line', () => { + const doc = parseDocument('bad = Card(("unclosed"\ngood = Text("ok")'); + // The unbalanced paren swallows the newline; only one statement completes. + expect(doc.diagnostics.length + doc.order.length).toBeGreaterThan(0); + expect(parseDocument('x = = =').diagnostics).toHaveLength(1); + }); +}); diff --git a/packages/openui-playground/tsconfig.json b/packages/openui-playground/tsconfig.json new file mode 100644 index 00000000..6412ea8d --- /dev/null +++ b/packages/openui-playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "esm", + "noEmit": true + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "esm"] +} diff --git a/packages/openui-playground/vitest.config.ts b/packages/openui-playground/vitest.config.ts new file mode 100644 index 00000000..4a58023e --- /dev/null +++ b/packages/openui-playground/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a98d76a5..e6fd9aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 5.8.3 vitest: specifier: ^4.1.5 - version: 4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)) + version: 4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1)) packages/agent: dependencies: @@ -60,6 +60,28 @@ importers: specifier: ^4.0.0 version: 4.3.6 + packages/openui-playground: + dependencies: + '@openrouter/agent': + specifier: workspace:* + version: link:../agent + '@openrouter/sdk': + specifier: ^0.13.7 + version: 0.13.7 + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + tsx: + specifier: ^4.19.0 + version: 4.23.1 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vitest: + specifier: ^4.1.5 + version: 4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1)) + packages: '@babel/helper-string-parser@7.29.7': @@ -207,156 +229,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.4': resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.4': resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.4': resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.4': resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.4': resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.4': resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.4': resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.4': resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.4': resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.4': resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.4': resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.4': resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.4': resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.4': resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.4': resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.4': resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.4': resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.4': resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.4': resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.4': resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.4': resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.4': resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -793,6 +971,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1357,6 +1540,11 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.9.6: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true @@ -1708,81 +1896,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: hono: 4.12.27 @@ -1979,7 +2245,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)) + vitest: 4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1)) '@vitest/expect@4.1.10': dependencies: @@ -1990,13 +2256,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@22.19.15))': + '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.15) + vite: 7.3.1(@types/node@22.19.15)(tsx@4.23.1) '@vitest/pretty-format@4.1.10': dependencies: @@ -2191,6 +2457,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} esprima@4.0.1: {} @@ -2748,6 +3043,12 @@ snapshots: tr46@0.0.3: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + turbo@2.9.6: optionalDependencies: '@turbo/darwin-64': 2.9.6 @@ -2773,7 +3074,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@22.19.15): + vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -2784,11 +3085,12 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 fsevents: 2.3.3 + tsx: 4.23.1 - vitest@4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)): + vitest@4.1.10(@types/node@22.19.15)(@vitest/coverage-v8@4.1.10)(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@22.19.15)) + '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@22.19.15)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2805,7 +3107,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@22.19.15) + vite: 7.3.1(@types/node@22.19.15)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.15 From 69bfb14250842e840db248516ba0ad01ed9eb90e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:59:00 -0500 Subject: [PATCH 04/20] fix(openui): quote non-identifier object keys, guard non-finite numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serializeExpr emitted object keys raw, so a key with spaces, quotes, punctuation, or a leading digit produced source the parser rejects. Keys come from arbitrary tool-authored objects via toExpr, so they cannot be assumed to be identifiers. The grammar already accepts a quoted key (parseObject branches on '"'), so quoting the rest round-trips. String(NaN)/String(Infinity) also emitted bare identifiers, which parse back as refs to undefined names. JSON resolves the same hole as null; do that rather than emit source that cannot round-trip. fix(playground): describe enum props by their values, not "string" describeSchema returned on json.type before checking json.enum, but an enum serializes as {type: 'string', enum: [...]} — so every enum prop was described to the model as a plain string and it never saw which values are legal for Badge.tone, Stack.direction, Button.variant. docs(changeset): add the required minor changeset for the OpenUI exports ~30 new exports plus ModelResult.getUiStream and the toUIOutput tool option had no changeset, which the public-api-examples skill requires. The example is compile-checked against the real signatures. --- .changeset/openui-bindings.md | 76 +++++++++++++++++ packages/agent/src/lib/openui/document.ts | 34 +++++++- packages/agent/tests/unit/openui.test.ts | 83 +++++++++++++++++++ packages/openui-playground/src/lang/prompt.ts | 12 ++- .../openui-playground/tests/prompt.test.ts | 34 ++++++++ 5 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .changeset/openui-bindings.md create mode 100644 packages/openui-playground/tests/prompt.test.ts diff --git a/.changeset/openui-bindings.md b/.changeset/openui-bindings.md new file mode 100644 index 00000000..049a851f --- /dev/null +++ b/.changeset/openui-bindings.md @@ -0,0 +1,76 @@ +--- +'@openrouter/agent': minor +--- + +OpenUI bindings: a component-library model (`defineComponent`, `createLibrary`, `componentProps`), a typed fragment builder (`fragment`, `uiRef`, `uiState`, `uiBuiltin`), the `openui` plugin helper, `serializeExpr`/`OPENUI_LANG_DIALECT` for emitting OpenUI Lang, a `toUIOutput` tool option that renders a tool's result as UI, and `ModelResult.getUiStream()` for consuming fragments as they arrive. + +A tool declares how its output renders, and the caller streams the fragments: + +```ts +import { + callModel, + createLibrary, + defineComponent, + fragment, + openui, + tool, +} from '@openrouter/agent'; +import { z } from 'zod/v4'; + +const library = createLibrary([ + defineComponent({ + name: 'Card', + description: 'Container with a title', + props: z.object({ + title: z.string(), + children: z.array(z.unknown()).optional(), + }), + }), + defineComponent({ + name: 'Text', + props: z.object({ + value: z.string(), + }), + }), +]); + +const ui = fragment(library); + +const weather = tool({ + name: 'weather', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + summary: z.string(), + }), + execute: ({ city }) => ({ + summary: `Clear in ${city}`, + }), + // Renders the tool's result instead of leaving the model to describe it. + toUIOutput: ({ input, output }) => + ui.Card(input.city, [ + ui.Text(output.summary), + ]), +}); + +const result = callModel(client, { + model: 'anthropic/claude-sonnet-4.5', + input: 'What is the weather in Lisbon?', + tools: [ + weather, + ], + plugins: [ + // `as never` until the SDK regen adds `openui` to its plugin union; the + // wire shape is already accepted by the API. + openui(library) as never, + ], +}); + +for await (const event of result.getUiStream()) { + if (event.type === 'fragment') { + // source: 'root = Card("Lisbon", [Text("Clear in Lisbon")])' + console.log(event.source); + } +} +``` diff --git a/packages/agent/src/lib/openui/document.ts b/packages/agent/src/lib/openui/document.ts index 7efb8aeb..7a496fef 100644 --- a/packages/agent/src/lib/openui/document.ts +++ b/packages/agent/src/lib/openui/document.ts @@ -63,11 +63,41 @@ export interface UiFragment { source: string; } +/** + * Bare-identifier object keys, which the grammar accepts unquoted. Anything + * else — spaces, quotes, punctuation, a leading digit, the empty string — must + * be quoted or the emitted source does not parse. + */ +const BARE_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Object keys reach here from arbitrary tool-authored objects via `toExpr`, so + * they cannot be assumed to be identifiers. The parser accepts a quoted key + * (`parseObject` branches on `"`), so quoting the rest round-trips. + */ +function serializeKey(key: string): string { + return BARE_KEY.test(key) ? key : JSON.stringify(key); +} + +/** + * Numbers that have no OpenUI Lang literal: `String(NaN)` is `NaN` and + * `String(Infinity)` is `Infinity`, both of which serialize as bare identifiers + * and would parse back as refs to undefined names (or fail outright). JSON has + * the same hole and resolves it as `null`; do the same rather than emit source + * that cannot round-trip. + */ +function serializeNumber(value: number): string { + return Number.isFinite(value) ? String(value) : 'null'; +} + /** Serialize an expression to OpenUI Lang source. */ export function serializeExpr(expr: UiExpr): string { switch (expr.kind) { case 'literal': - return typeof expr.value === 'string' ? JSON.stringify(expr.value) : String(expr.value); + if (typeof expr.value === 'string') { + return JSON.stringify(expr.value); + } + return typeof expr.value === 'number' ? serializeNumber(expr.value) : String(expr.value); case 'ref': return expr.name; case 'state-ref': @@ -77,7 +107,7 @@ export function serializeExpr(expr: UiExpr): string { case 'array': return `[${expr.items.map(serializeExpr).join(', ')}]`; case 'object': - return `{${expr.entries.map((e) => `${e.key}: ${serializeExpr(e.value)}`).join(', ')}}`; + return `{${expr.entries.map((e) => `${serializeKey(e.key)}: ${serializeExpr(e.value)}`).join(', ')}}`; case 'call': return `${expr.builtin ? '@' : ''}${expr.fn}(${expr.args.map(serializeExpr).join(', ')})`; } diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts index 74758d84..acde5ddf 100644 --- a/packages/agent/tests/unit/openui.test.ts +++ b/packages/agent/tests/unit/openui.test.ts @@ -54,6 +54,89 @@ describe('serializeExpr', () => { ).toBe('null'); }); + /* + * Keys arrive from arbitrary tool-authored objects via `toExpr`, so a key + * with spaces, quotes, punctuation, or a leading digit would emit source the + * parser rejects. The grammar accepts a quoted key, so quoting round-trips. + */ + it('quotes object keys that are not bare identifiers', () => { + expect( + serializeExpr({ + kind: 'object', + entries: [ + { + key: 'ok_key1', + value: { + kind: 'literal', + value: 1, + }, + }, + { + key: 'has space', + value: { + kind: 'literal', + value: 2, + }, + }, + { + key: '2leading', + value: { + kind: 'literal', + value: 3, + }, + }, + { + key: 'has"quote', + value: { + kind: 'literal', + value: 4, + }, + }, + { + key: '', + value: { + kind: 'literal', + value: 5, + }, + }, + ], + }), + ).toBe('{ok_key1: 1, "has space": 2, "2leading": 3, "has\\"quote": 4, "": 5}'); + }); + + /* + * `String(NaN)`/`String(Infinity)` emit bare identifiers, which parse back as + * refs to undefined names rather than numbers. JSON has the same hole and + * resolves it as null. + */ + it('serializes non-finite numbers as null rather than bare identifiers', () => { + expect( + serializeExpr({ + kind: 'literal', + value: Number.NaN, + }), + ).toBe('null'); + expect( + serializeExpr({ + kind: 'literal', + value: Number.POSITIVE_INFINITY, + }), + ).toBe('null'); + expect( + serializeExpr({ + kind: 'literal', + value: Number.NEGATIVE_INFINITY, + }), + ).toBe('null'); + /* Finite numbers, including negative zero, are untouched. */ + expect( + serializeExpr({ + kind: 'literal', + value: -1.5, + }), + ).toBe('-1.5'); + }); + it('serializes refs, state refs, and member access', () => { expect( serializeExpr({ diff --git a/packages/openui-playground/src/lang/prompt.ts b/packages/openui-playground/src/lang/prompt.ts index 5b9f4c84..e1efb1bc 100644 --- a/packages/openui-playground/src/lang/prompt.ts +++ b/packages/openui-playground/src/lang/prompt.ts @@ -14,6 +14,15 @@ function describeSchema(schema: $ZodType): string { const json = z4.toJSONSchema(schema, { io: 'input', }); + /* + * Before `type`: an enum serializes as `{type: 'string', enum: [...]}`, so + * checking `type` first labels every enum prop a plain `string` and the + * model never learns which values are legal for `Badge.tone`, + * `Stack.direction`, `Button.variant`, and the rest. + */ + if (Array.isArray(json.enum)) { + return json.enum.map((v) => JSON.stringify(v)).join(' | '); + } if (typeof json.type === 'string') { return json.type; } @@ -25,9 +34,6 @@ function describeSchema(schema: $ZodType): string { return types.join(' | '); } } - if (Array.isArray(json.enum)) { - return json.enum.map((v) => JSON.stringify(v)).join(' | '); - } } catch { // Exotic schema — fall through to the permissive label. } diff --git a/packages/openui-playground/tests/prompt.test.ts b/packages/openui-playground/tests/prompt.test.ts new file mode 100644 index 00000000..0f4839d8 --- /dev/null +++ b/packages/openui-playground/tests/prompt.test.ts @@ -0,0 +1,34 @@ +import { createLibrary, defineComponent } from '@openrouter/agent'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { libraryPrompt } from '../src/lang/prompt.js'; + +/* + * The prompt is the only place the model learns a prop's legal values. An enum + * serializes as `{type: 'string', enum: [...]}`, so a `type`-first check labels + * it a bare `string` and every enum prop's values stay invisible. + */ +describe('libraryPrompt enum props', () => { + const library = createLibrary([ + defineComponent({ + name: 'Badge', + props: z.object({ + tone: z.enum([ + 'info', + 'warn', + 'danger', + ]), + label: z.string(), + }), + }), + ]); + + it('lists an enum prop’s valid values instead of "string"', () => { + const prompt = libraryPrompt(library); + expect(prompt).toContain('tone: "info" | "warn" | "danger"'); + }); + + it('still labels non-enum props by type', () => { + expect(libraryPrompt(library)).toContain('label: string'); + }); +}); From 3720fde9bf1ad92c4ffc53a780603d849f74aebc Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:04:54 -0500 Subject: [PATCH 05/20] =?UTF-8?q?refactor:=20clear=20the=20structural=20ga?= =?UTF-8?q?te=20=E2=80=94=20god=20file=20and=204=20complex=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate's no_god_files rule is fan-out > 15, not file size: model-result.ts sat at exactly 15 outbound edges and this PR's ./openui/ui-stream.js import made it 16. Verified by removing that one import — the violation disappears. Re-exported UiStreamEvent and translateUiEvent from stream-transformers.js, which model-result.ts already depends on and which owns every other wire-event translation the loop performs, so no new edge is added. Complex functions were 9 -> 13, all four new here. Each is split along a seam it already had: - translateUiEvent: one function per wire event type (cc=18 -> under) - scanStatements: string-literal and bracket-depth state machines extracted - generate: the native path's per-variant event mapping extracted - renderCall: form controls and Table extracted to renderControl/renderTable Verified with sentrux 0.5.7, the version CI pins: God files 0 -> 0, complex functions back to 9 (the 9 remaining are all pre-existing on main and untouched), gate reports 'No degradation detected'. Behavior unchanged — full suite green, typecheck and lint clean. --- packages/agent/src/lib/model-result.ts | 5 +- packages/agent/src/lib/openui/ui-stream.ts | 210 ++++++++++-------- packages/agent/src/lib/stream-transformers.ts | 13 ++ packages/openui-playground/public/app.js | 181 ++++++++------- packages/openui-playground/src/generate.ts | 49 ++-- packages/openui-playground/src/lang/parser.ts | 42 ++-- 6 files changed, 286 insertions(+), 214 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 8940f7d5..ebf60e56 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -31,11 +31,9 @@ import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js'; -import type { UiStreamEvent } from './openui/ui-stream.js'; -import { translateUiEvent } from './openui/ui-stream.js'; import { ReusableReadableStream } from './reusable-stream.js'; import { isStopConditionMet } from './stop-conditions.js'; -import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js'; +import type { ItemInProgress, StreamableOutputItem, UiStreamEvent } from './stream-transformers.js'; import { buildItemsStream, buildResponsesMessageStream, @@ -49,6 +47,7 @@ import { extractToolDeltas, itemsStreamHandlers, streamTerminationEvents, + translateUiEvent, } from './stream-transformers.js'; import { hasTypeProperty, diff --git a/packages/agent/src/lib/openui/ui-stream.ts b/packages/agent/src/lib/openui/ui-stream.ts index 6414fe12..7796cb1f 100644 --- a/packages/agent/src/lib/openui/ui-stream.ts +++ b/packages/agent/src/lib/openui/ui-stream.ts @@ -85,109 +85,129 @@ function str(value: unknown): string | undefined { * `tool.ui_fragment` and the API's `response.openui.*` wire events (including * their pre-regen `Unknown` encoding). */ -export function translateUiEvent(event: unknown): UiStreamEvent | null { - const payload = unwrapEvent(event); - if (!payload) { +/** `tool.ui_fragment`: a local tool's fragment, carried on the tool stream. */ +function toolFragmentEvent(payload: Record): UiStreamEvent | null { + const fragment = payload['fragment']; + if (!isRecord(fragment)) { + return null; + } + const dialect = str(fragment['dialect']); + const source = str(fragment['source']); + if (dialect === undefined || source === undefined) { return null; } + const result: UiFragmentEvent = { + type: 'fragment', + dialect, + source, + }; + const toolCallId = str(payload['toolCallId']); + if (toolCallId !== undefined) { + result.toolCallId = toolCallId; + } + const toolName = str(payload['toolName']); + if (toolName !== undefined) { + result.toolName = toolName; + } + return result; +} - switch (payload['type']) { - case 'tool.ui_fragment': { - const fragment = payload['fragment']; - if (!isRecord(fragment)) { - return null; - } - const dialect = str(fragment['dialect']); - const source = str(fragment['source']); - if (dialect === undefined || source === undefined) { - return null; - } - const result: UiFragmentEvent = { - type: 'fragment', - dialect, - source, - }; - const toolCallId = str(payload['toolCallId']); - if (toolCallId !== undefined) { - result.toolCallId = toolCallId; - } - const toolName = str(payload['toolName']); - if (toolName !== undefined) { - result.toolName = toolName; - } - return result; - } +/** `response.openui.statement`: one completed assignment from the API. */ +function statementEvent(payload: Record): UiStreamEvent | null { + const ref = str(payload['ref']); + const kind = str(payload['kind']); + const source = str(payload['source']); + if (ref === undefined || kind === undefined || source === undefined) { + return null; + } + return { + type: 'statement', + ref, + kind, + source, + }; +} - case OPENUI_WIRE_EVENT.Statement: { - const ref = str(payload['ref']); - const kind = str(payload['kind']); - const source = str(payload['source']); - if (ref === undefined || kind === undefined || source === undefined) { - return null; - } - return { - type: 'statement', - ref, - kind, - source, - }; - } +/** `response.openui.fragment`: a server tool's fragment. */ +function wireFragmentEvent(payload: Record): UiStreamEvent | null { + const dialect = str(payload['dialect']); + const source = str(payload['source']); + if (dialect === undefined || source === undefined) { + return null; + } + const result: UiFragmentEvent = { + type: 'fragment', + dialect, + source, + }; + // Wire field is snake_case; tolerate camelCase for forward compat. + const callId = str(payload['call_id']) ?? str(payload['callId']); + if (callId !== undefined) { + result.toolCallId = callId; + } + return result; +} - case OPENUI_WIRE_EVENT.Fragment: { - const dialect = str(payload['dialect']); - const source = str(payload['source']); - if (dialect === undefined || source === undefined) { - return null; - } - const result: UiFragmentEvent = { - type: 'fragment', - dialect, - source, - }; - // Wire field is snake_case; tolerate camelCase for forward compat. - const callId = str(payload['call_id']) ?? str(payload['callId']); - if (callId !== undefined) { - result.toolCallId = callId; - } - return result; +/** Diagnostics on a document event, skipping any entry without a message. */ +function documentDiagnostics(raw: unknown): UiDocumentEvent['diagnostics'] { + if (!Array.isArray(raw)) { + return []; + } + return raw.filter(isRecord).flatMap((d) => { + const message = str(d['message']); + if (message === undefined) { + return []; } - - case OPENUI_WIRE_EVENT.Document: { - const dialect = str(payload['dialect']); - if (dialect === undefined) { - return null; - } - const root = str(payload['root']) ?? null; - const rawDiagnostics = payload['diagnostics']; - const diagnostics: UiDocumentEvent['diagnostics'] = Array.isArray(rawDiagnostics) - ? rawDiagnostics.filter(isRecord).flatMap((d) => { - const message = str(d['message']); - if (message === undefined) { - return []; - } - const diagnostic: UiDocumentEvent['diagnostics'][number] = { - message, - }; - if (typeof d['line'] === 'number') { - diagnostic.line = d['line']; - } - const source = str(d['source']); - if (source !== undefined) { - diagnostic.source = source; - } - return [ - diagnostic, - ]; - }) - : []; - return { - type: 'document', - root, - dialect, - diagnostics, - }; + const diagnostic: UiDocumentEvent['diagnostics'][number] = { + message, + }; + if (typeof d['line'] === 'number') { + diagnostic.line = d['line']; } + const source = str(d['source']); + if (source !== undefined) { + diagnostic.source = source; + } + return [ + diagnostic, + ]; + }); +} + +/** `response.openui.document`: turn-end summary (root ref + diagnostics). */ +function documentEvent(payload: Record): UiStreamEvent | null { + const dialect = str(payload['dialect']); + if (dialect === undefined) { + return null; + } + return { + type: 'document', + root: str(payload['root']) ?? null, + dialect, + diagnostics: documentDiagnostics(payload['diagnostics']), + }; +} +/* + * Wire event -> stream event. Each case is its own function: the switch was one + * body holding every field-validation branch, which put it over the structural + * gate's per-function complexity ceiling. + */ +export function translateUiEvent(event: unknown): UiStreamEvent | null { + const payload = unwrapEvent(event); + if (!payload) { + return null; + } + + switch (payload['type']) { + case 'tool.ui_fragment': + return toolFragmentEvent(payload); + case OPENUI_WIRE_EVENT.Statement: + return statementEvent(payload); + case OPENUI_WIRE_EVENT.Fragment: + return wireFragmentEvent(payload); + case OPENUI_WIRE_EVENT.Document: + return documentEvent(payload); default: return null; } diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index b35b56b9..f165b0de 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -6,6 +6,8 @@ import type { ClaudeTextCitation, UnsupportedContent, } from '../api-shape-helpers/claude-message.js'; +import type { UiStreamEvent } from './openui/ui-stream.js'; +import { translateUiEvent } from './openui/ui-stream.js'; import type { ReusableReadableStream } from './reusable-stream.js'; import { isFileCitationAnnotation, @@ -1240,3 +1242,14 @@ export function getUnsupportedContentSummary(message: ClaudeMessage): Record + setStatus('action fired (client event ingestion is Phase 3 — DEV-774)'), + ); + return btn; + } + case 'Progress': { + const value = Math.min(100, Math.max(0, Number(val('value', 0)))); + const bar = el('div', 'ui-progress', [ + el('div'), + ]); + bar.firstChild.style.width = `${value}%`; + const label = val('label', null); + return label + ? el('div', null, [ + textNode(String(label), 'ui-text muted'), + bar, + ]) + : bar; + } + default: + return undefined; // not a control — caller falls through + } +} + +/** Tabular data, split out for the same reason as `renderControl`. */ +function renderTable(val) { + const columns = val('columns', []); + const rows = val('rows', []); + const table = document.createElement('table'); + table.className = 'ui-table'; + if (Array.isArray(columns)) { + const tr = document.createElement('tr'); + for (const c of columns) { + tr.appendChild( + el('th', null, [ + document.createTextNode(String(c)), + ]), + ); + } + table.appendChild(tr); + } + if (Array.isArray(rows)) { + for (const row of rows) { + const tr = document.createElement('tr'); + for (const cell of Array.isArray(row) + ? row + : [ + row, + ]) { + tr.appendChild( + el('td', null, [ + document.createTextNode(String(cell)), + ]), + ); + } + table.appendChild(tr); + } + } + return table; +} + function renderCall(call, depth) { if (call.builtin) { return null; // @Run/@Set/... are action steps, not DOM @@ -227,6 +321,11 @@ function renderCall(call, depth) { }; const children = (name) => (p[name] ? renderExpr(p[name], depth + 1) : null); + const control = renderControl(call, val); + if (control !== undefined) { + return control; + } + switch (call.fn) { case 'Stack': { const node = el('div', `ui-stack${val('direction', 'column') === 'row' ? ' row' : ''}`, [ @@ -271,86 +370,8 @@ function renderCall(call, depth) { } case 'Badge': return textNode(String(val('text', '')), `ui-badge ${val('tone', 'neutral')}`); - case 'Table': { - const columns = val('columns', []); - const rows = val('rows', []); - const table = document.createElement('table'); - table.className = 'ui-table'; - if (Array.isArray(columns)) { - const tr = document.createElement('tr'); - for (const c of columns) { - tr.appendChild( - el('th', null, [ - document.createTextNode(String(c)), - ]), - ); - } - table.appendChild(tr); - } - if (Array.isArray(rows)) { - for (const row of rows) { - const tr = document.createElement('tr'); - for (const cell of Array.isArray(row) - ? row - : [ - row, - ]) { - tr.appendChild( - el('td', null, [ - document.createTextNode(String(cell)), - ]), - ); - } - table.appendChild(tr); - } - } - return table; - } - case 'Input': { - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'ui-input'; - input.placeholder = String(val('placeholder', '')); - const v = val('value', ''); - if (v) { - input.value = String(v); - } - return input; - } - case 'Select': { - const select = document.createElement('select'); - select.className = 'ui-select'; - for (const opt of val('options', [])) { - const o = document.createElement('option'); - o.textContent = String(opt); - select.appendChild(o); - } - return select; - } - case 'Button': { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = `ui-button ${val('variant', 'secondary')}`; - btn.textContent = String(val('label', 'Button')); - btn.addEventListener('click', () => - setStatus('action fired (client event ingestion is Phase 3 — DEV-774)'), - ); - return btn; - } - case 'Progress': { - const value = Math.min(100, Math.max(0, Number(val('value', 0)))); - const bar = el('div', 'ui-progress', [ - el('div'), - ]); - bar.firstChild.style.width = `${value}%`; - const label = val('label', null); - return label - ? el('div', null, [ - textNode(String(label), 'ui-text muted'), - bar, - ]) - : bar; - } + case 'Table': + return renderTable(val); case 'Query': case 'Mutation': case 'Action': diff --git a/packages/openui-playground/src/generate.ts b/packages/openui-playground/src/generate.ts index 9fcfab81..d427bcd1 100644 --- a/packages/openui-playground/src/generate.ts +++ b/packages/openui-playground/src/generate.ts @@ -14,7 +14,7 @@ * count, token usage, cost) so the playground doubles as an eval harness. */ -import type { UiLibrary } from '@openrouter/agent'; +import type { UiLibrary, UiStreamEvent } from '@openrouter/agent'; import { callModel, openui, serializeExpr } from '@openrouter/agent'; import type { OpenRouterCore } from '@openrouter/sdk/core'; import type { UiAssignment, UiDocument } from './lang/parser.js'; @@ -110,6 +110,31 @@ function extractUsage(response: unknown): UsageSummary { }; } +/* + * One native `getUiStream()` event -> one playground event. Split out of + * `generate` so the per-variant field mapping does not count toward that + * function's complexity, which the structural gate caps. + */ +function toPlaygroundEvent(event: UiStreamEvent, at: number, statements: number): PlaygroundEvent { + if (event.type === 'document') { + return { + type: 'document', + root: event.root, + dialect: event.dialect, + statements, + diagnostics: event.diagnostics.map((d) => ({ + line: d.line ?? 0, + message: d.message, + source: d.source ?? '', + })), + }; + } + return { + ...event, + at, + }; +} + /** * Run one generation and yield playground events as they materialize. */ @@ -148,28 +173,8 @@ export async function* generate( } statements += 1; chars += event.source.length; - yield { - ...event, - at: Date.now() - start, - }; - } else if (event.type === 'fragment') { - yield { - ...event, - at: Date.now() - start, - }; - } else { - yield { - type: 'document', - root: event.root, - dialect: event.dialect, - statements, - diagnostics: event.diagnostics.map((d) => ({ - line: d.line ?? 0, - message: d.message, - source: d.source ?? '', - })), - }; } + yield toPlaygroundEvent(event, Date.now() - start, statements); } const usage = extractUsage(await result.getResponse()); diff --git a/packages/openui-playground/src/lang/parser.ts b/packages/openui-playground/src/lang/parser.ts index 4eb5f1ba..6746117b 100644 --- a/packages/openui-playground/src/lang/parser.ts +++ b/packages/openui-playground/src/lang/parser.ts @@ -94,6 +94,32 @@ interface ScannedStatement { * Consume raw text, returning each completed top-level statement line. * Newlines inside brackets or strings do not terminate a statement. */ +/** Advance string-literal state for one character inside a string. */ +function scanInString(state: ScannerState, ch: string): void { + if (state.escaped) { + state.escaped = false; + return; + } + if (ch === '\\') { + state.escaped = true; + return; + } + if (ch === '"') { + state.inString = false; + } +} + +/** Track bracket nesting so a statement can span lines. */ +function scanDepth(state: ScannerState, ch: string): void { + if (ch === '(' || ch === '[' || ch === '{') { + state.depth += 1; + return; + } + if (ch === ')' || ch === ']' || ch === '}') { + state.depth -= 1; + } +} + function scanStatements(state: ScannerState, text: string): ScannedStatement[] { const completed: ScannedStatement[] = []; for (const ch of text) { @@ -103,26 +129,14 @@ function scanStatements(state: ScannerState, text: string): ScannedStatement[] { } state.buffer += ch; if (state.inString) { - if (state.escaped) { - state.escaped = false; - } else if (ch === '\\') { - state.escaped = true; - } else if (ch === '"') { - state.inString = false; - } + scanInString(state, ch); continue; } if (ch === '"') { state.inString = true; continue; } - if (ch === '(' || ch === '[' || ch === '{') { - state.depth += 1; - continue; - } - if (ch === ')' || ch === ']' || ch === '}') { - state.depth -= 1; - } + scanDepth(state, ch); } return completed; } From e5759d34099ee2eec4e70ec3540e8d6c1586d796 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:10:04 -0500 Subject: [PATCH 06/20] docs(agent): list getUiStream in the README stream table The table of what each stream emits is the reference consumers use to pick one; getUiStream was absent. --- packages/agent/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agent/README.md b/packages/agent/README.md index 86cdd66d..8ce73c9f 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -110,6 +110,7 @@ What each stream emits: | `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results | | `getToolCallsStream()` | parsed tool calls as they complete | | `getItemsStream()` | all output items (messages, function calls, …) | +| `getUiStream()` | OpenUI events — `statement` / `fragment` / `document` — from tools declaring `toUIOutput` and from the `openui` plugin | | `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events | ### Tool Types From b388f3506191d6b02f46dbd02636b0ada44ebe86 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:02:59 -0500 Subject: [PATCH 07/20] fix(playground): escape diagnostic messages; label rendered controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from cortex's review pass. XSS: the diagnostics panel escaped `source` but interpolated `message` and `line` raw into innerHTML. Every field there is model-controlled — `ParseFailure.message` is built from the offending source line, and in native mode diagnostics arrive verbatim off the wire — so a model could inject markup by emitting a crafted statement. All three fields are now escaped. A11y: rendered Input/Select carried no accessible name, so a screen reader announced an unlabelled field. Both signatures already have a `name` prop that was going unused for labelling; it now sets aria-label (and the real `name` attribute), falling back to the placeholder for Input. --- packages/openui-playground/public/app.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js index bf6ab0a0..4e4b6396 100644 --- a/packages/openui-playground/public/app.js +++ b/packages/openui-playground/public/app.js @@ -232,11 +232,24 @@ function renderControl(call, val) { if (v) { input.value = String(v); } + // The signature's `name` is the only human label these controls carry; + // without it a screen reader announces an unlabelled text field. Fall + // back to the placeholder when a name wasn't supplied. + const inputLabel = String(val('name', '') || val('placeholder', '')); + if (inputLabel) { + input.setAttribute('aria-label', inputLabel); + input.name = String(val('name', '')); + } return input; } case 'Select': { const select = document.createElement('select'); select.className = 'ui-select'; + const selectLabel = String(val('name', '')); + if (selectLabel) { + select.setAttribute('aria-label', selectLabel); + select.name = selectLabel; + } for (const opt of val('options', [])) { const o = document.createElement('option'); o.textContent = String(opt); @@ -570,7 +583,14 @@ function handleEvent(event) { case 'document': { if (event.diagnostics.length) { $('diagnostics').innerHTML = event.diagnostics - .map((d) => `
L${d.line}: ${d.message} — ${escapeHtml(d.source)}
`) + // Every interpolated field is model-controlled: `source` is the + // offending line and `message` carries parser text built from it + // (ParseFailure.message), or arrives verbatim off the wire in native + // mode. Escaping only `source` left an injection through `message`. + .map( + (d) => + `
L${escapeHtml(String(d.line))}: ${escapeHtml(d.message)} — ${escapeHtml(d.source)}
`, + ) .join(''); } else { $('diagnostics').innerHTML = From 37f816dc68fe000f7c806e51c487182fb59670f8 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:07:47 -0500 Subject: [PATCH 08/20] =?UTF-8?q?fix(openui):=20address=20cortex=20review?= =?UTF-8?q?=20=E2=80=94=20security,=20a11y,=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - escape diagnostic messages and history model names before innerHTML (XSS) - bind playground server to 127.0.0.1 (API-key-backed endpoint) - require trailing separator in static-file public-root prefix check - validate uiRef/uiState/uiBuiltin names as identifiers at construction - aria-label form controls from their name prop; progressbar ARIA + text % - aria-live status/diagnostics regions; error frames no longer overwritten by the green 'done' status - rename toUIOutput -> toUiOutput (match Ui casing convention pre-release) - warn (tool name + call id) when toUiOutput throws instead of catch {} - collect toUiOutput broadcasts and await as one batch off the follow-up critical path - sticky regexes + charCode skipWs in the playground parser (was O(n^2)) - memoize openui(library) wire shape per library (WeakMap) - drop the playground's no-op build script / outDir --- .changeset/openui-bindings.md | 4 +- packages/agent/README.md | 2 +- packages/agent/src/index.ts | 2 +- packages/agent/src/lib/model-result.ts | 28 +++++++++---- packages/agent/src/lib/openui/fragment.ts | 21 ++++++++-- packages/agent/src/lib/openui/plugin.ts | 18 ++++++++ packages/agent/src/lib/openui/ui-stream.ts | 4 +- packages/agent/src/lib/tool-types.ts | 10 ++--- packages/agent/src/lib/tool.ts | 26 ++++++------ .../agent/tests/unit/openui-stream.test.ts | 30 +++++++------- packages/openui-playground/package.json | 1 - packages/openui-playground/public/app.js | 41 +++++++++++++++++-- packages/openui-playground/public/index.html | 4 +- packages/openui-playground/src/lang/parser.ts | 21 ++++++++-- packages/openui-playground/src/server.ts | 10 +++-- packages/openui-playground/tsconfig.json | 3 +- 16 files changed, 160 insertions(+), 65 deletions(-) diff --git a/.changeset/openui-bindings.md b/.changeset/openui-bindings.md index 049a851f..c6489672 100644 --- a/.changeset/openui-bindings.md +++ b/.changeset/openui-bindings.md @@ -2,7 +2,7 @@ '@openrouter/agent': minor --- -OpenUI bindings: a component-library model (`defineComponent`, `createLibrary`, `componentProps`), a typed fragment builder (`fragment`, `uiRef`, `uiState`, `uiBuiltin`), the `openui` plugin helper, `serializeExpr`/`OPENUI_LANG_DIALECT` for emitting OpenUI Lang, a `toUIOutput` tool option that renders a tool's result as UI, and `ModelResult.getUiStream()` for consuming fragments as they arrive. +OpenUI bindings: a component-library model (`defineComponent`, `createLibrary`, `componentProps`), a typed fragment builder (`fragment`, `uiRef`, `uiState`, `uiBuiltin`), the `openui` plugin helper, `serializeExpr`/`OPENUI_LANG_DIALECT` for emitting OpenUI Lang, a `toUiOutput` tool option that renders a tool's result as UI, and `ModelResult.getUiStream()` for consuming fragments as they arrive. A tool declares how its output renders, and the caller streams the fragments: @@ -48,7 +48,7 @@ const weather = tool({ summary: `Clear in ${city}`, }), // Renders the tool's result instead of leaving the model to describe it. - toUIOutput: ({ input, output }) => + toUiOutput: ({ input, output }) => ui.Card(input.city, [ ui.Text(output.summary), ]), diff --git a/packages/agent/README.md b/packages/agent/README.md index 8ce73c9f..fe988d5e 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -110,7 +110,7 @@ What each stream emits: | `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results | | `getToolCallsStream()` | parsed tool calls as they complete | | `getItemsStream()` | all output items (messages, function calls, …) | -| `getUiStream()` | OpenUI events — `statement` / `fragment` / `document` — from tools declaring `toUIOutput` and from the `openui` plugin | +| `getUiStream()` | OpenUI events — `statement` / `fragment` / `document` — from tools declaring `toUiOutput` and from the `openui` plugin | | `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events | ### Tool Types diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fc094e20..8bbbbea8 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -286,7 +286,7 @@ export type { ToolUiFragmentEvent, ToolWithExecute, ToolWithGenerator, - ToUIOutputFunction, + ToUiOutputFunction, TurnContext, TurnEndEvent, TurnStartEvent, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index ebf60e56..3b7b419c 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -2582,6 +2582,10 @@ export class ModelResult< const settledResults = await Promise.allSettled(toolCallPromises); const toolResults: models.FunctionCallOutputItem[] = []; const pausedCalls: ParsedToolCall[] = []; + // Render-only work (toUiOutput) collected during aggregation and awaited + // as one batch below, so user-supplied fragment builders don't serially + // delay the follow-up model turn. + const uiFragmentPromises: Promise[] = []; for (let i = 0; i < settledResults.length; i++) { const settled = settledResults[i]; @@ -2673,9 +2677,13 @@ export class ModelResult< timestamp: Date.now(), } satisfies ToolCallOutputEvent); - await this.broadcastUiFragment(value); + uiFragmentPromises.push(this.broadcastUiFragment(value)); } + // broadcastUiFragment never rejects (errors degrade to a console.warn), + // so a plain all() is safe here. + await Promise.all(uiFragmentPromises); + return { toolResults, pausedCalls, @@ -2685,7 +2693,7 @@ export class ModelResult< /** * Compute and broadcast a tool-authored OpenUI fragment for a successful * execution. Render-only: the fragment never reaches the model, so a - * throwing `toUIOutput` degrades to "no fragment" instead of failing the + * throwing `toUiOutput` degrades to "no fragment" instead of failing the * round — the model-facing output has already been pushed. */ private async broadcastUiFragment(value: { @@ -2699,7 +2707,7 @@ export class ModelResult< if ( value.result.error || !isAutoResolvableTool(value.tool) || - !value.tool.function.toUIOutput + !value.tool.function.toUiOutput ) { return; } @@ -2708,7 +2716,7 @@ export class ModelResult< return; } try { - const fragment = await value.tool.function.toUIOutput({ + const fragment = await value.tool.function.toUiOutput({ output: value.result.result, input: rawArgs, }); @@ -2725,8 +2733,14 @@ export class ModelResult< }, timestamp: Date.now(), } satisfies ToolUiFragmentEvent); - } catch { - // Fragment construction failed — drop it; rendering is best-effort. + } catch (error) { + // Fragment construction failed — drop it; rendering is best-effort. But + // surface the cause, or a throwing toUiOutput is undebuggable ("no + // fragment ever arrives", with nothing in the console). + console.warn( + `toUiOutput for tool "${value.toolCall.name}" (call ${value.toolCall.id}) threw; dropping UI fragment:`, + error, + ); } } @@ -4648,7 +4662,7 @@ export class ModelResult< * Stream OpenUI events from all turns: completed OpenUI Lang statements * authored by the model (`response.openui.*` wire events from the `openui` * plugin) and tool-authored fragments (`tool.ui_fragment` events produced - * by tools declaring `toUIOutput`). + * by tools declaring `toUiOutput`). * * Wire events not yet in the SDK's stream-event union arrive through its * forward-compat catch-all; translation reads the raw payload, so this diff --git a/packages/agent/src/lib/openui/fragment.ts b/packages/agent/src/lib/openui/fragment.ts index 3e86185a..38bde498 100644 --- a/packages/agent/src/lib/openui/fragment.ts +++ b/packages/agent/src/lib/openui/fragment.ts @@ -16,6 +16,21 @@ import { componentProps } from './library.js'; const FRAGMENT_EXPR: unique symbol = Symbol.for('openrouter.openui.fragment-expr'); +/* + * Ref/state/builtin names are emitted verbatim into OpenUI Lang source (every + * other value channel is quoted/JSON-escaped by the serializer), so a name + * derived from model-controlled input could otherwise inject arbitrary + * expressions into what the client treats as trusted tool-authored UI. + */ +const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function assertIdent(kind: string, name: string): string { + if (!IDENT_RE.test(name)) { + throw new Error(`${kind} name ${JSON.stringify(name)} must match ${IDENT_RE.source}`); + } + return name; +} + /** A composable fragment node: a {@link UiFragment} that also nests as a child argument. */ export interface FragmentNode extends UiFragment { [FRAGMENT_EXPR]: UiExpr; @@ -71,7 +86,7 @@ function makeNode(dialect: string, expr: UiExpr): FragmentNode { export function uiRef(name: string, dialect?: string): FragmentNode { return makeNode(dialect ?? OPENUI_LANG_DIALECT, { kind: 'ref', - name, + name: assertIdent('uiRef', name), }); } @@ -79,7 +94,7 @@ export function uiRef(name: string, dialect?: string): FragmentNode { export function uiState(name: string, dialect?: string): FragmentNode { return makeNode(dialect ?? OPENUI_LANG_DIALECT, { kind: 'state-ref', - name, + name: assertIdent('uiState', name), }); } @@ -87,7 +102,7 @@ export function uiState(name: string, dialect?: string): FragmentNode { export function uiBuiltin(fn: string, ...args: FragmentArg[]): FragmentNode { return makeNode(OPENUI_LANG_DIALECT, { kind: 'call', - fn, + fn: assertIdent('uiBuiltin', fn), builtin: true, args: args.map(toExpr), }); diff --git a/packages/agent/src/lib/openui/plugin.ts b/packages/agent/src/lib/openui/plugin.ts index 204dcf9d..0f32fb9d 100644 --- a/packages/agent/src/lib/openui/plugin.ts +++ b/packages/agent/src/lib/openui/plugin.ts @@ -29,6 +29,14 @@ export interface OpenUiPlugin { dialect?: string; } +/* + * Libraries are immutable after `createLibrary`, so the wire shape (including + * the Zod→JSON-Schema conversion per component) is computed once per library + * rather than once per request — `plugins: [openui(library)]` inline per call + * is the documented usage. + */ +const wireCache = new WeakMap(); + /** * Build the `openui` plugin preference from a component library. * @@ -42,6 +50,16 @@ export interface OpenUiPlugin { * ``` */ export function openui(library: UiLibrary): OpenUiPlugin { + const cached = wireCache.get(library); + if (cached) { + return cached; + } + const plugin = buildPlugin(library); + wireCache.set(library, plugin); + return plugin; +} + +function buildPlugin(library: UiLibrary): OpenUiPlugin { return { id: 'openui', library: [ diff --git a/packages/agent/src/lib/openui/ui-stream.ts b/packages/agent/src/lib/openui/ui-stream.ts index 7796cb1f..6d8910cb 100644 --- a/packages/agent/src/lib/openui/ui-stream.ts +++ b/packages/agent/src/lib/openui/ui-stream.ts @@ -4,7 +4,7 @@ * * Two sources feed the UI stream: * - `tool.ui_fragment` — SDK-synthetic events broadcast when a local tool's - * `toUIOutput` produces a fragment. + * `toUiOutput` produces a fragment. * - `response.openui.*` — API wire events emitted by the `openui` plugin. * Until `@openrouter/sdk` regenerates with these union members (DEV-772), * they arrive through the SDK's forward-compat catch-all as @@ -23,7 +23,7 @@ export interface UiStatementEvent { source: string; } -/** A tool-authored fragment (local `toUIOutput` or API `response.openui.fragment`). */ +/** A tool-authored fragment (local `toUiOutput` or API `response.openui.fragment`). */ export interface UiFragmentEvent { type: 'fragment'; /** The tool call this fragment belongs to, when tool-authored. */ diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 5c8e798e..71a4a537 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -275,7 +275,7 @@ export type ToModelOutputFunction = { * @template TOutput - The tool's output type */ // Object-with-method form for bivariant param checking — see ToModelOutputFunction. -export type ToUIOutputFunction = { +export type ToUiOutputFunction = { bivarianceHack(params: { output: TOutput; input: TInput; @@ -337,7 +337,7 @@ export interface ToolFunctionWithExecute< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; } /** @@ -380,7 +380,7 @@ export interface ToolFunctionWithGenerator< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; } /** @@ -431,7 +431,7 @@ export interface HITLToolFunction< ): Promise> | zodInfer; toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; } /** @@ -990,7 +990,7 @@ export type ToolCallOutputEvent = { /** * Tool UI fragment event carrying a tool-authored OpenUI fragment. * Broadcast by executeToolRound after a successful execution when the tool - * declares `toUIOutput`. Client-render only — never sent to the model. + * declares `toUiOutput`. Client-render only — never sent to the model. */ export type ToolUiFragmentEvent = { type: 'tool.ui_fragment'; diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 72a76cf9..3661ce02 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -15,7 +15,7 @@ import type { ToolLoopKey, ToolWithExecute, ToolWithGenerator, - ToUIOutputFunction, + ToUiOutputFunction, } from './tool-types.js'; import { isClientTool, SHARED_CONTEXT_KEY, ToolType } from './tool-types.js'; @@ -50,7 +50,7 @@ type RegularToolConfigWithOutput< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; }; /** @@ -80,7 +80,7 @@ type RegularToolConfigWithoutOutput< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, TReturn>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, TReturn>; + toUiOutput?: ToUiOutputFunction, TReturn>; }; /** @@ -111,7 +111,7 @@ type GeneratorToolConfig< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; }; /** @@ -179,7 +179,7 @@ type HITLToolConfig< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, zodInfer>; + toUiOutput?: ToUiOutputFunction, zodInfer>; }; /** @@ -213,7 +213,7 @@ type ToolConfigWithSharedContext< /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, unknown>; /** Convert tool execution output to a renderable OpenUI fragment */ - toUIOutput?: ToUIOutputFunction, unknown>; + toUiOutput?: ToUiOutputFunction, unknown>; }; //#endregion @@ -390,8 +390,8 @@ export function tool( fn.toModelOutput = config.toModelOutput; } - if (config.toUIOutput !== undefined) { - fn.toUIOutput = config.toUIOutput; + if (config.toUiOutput !== undefined) { + fn.toUiOutput = config.toUiOutput; } return { @@ -479,8 +479,8 @@ export function tool( fn.toModelOutput = config.toModelOutput; } - if ('toUIOutput' in config && config.toUIOutput !== undefined) { - fn.toUIOutput = config.toUIOutput; + if ('toUiOutput' in config && config.toUiOutput !== undefined) { + fn.toUiOutput = config.toUiOutput; } return { @@ -516,9 +516,9 @@ export function tool( config.toModelOutput !== undefined && { toModelOutput: config.toModelOutput, }), - ...('toUIOutput' in config && - config.toUIOutput !== undefined && { - toUIOutput: config.toUIOutput, + ...('toUiOutput' in config && + config.toUiOutput !== undefined && { + toUiOutput: config.toUiOutput, }), }; diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index a13ea237..e31285a5 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the OpenUI streaming half: toUIOutput plumbing through tool(), + * Tests for the OpenUI streaming half: toUiOutput plumbing through tool(), * the tool.ui_fragment broadcast, translateUiEvent (including the SDK's * forward-compat Unknown encoding of response.openui.* wire events), and * getUiStream()'s no-tools fast path. @@ -32,7 +32,7 @@ const library = createLibrary([ ]); const ui = fragment(library); -describe('tool() carries toUIOutput', () => { +describe('tool() carries toUiOutput', () => { it('regular tool', () => { const t = tool({ name: 'usage', @@ -42,9 +42,9 @@ describe('tool() carries toUIOutput', () => { execute: async () => ({ total: 12, }), - toUIOutput: ({ output }) => ui.Card(`$${output.total}`), + toUiOutput: ({ output }) => ui.Card(`$${output.total}`), }); - expect(t.function.toUIOutput).toBeTypeOf('function'); + expect(t.function.toUiOutput).toBeTypeOf('function'); }); it('generator tool', () => { @@ -62,9 +62,9 @@ describe('tool() carries toUIOutput', () => { done: true, }; }, - toUIOutput: () => ui.Text('done'), + toUiOutput: () => ui.Text('done'), }); - expect(t.function.toUIOutput).toBeTypeOf('function'); + expect(t.function.toUiOutput).toBeTypeOf('function'); }); it('HITL tool', () => { @@ -75,9 +75,9 @@ describe('tool() carries toUIOutput', () => { ok: z.boolean(), }), onToolCalled: () => null, - toUIOutput: () => ui.Text('pending'), + toUiOutput: () => ui.Text('pending'), }); - expect(t.function.toUIOutput).toBeTypeOf('function'); + expect(t.function.toUiOutput).toBeTypeOf('function'); }); it('omitted stays absent', () => { @@ -86,7 +86,7 @@ describe('tool() carries toUIOutput', () => { inputSchema: z.object({}), execute: async () => 'ok', }); - expect('toUIOutput' in t.function && t.function.toUIOutput !== undefined).toBe(false); + expect('toUiOutput' in t.function && t.function.toUiOutput !== undefined).toBe(false); }); }); @@ -394,7 +394,7 @@ describe('broadcastUiFragment', () => { execute: async () => ({ total: 12, }), - toUIOutput: ({ output, input }) => ui.Card(`$${output.total} over ${input.days}d`), + toUiOutput: ({ output, input }) => ui.Card(`$${output.total} over ${input.days}d`), }); await internal.broadcastUiFragment( @@ -419,7 +419,7 @@ describe('broadcastUiFragment', () => { }); }); - it('skips tools without toUIOutput and errored executions', async () => { + it('skips tools without toUiOutput and errored executions', async () => { const { internal, pushed } = makeHarness(); const plain = tool({ name: 'plain', @@ -440,7 +440,7 @@ describe('broadcastUiFragment', () => { days: z.number(), }), execute: async () => 'ok', - toUIOutput: () => ui.Text('never'), + toUiOutput: () => ui.Text('never'), }); await internal.broadcastUiFragment( makeCall(withUi, { @@ -452,7 +452,7 @@ describe('broadcastUiFragment', () => { expect(pushed).toEqual([]); }); - it('drops the fragment when toUIOutput returns null or throws', async () => { + it('drops the fragment when toUiOutput returns null or throws', async () => { const { internal, pushed } = makeHarness(); const nullTool = tool({ name: 'null_ui', @@ -460,7 +460,7 @@ describe('broadcastUiFragment', () => { days: z.number(), }), execute: async () => 'ok', - toUIOutput: () => null, + toUiOutput: () => null, }); await internal.broadcastUiFragment( makeCall(nullTool, { @@ -474,7 +474,7 @@ describe('broadcastUiFragment', () => { days: z.number(), }), execute: async () => 'ok', - toUIOutput: () => { + toUiOutput: () => { throw new Error('render bug'); }, }); diff --git a/packages/openui-playground/package.json b/packages/openui-playground/package.json index 5df05291..fb54fd1b 100644 --- a/packages/openui-playground/package.json +++ b/packages/openui-playground/package.json @@ -7,7 +7,6 @@ "scripts": { "lint": "biome check src tests public", "lint:fix": "biome check --write src tests public", - "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest --run", "dev": "tsx watch src/server.ts", diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js index bf6ab0a0..2317289f 100644 --- a/packages/openui-playground/public/app.js +++ b/packages/openui-playground/public/app.js @@ -228,6 +228,12 @@ function renderControl(call, val) { input.type = 'text'; input.className = 'ui-input'; input.placeholder = String(val('placeholder', '')); + // Placeholder text is not an accessible name — surface `name` to AT. + const name = val('name', ''); + if (name) { + input.name = String(name); + input.setAttribute('aria-label', String(name)); + } const v = val('value', ''); if (v) { input.value = String(v); @@ -237,6 +243,11 @@ function renderControl(call, val) { case 'Select': { const select = document.createElement('select'); select.className = 'ui-select'; + const name = val('name', ''); + if (name) { + select.name = String(name); + select.setAttribute('aria-label', String(name)); + } for (const opt of val('options', [])) { const o = document.createElement('option'); o.textContent = String(opt); @@ -260,10 +271,18 @@ function renderControl(call, val) { el('div'), ]); bar.firstChild.style.width = `${value}%`; + // The fill width is invisible to AT — mirror the value into ARIA. + bar.setAttribute('role', 'progressbar'); + bar.setAttribute('aria-valuenow', String(value)); + bar.setAttribute('aria-valuemin', '0'); + bar.setAttribute('aria-valuemax', '100'); const label = val('label', null); + if (label) { + bar.setAttribute('aria-label', String(label)); + } return label ? el('div', null, [ - textNode(String(label), 'ui-text muted'), + textNode(`${String(label)} (${value}%)`, 'ui-text muted'), bar, ]) : bar; @@ -445,7 +464,7 @@ function renderHistory() { const rows = history .map( (h) => - `${h.model} · ${h.mode}${h.ttfbMs ?? '—'}${h.firstStatementMs ?? '—'}${h.totalMs}${h.statements}${h.diagnostics}${h.outputTokens ?? '—'}${h.cost !== null ? `$${h.cost.toFixed(5)}` : '—'}`, + `${escapeHtml(`${h.model} · ${h.mode}`)}${h.ttfbMs ?? '—'}${h.firstStatementMs ?? '—'}${h.totalMs}${h.statements}${h.diagnostics}${h.outputTokens ?? '—'}${h.cost !== null ? `$${h.cost.toFixed(5)}` : '—'}`, ) .join(''); $('history').innerHTML = @@ -478,9 +497,17 @@ async function boot() { } } +/* + * Whether the current run streamed an `error` frame. The server always ends + * the SSE stream normally after an error frame, so `run()` must not overwrite + * the error status with a green "done" when the reader drains. + */ +let streamErrored = false; + async function run() { const runBtn = $('run'); runBtn.disabled = true; + streamErrored = false; resetDoc(); $('lang').replaceChildren(); $('events').textContent = ''; @@ -537,7 +564,9 @@ async function run() { handleEvent(JSON.parse(payload)); } } - setStatus('done'); + if (!streamErrored) { + setStatus('done'); + } } catch (error) { setStatus(String(error.message ?? error), true); } finally { @@ -570,7 +599,10 @@ function handleEvent(event) { case 'document': { if (event.diagnostics.length) { $('diagnostics').innerHTML = event.diagnostics - .map((d) => `
L${d.line}: ${d.message} — ${escapeHtml(d.source)}
`) + .map( + (d) => + `
L${d.line}: ${escapeHtml(d.message)} — ${escapeHtml(d.source)}
`, + ) .join(''); } else { $('diagnostics').innerHTML = @@ -584,6 +616,7 @@ function handleEvent(event) { renderHistory(); break; case 'error': + streamErrored = true; setStatus(event.message, true); break; } diff --git a/packages/openui-playground/public/index.html b/packages/openui-playground/public/index.html index c05757fe..6aac0969 100644 --- a/packages/openui-playground/public/index.html +++ b/packages/openui-playground/public/index.html @@ -100,7 +100,7 @@

OpenUI Playground

- +
@@ -130,7 +130,7 @@

Library prompt sent to the model

Rendered surface

Run a prompt to render generated UI here.

Diagnostics

-
—
+
—
diff --git a/packages/openui-playground/src/lang/parser.ts b/packages/openui-playground/src/lang/parser.ts index 6746117b..e5183f2a 100644 --- a/packages/openui-playground/src/lang/parser.ts +++ b/packages/openui-playground/src/lang/parser.ts @@ -407,8 +407,14 @@ class ExprParser { } } + // Sticky (`y`) regexes anchored via `lastIndex` — scanning must not slice + // the remaining source per token, or long statements parse in O(n²). + private static readonly NUMBER_RE = /-?\d+(\.\d+)?([eE][+-]?\d+)?/y; + private static readonly IDENT_RE = /[A-Za-z_][A-Za-z0-9_]*/y; + private parseNumber(): number { - const match = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(this.src.slice(this.pos)); + ExprParser.NUMBER_RE.lastIndex = this.pos; + const match = ExprParser.NUMBER_RE.exec(this.src); if (!match) { throw new ParseFailure('invalid number'); } @@ -417,7 +423,8 @@ class ExprParser { } private parseIdent(): string { - const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.pos)); + ExprParser.IDENT_RE.lastIndex = this.pos; + const match = ExprParser.IDENT_RE.exec(this.src); if (!match) { throw new ParseFailure(`expected identifier at '${this.src.slice(this.pos, this.pos + 8)}'`); } @@ -438,8 +445,14 @@ class ExprParser { } private skipWs(): void { - while (this.pos < this.src.length && /\s/.test(this.src[this.pos] ?? '')) { - this.pos += 1; + // charCode comparison instead of a per-character regex: space, tab, LF, CR. + for (;;) { + const c = this.src.charCodeAt(this.pos); + if (c === 32 || c === 9 || c === 10 || c === 13) { + this.pos += 1; + } else { + return; + } } } } diff --git a/packages/openui-playground/src/server.ts b/packages/openui-playground/src/server.ts index 61ef09c6..64174c37 100644 --- a/packages/openui-playground/src/server.ts +++ b/packages/openui-playground/src/server.ts @@ -14,7 +14,7 @@ import { readFile } from 'node:fs/promises'; import type { ServerResponse } from 'node:http'; import { createServer } from 'node:http'; -import { dirname, extname, join, normalize } from 'node:path'; +import { dirname, extname, join, normalize, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { OpenRouter } from '@openrouter/agent'; import { demoLibrary } from './demo-library.js'; @@ -92,7 +92,9 @@ function parseGenerateRequest(body: unknown): GenerateRequest | null { async function serveStatic(res: ServerResponse, urlPath: string): Promise { const rel = urlPath === '/' ? 'index.html' : urlPath.slice(1); const file = normalize(join(PUBLIC_DIR, rel)); - if (!file.startsWith(PUBLIC_DIR)) { + // Trailing separator so `public.bak`/`public-anything` siblings can't + // satisfy a bare prefix check. + if (!file.startsWith(PUBLIC_DIR + sep)) { sendJson(res, 404, { error: 'not found', }); @@ -187,6 +189,8 @@ const server = createServer((req, res) => { }); }); -server.listen(PORT, () => { +// Local-only tool backed by the developer's API key — never expose it to the +// LAN by listening on all interfaces. +server.listen(PORT, '127.0.0.1', () => { console.log(`OpenUI playground → http://localhost:${PORT} (default model: ${DEFAULT_MODEL})`); }); diff --git a/packages/openui-playground/tsconfig.json b/packages/openui-playground/tsconfig.json index 6412ea8d..6e3a0dad 100644 --- a/packages/openui-playground/tsconfig.json +++ b/packages/openui-playground/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "esm", "noEmit": true }, "include": ["src", "tests"], - "exclude": ["node_modules", "esm"] + "exclude": ["node_modules"] } From 3d4a582531126536af44c534c3a33e7a190b47e2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:37:23 -0500 Subject: [PATCH 09/20] fix(openui): address review findings --- packages/agent/src/lib/openui/fragment.ts | 40 +++++++++---------- packages/agent/src/lib/openui/library.ts | 11 +++++ packages/agent/tests/unit/openui.test.ts | 25 ++++++++++++ packages/openui-playground/public/app.js | 2 +- packages/openui-playground/src/generate.ts | 5 ++- packages/openui-playground/src/lang/parser.ts | 25 +++++++++--- .../openui-playground/tests/parser.test.ts | 15 +++++++ 7 files changed, 94 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/lib/openui/fragment.ts b/packages/agent/src/lib/openui/fragment.ts index e89be771..2ed54918 100644 --- a/packages/agent/src/lib/openui/fragment.ts +++ b/packages/agent/src/lib/openui/fragment.ts @@ -12,25 +12,10 @@ import * as z4 from 'zod/v4'; import type { UiExpr, UiFragment, UiLiteralValue } from './document.js'; import { OPENUI_LANG_DIALECT, OPENUI_ROOT_REF, serializeExpr } from './document.js'; import type { UiLibrary } from './library.js'; -import { componentProps, OPENUI_BUILTIN_COMPONENTS } from './library.js'; +import { assertIdent, componentProps, OPENUI_BUILTIN_COMPONENTS } from './library.js'; const FRAGMENT_EXPR: unique symbol = Symbol.for('openrouter.openui.fragment-expr'); -/* - * Ref/state/builtin names are emitted verbatim into OpenUI Lang source (every - * other value channel is quoted/JSON-escaped by the serializer), so a name - * derived from model-controlled input could otherwise inject arbitrary - * expressions into what the client treats as trusted tool-authored UI. - */ -const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -function assertIdent(kind: string, name: string): string { - if (!IDENT_RE.test(name)) { - throw new Error(`${kind} name ${JSON.stringify(name)} must match ${IDENT_RE.source}`); - } - return name; -} - /** A composable fragment node: a {@link UiFragment} that also nests as a child argument. */ export interface FragmentNode extends UiFragment { [FRAGMENT_EXPR]: UiExpr; @@ -38,6 +23,7 @@ export interface FragmentNode extends UiFragment { /** Any value accepted as a fragment constructor argument. */ export type FragmentArg = + | undefined | UiLiteralValue | FragmentNode | FragmentArg[] @@ -62,10 +48,22 @@ function toExpr(arg: FragmentArg): UiExpr { if (typeof arg === 'object' && arg !== null) { return { kind: 'object', - entries: Object.entries(arg).map(([key, value]) => ({ - key, - value: toExpr(value), - })), + entries: Object.entries(arg).flatMap(([key, value]) => + value === undefined + ? [] + : [ + { + key, + value: toExpr(value), + }, + ], + ), + }; + } + if (arg === undefined) { + return { + kind: 'literal', + value: null, }; } return { @@ -137,7 +135,7 @@ export function fragment(library: UiLibrary): FragmentBuild const exprs = args.map((arg, i) => { const expr = toExpr(arg); const prop = props[i]; - if (prop && expr.kind === 'literal') { + if (arg !== undefined && prop && expr.kind === 'literal') { const parsed = z4.safeParse(prop.schema, expr.value); if (!parsed.success) { throw new Error( diff --git a/packages/agent/src/lib/openui/library.ts b/packages/agent/src/lib/openui/library.ts index 67188739..338b9e9e 100644 --- a/packages/agent/src/lib/openui/library.ts +++ b/packages/agent/src/lib/openui/library.ts @@ -24,10 +24,20 @@ export interface ComponentDefinition { props?: ZodObject; } +const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function assertIdent(kind: string, name: string): string { + if (!IDENT_RE.test(name)) { + throw new Error(`${kind} name ${JSON.stringify(name)} must match ${IDENT_RE.source}`); + } + return name; +} + /** Declare a component the model (or a tool) may render. */ export function defineComponent( def: ComponentDefinition, ): ComponentDefinition { + assertIdent('component', def.name); return def; } @@ -61,6 +71,7 @@ export function createLibrary( ): UiLibrary { const components = new Map(); for (const def of definitions) { + assertIdent('component', def.name); if (components.has(def.name)) { throw new Error(`duplicate component name '${def.name}' in library`); } diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts index de6e7c31..0436e0e6 100644 --- a/packages/agent/tests/unit/openui.test.ts +++ b/packages/agent/tests/unit/openui.test.ts @@ -225,6 +225,21 @@ describe('createLibrary / componentProps', () => { ).toThrow(/duplicate component name 'A'/); }); + it('rejects component names that are not identifiers', () => { + expect(() => + defineComponent({ + name: 'Card); injected = Text("pwned")', + }), + ).toThrow(/component name .* must match/); + expect(() => + createLibrary([ + { + name: 'Card-name', + }, + ]), + ).toThrow(/component name .* must match/); + }); + it('reports prop signatures in declaration order with optionality', () => { const card = library.components.get('Card'); expect(card).toBeDefined(); @@ -290,6 +305,16 @@ describe('fragment builder', () => { expect(wrapped.source).toBe('root = Card("W", [Text("ok"), {nested: [1, true, null]}])'); }); + it('serializes undefined arguments as null and omits undefined object properties', () => { + expect(ui.Card(undefined).source).toBe('root = Card(null)'); + expect( + ui.Query('weather', { + city: undefined, + units: 'metric', + }).source, + ).toBe('root = Query("weather", {units: "metric"})'); + }); + it('validates literal props at construction time', () => { expect(() => ui.Text(42)).toThrow(/Text\(\) prop 'value' rejects 42/); }); diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js index ccb56c9b..4941309b 100644 --- a/packages/openui-playground/public/app.js +++ b/packages/openui-playground/public/app.js @@ -92,7 +92,7 @@ function evalExpr(expr, depth = 0) { case 'member': { let base = evalExpr(expr.base, depth + 1); for (const key of expr.path) { - base = base !== null ? base[key] : null; + base = base === null || base === undefined ? undefined : base[key]; } return base; } diff --git a/packages/openui-playground/src/generate.ts b/packages/openui-playground/src/generate.ts index 3b4277ab..dc628d49 100644 --- a/packages/openui-playground/src/generate.ts +++ b/packages/openui-playground/src/generate.ts @@ -148,6 +148,7 @@ export async function* generate( let ttfbMs: number | null = null; let firstStatementMs: number | null = null; let statements = 0; + let diagnostics = 0; let chars = 0; if (mode === 'native') { @@ -173,6 +174,8 @@ export async function* generate( } statements += 1; chars += event.source.length; + } else if (event.type === 'document') { + diagnostics += event.diagnostics.length; } yield toPlaygroundEvent(event, Date.now() - start, statements); } @@ -186,7 +189,7 @@ export async function* generate( firstStatementMs, totalMs: Date.now() - start, statements, - diagnostics: 0, + diagnostics, chars, ...usage, }; diff --git a/packages/openui-playground/src/lang/parser.ts b/packages/openui-playground/src/lang/parser.ts index e5183f2a..25c77012 100644 --- a/packages/openui-playground/src/lang/parser.ts +++ b/packages/openui-playground/src/lang/parser.ts @@ -397,13 +397,26 @@ class ExprParser { throw new ParseFailure('unterminated escape'); } this.pos += 1; - if (esc === 'n') { - out += '\n'; - } else if (esc === 't') { - out += '\t'; - } else { - out += esc; + if (esc === 'u') { + const hex = this.src.slice(this.pos, this.pos + 4); + if (!/^[0-9A-Fa-f]{4}$/.test(hex)) { + throw new ParseFailure(`invalid unicode escape '\\u${hex}'`); + } + out += String.fromCharCode(Number.parseInt(hex, 16)); + this.pos += 4; + continue; } + const escaped = { + '"': '"', + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + }[esc]; + out += escaped ?? esc; } } diff --git a/packages/openui-playground/tests/parser.test.ts b/packages/openui-playground/tests/parser.test.ts index 30f3d9bb..f3f1db8b 100644 --- a/packages/openui-playground/tests/parser.test.ts +++ b/packages/openui-playground/tests/parser.test.ts @@ -1,3 +1,4 @@ +import { serializeExpr } from '@openrouter/agent'; import { describe, expect, it } from 'vitest'; import { OpenUiLangParser, parseDocument } from '../src/lang/parser.js'; @@ -161,6 +162,20 @@ describe('parseDocument (tolerance + semantics)', () => { }); }); + it('round-trips strings serialized by the SDK', () => { + const value = 'bell:\u0007 newline:\n tab:\t return:\r slash:\\ quote:"'; + const source = serializeExpr({ + kind: 'literal', + value, + }); + const doc = parseDocument(`value = ${source}`); + expect(doc.diagnostics).toEqual([]); + expect(doc.assignments['value']?.expr).toEqual({ + kind: 'literal', + value, + }); + }); + it('reports unparseable expressions as diagnostics with the source line', () => { const doc = parseDocument('bad = Card(("unclosed"\ngood = Text("ok")'); // The unbalanced paren swallows the newline; only one statement completes. From 3cefbdcd95bffe8cc53496620021fa99998fc5ff Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:47:32 -0500 Subject: [PATCH 10/20] refactor(openui): simplify native diagnostics counting --- packages/openui-playground/src/generate.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/openui-playground/src/generate.ts b/packages/openui-playground/src/generate.ts index dc628d49..5ba035ee 100644 --- a/packages/openui-playground/src/generate.ts +++ b/packages/openui-playground/src/generate.ts @@ -115,6 +115,10 @@ function extractUsage(response: unknown): UsageSummary { * `generate` so the per-variant field mapping does not count toward that * function's complexity, which the structural gate caps. */ +function countDiagnostics(event: UiStreamEvent): number { + return event.type === 'document' ? event.diagnostics.length : 0; +} + function toPlaygroundEvent(event: UiStreamEvent, at: number, statements: number): PlaygroundEvent { if (event.type === 'document') { return { @@ -174,9 +178,8 @@ export async function* generate( } statements += 1; chars += event.source.length; - } else if (event.type === 'document') { - diagnostics += event.diagnostics.length; } + diagnostics += countDiagnostics(event); yield toPlaygroundEvent(event, Date.now() - start, statements); } From 829a94c1d45d5fceaa1b68443befbb1c7d808284 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:29:02 -0500 Subject: [PATCH 11/20] fix(agent): bound OpenUI fragment rendering --- packages/agent/src/index.ts | 1 + packages/agent/src/lib/model-result.ts | 31 +++- packages/agent/src/lib/openui/fragment.ts | 26 +++- packages/agent/src/lib/openui/index.ts | 1 + .../agent/tests/unit/openui-stream.test.ts | 133 ++++++++++++++++-- packages/agent/tests/unit/openui.test.ts | 16 +++ 6 files changed, 187 insertions(+), 21 deletions(-) diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 11b98a21..9f3e933c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -210,6 +210,7 @@ export type { OpenUiPlugin, OpenUiWireComponent, PropSignature, + UiBuiltinOptions, UiDocumentEvent, UiExpr, UiFragment, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 40a6538d..12fe6ace 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -143,6 +143,8 @@ import { export const DEFAULT_FINAL_RESPONSE_DIRECTIVE = 'You have reached the tool-use limit, and tools are no longer available. Do not attempt to call any more tools. Using the information you already have, write your final answer now.'; +const UI_FRAGMENT_RENDER_TIMEOUT_MS = 50; + /** * Typeguard for plain-object records (non-null, non-array). */ @@ -3095,7 +3097,7 @@ export class ModelResult< (candidate) => isClientTool(candidate) && candidate.function.name === task.name, ); if (tool) { - await this.broadcastUiFragment({ + await this.awaitUiFragment({ toolCall: { id: task.callId, name: task.name, @@ -3675,11 +3677,9 @@ export class ModelResult< timestamp: Date.now(), } satisfies ToolCallOutputEvent); - uiFragmentPromises.push(this.broadcastUiFragment(value)); + uiFragmentPromises.push(this.awaitUiFragment(value)); } - // broadcastUiFragment never rejects (errors degrade to a console.warn), - // so a plain all() is safe here. await Promise.all(uiFragmentPromises); return { @@ -3840,7 +3840,7 @@ export class ModelResult< }, }; const outputForModel = await this.computeToolOutputForModel(settledValue); - await this.broadcastUiFragment(settledValue); + await this.awaitUiFragment(settledValue); return { output: { type: 'function_call_output' as const, @@ -4345,6 +4345,27 @@ export class ModelResult< }; } + private awaitUiFragment(value: { + toolCall: ParsedToolCall; + tool: Tool; + result: { + result: unknown; + error?: Error; + }; + }): Promise { + const rendering = this.broadcastUiFragment(value); + return new Promise((resolve) => { + const timer = setTimeout(resolve, UI_FRAGMENT_RENDER_TIMEOUT_MS); + if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } + rendering.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + /** * Compute and broadcast a tool-authored OpenUI fragment for a successful * execution. Render-only: the fragment never reaches the model, so a diff --git a/packages/agent/src/lib/openui/fragment.ts b/packages/agent/src/lib/openui/fragment.ts index 2ed54918..fdd1e19e 100644 --- a/packages/agent/src/lib/openui/fragment.ts +++ b/packages/agent/src/lib/openui/fragment.ts @@ -96,9 +96,31 @@ export function uiState(name: string, dialect?: string): FragmentNode { }); } +/** Options for stamping a standalone built-in with a custom dialect. */ +export interface UiBuiltinOptions { + dialect?: string; +} + /** A built-in function step (`uiBuiltin('Run', uiRef('save'))` → `@Run(save)`). */ -export function uiBuiltin(fn: string, ...args: FragmentArg[]): FragmentNode { - return makeNode(OPENUI_LANG_DIALECT, { +export function uiBuiltin(fn: string, ...args: FragmentArg[]): FragmentNode; +export function uiBuiltin( + options: UiBuiltinOptions, + fn: string, + ...args: FragmentArg[] +): FragmentNode; +export function uiBuiltin( + fnOrOptions: string | UiBuiltinOptions, + ...fnAndArgs: + | [ + string, + ...FragmentArg[], + ] + | FragmentArg[] +): FragmentNode { + const hasOptions = typeof fnOrOptions !== 'string'; + const fn = hasOptions ? (fnAndArgs[0] as string) : fnOrOptions; + const args = hasOptions ? fnAndArgs.slice(1) : fnAndArgs; + return makeNode(hasOptions ? (fnOrOptions.dialect ?? OPENUI_LANG_DIALECT) : OPENUI_LANG_DIALECT, { kind: 'call', fn: assertIdent('uiBuiltin', fn), builtin: true, diff --git a/packages/agent/src/lib/openui/index.ts b/packages/agent/src/lib/openui/index.ts index 78cf91f1..d18646b8 100644 --- a/packages/agent/src/lib/openui/index.ts +++ b/packages/agent/src/lib/openui/index.ts @@ -19,6 +19,7 @@ export { type FragmentBuilder, type FragmentNode, fragment, + type UiBuiltinOptions, uiBuiltin, uiRef, uiState, diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index e93886cb..55d2858a 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -40,6 +40,56 @@ const library = createLibrary([ ]); const ui = fragment(library); +const response = (id: string, output: models.OpenResponsesResult['output']) => + ({ + id, + object: 'response', + createdAt: 0, + model: 'test-model', + status: 'completed', + output, + error: null, + incompleteDetails: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, + }) as models.OpenResponsesResult; + +function mockToolRound(toolName: string): void { + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: response('r1', [ + { + type: 'function_call', + id: 'fc1', + callId: 'c1', + name: toolName, + arguments: '{}', + status: 'completed', + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + value: response('r2', [ + { + type: 'message', + id: 'm1', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + annotations: [], + }, + ], + }, + ]), + }); +} + describe('tool() carries toUiOutput', () => { it('regular tool', () => { const t = tool({ @@ -336,6 +386,75 @@ describe('getUiStream (no-tools fast path)', () => { }); }); +describe('toUiOutput round lifecycle', () => { + it('does not block the run when rendering never settles', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('hanging_ui'); + const hanging = tool({ + name: 'hanging_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: () => new Promise(() => undefined), + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + hanging, + ] as const, + }, + ); + + await expect( + Promise.race([ + result.getText(), + new Promise((_, reject) => setTimeout(() => reject(new Error('run stalled')), 100)), + ]), + ).resolves.toBe('done'); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + }); + + it('delivers a normally-fast fragment before the UI stream closes', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('fast_ui'); + const fast = tool({ + name: 'fast_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: () => ui.Text('ready'), + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + fast, + ] as const, + }, + ); + const events = []; + + for await (const event of result.getUiStream()) { + events.push(event); + } + + expect(events).toContainEqual({ + type: 'fragment', + toolCallId: 'c1', + toolName: 'fast_ui', + dialect: 'openui-lang/0.5', + source: 'root = Text("ready")', + }); + }); +}); + describe('async tool settlement', () => { it('emits UI after background work settles past its grace window', async () => { mockBetaResponsesSend.mockReset(); @@ -358,20 +477,6 @@ describe('async tool settlement', () => { run: () => gate, toUiOutput: ({ input, output }) => ui.Card(`${input.city}: ${output.summary}`), }); - const response = (id: string, output: models.OpenResponsesResult['output']) => - ({ - id, - object: 'response', - createdAt: 0, - model: 'test-model', - status: 'completed', - output, - error: null, - incompleteDetails: null, - tools: [], - toolChoice: 'auto', - parallelToolCalls: false, - }) as models.OpenResponsesResult; mockBetaResponsesSend .mockResolvedValueOnce({ ok: true, diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts index 0436e0e6..49539f99 100644 --- a/packages/agent/tests/unit/openui.test.ts +++ b/packages/agent/tests/unit/openui.test.ts @@ -290,6 +290,22 @@ describe('fragment builder', () => { expect(ui.Query('weather', {}).source).toBe('root = Query("weather", {})'); }); + it('stamps standalone builtins with a custom library dialect', () => { + const custom = createLibrary([], { + dialect: 'openui-lang/0.6', + }); + const node = uiBuiltin( + { + dialect: custom.dialect, + }, + 'Run', + uiRef('save', custom.dialect), + ); + + expect(node.dialect).toBe(custom.dialect); + expect(node.source).toBe('root = @Run(save)'); + }); + it('accepts plain objects and arrays as args', () => { const node = ui.Text('ok'); const wrapped = ui.Card('W', [ From 67011562d47553e96553375d762e51f6e878865c Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:54:18 -0500 Subject: [PATCH 12/20] fix(openui): finish review feedback --- packages/agent/src/lib/model-result.ts | 59 +++++++----- .../agent/tests/unit/openui-stream.test.ts | 90 +++++++++++++++---- packages/openui-playground/public/app.js | 15 +--- .../openui-playground/public/render-utils.js | 16 ++++ packages/openui-playground/src/server.ts | 10 +-- packages/openui-playground/src/static.ts | 6 ++ .../openui-playground/tests/security.test.ts | 40 +++++++++ 7 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 packages/openui-playground/public/render-utils.js create mode 100644 packages/openui-playground/src/static.ts create mode 100644 packages/openui-playground/tests/security.test.ts diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 12fe6ace..249cd8c6 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -143,8 +143,6 @@ import { export const DEFAULT_FINAL_RESPONSE_DIRECTIVE = 'You have reached the tool-use limit, and tools are no longer available. Do not attempt to call any more tools. Using the information you already have, write your final answer now.'; -const UI_FRAGMENT_RENDER_TIMEOUT_MS = 50; - /** * Typeguard for plain-object records (non-null, non-array). */ @@ -628,6 +626,7 @@ export class ModelResult< private turnBroadcaster: ToolEventBroadcaster< ResponseStreamEvent, InferToolOutputsUnion> > | null = null; + private pendingUiFragments = new Set>(); private initialStreamPipeStarted = false; private initialPipePromise: Promise | null = null; @@ -1020,7 +1019,7 @@ export class ModelResult< * Set up the turn broadcaster with tool execution and return the consumer. * Used by stream methods that need to iterate over all turns. */ - private startTurnBroadcasterExecution(): { + private startTurnBroadcasterExecution(options?: { drainUiFragments?: boolean }): { consumer: AsyncIterableIterator< ResponseStreamEvent, InferToolOutputsUnion> >; @@ -1037,6 +1036,9 @@ export class ModelResult< if (this.initialPipePromise) { await this.initialPipePromise; } + if (options?.drainUiFragments) { + await this.drainUiFragments(); + } broadcaster.complete(); }); return { @@ -3097,7 +3099,7 @@ export class ModelResult< (candidate) => isClientTool(candidate) && candidate.function.name === task.name, ); if (tool) { - await this.awaitUiFragment({ + this.dispatchUiFragment({ toolCall: { id: task.callId, name: task.name, @@ -3550,7 +3552,6 @@ export class ModelResult< const toolResults: models.FunctionCallOutputItem[] = []; const pausedCalls: ParsedToolCall[] = []; const deferredTasks: PendingAsyncTool[] = []; - const uiFragmentPromises: Promise[] = []; // Start ALL async invocations before consuming any outcome: the work // (and its grace window) begins in handleAsyncInvocation, so awaiting @@ -3677,11 +3678,9 @@ export class ModelResult< timestamp: Date.now(), } satisfies ToolCallOutputEvent); - uiFragmentPromises.push(this.awaitUiFragment(value)); + this.dispatchUiFragment(value); } - await Promise.all(uiFragmentPromises); - return { toolResults, pausedCalls, @@ -3840,7 +3839,7 @@ export class ModelResult< }, }; const outputForModel = await this.computeToolOutputForModel(settledValue); - await this.awaitUiFragment(settledValue); + this.dispatchUiFragment(settledValue); return { output: { type: 'function_call_output' as const, @@ -4345,25 +4344,39 @@ export class ModelResult< }; } - private awaitUiFragment(value: { + private dispatchUiFragment(value: { toolCall: ParsedToolCall; tool: Tool; result: { result: unknown; error?: Error; }; - }): Promise { + }): void { const rendering = this.broadcastUiFragment(value); - return new Promise((resolve) => { - const timer = setTimeout(resolve, UI_FRAGMENT_RENDER_TIMEOUT_MS); - if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { - timer.unref(); - } - rendering.then(() => { - clearTimeout(timer); - resolve(); - }); - }); + this.pendingUiFragments.add(rendering); + rendering.finally(() => this.pendingUiFragments.delete(rendering)); + } + + private async drainUiFragments(): Promise { + if (this.pendingUiFragments.size === 0) { + return; + } + const timeoutMs = this.options.asyncTools?.drainTimeoutMs ?? 30_000; + let timer: ReturnType | undefined; + await Promise.race([ + Promise.all([ + ...this.pendingUiFragments, + ]), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); + if (timer !== undefined) { + clearTimeout(timer); + } } /** @@ -6853,7 +6866,9 @@ export class ModelResult< return; } - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); + const { consumer, executionPromise } = this.startTurnBroadcasterExecution({ + drainUiFragments: true, + }); for await (const event of consumer) { const uiEvent = translateUiEvent(event); diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index 55d2858a..26d11d88 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -6,6 +6,7 @@ */ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type * as models from '@openrouter/sdk/models'; +import { StreamEvents$inboundSchema } from '@openrouter/sdk/models/streamevents'; import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { callModel } from '../../src/inner-loop/call-model.js'; @@ -184,18 +185,21 @@ describe('translateUiEvent', () => { }); }); - it("unwraps the SDK's Unknown forward-compat encoding", () => { - const event = translateUiEvent({ + it("unwraps the installed SDK's runtime Unknown encoding", () => { + const raw = { + type: 'response.openui.statement', + ref: '$tab', + kind: 'state', + source: '$tab = "overview"', + }; + const encoded = StreamEvents$inboundSchema.parse(raw); + + expect(encoded).toEqual({ type: 'UNKNOWN', isUnknown: true, - raw: { - type: 'response.openui.statement', - ref: '$tab', - kind: 'state', - source: '$tab = "overview"', - }, + raw, }); - expect(event).toEqual({ + expect(translateUiEvent(encoded)).toEqual({ type: 'statement', ref: '$tab', kind: 'state', @@ -418,14 +422,14 @@ describe('toUiOutput round lifecycle', () => { expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); }); - it('delivers a normally-fast fragment before the UI stream closes', async () => { + it('bounds UI-stream close with the configured drain deadline', async () => { mockBetaResponsesSend.mockReset(); - mockToolRound('fast_ui'); - const fast = tool({ - name: 'fast_ui', + mockToolRound('hanging_ui'); + const hanging = tool({ + name: 'hanging_ui', inputSchema: z.object({}), execute: () => 'ok', - toUiOutput: () => ui.Text('ready'), + toUiOutput: () => new Promise(() => undefined), }); const result = callModel( { @@ -435,20 +439,72 @@ describe('toUiOutput round lifecycle', () => { model: 'test-model', input: 'test', tools: [ - fast, + hanging, ] as const, + asyncTools: { + drainTimeoutMs: 10, + }, }, ); - const events = []; + const events = []; for await (const event of result.getUiStream()) { events.push(event); } + expect(events).toEqual([]); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + }); + + it('advances the model while retaining ordinary async rendering until UI drain', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('async_ui'); + let release: (() => void) | undefined; + const rendering = new Promise((resolve) => { + release = resolve; + }); + const asyncUi = tool({ + name: 'async_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: async () => { + await rendering; + return ui.Text('ready'); + }, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + asyncUi, + ] as const, + }, + ); + const events: unknown[] = []; + async function consumeUiStream() { + for await (const event of result.getUiStream()) { + events.push(event); + } + } + const consuming = consumeUiStream(); + + await vi.waitFor(() => expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2)); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'fragment', + }), + ); + release?.(); + await consuming; + expect(events).toContainEqual({ type: 'fragment', toolCallId: 'c1', - toolName: 'fast_ui', + toolName: 'async_ui', dialect: 'openui-lang/0.5', source: 'root = Text("ready")', }); diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js index 4941309b..ae2f90b6 100644 --- a/packages/openui-playground/public/app.js +++ b/packages/openui-playground/public/app.js @@ -7,6 +7,8 @@ * client resolves refs/state and materializes DOM without its own parser. */ +import { escapeHtml, resolveMember } from './render-utils.js'; + const $ = (id) => document.getElementById(id); const PRESETS = [ @@ -89,13 +91,8 @@ function evalExpr(expr, depth = 0) { const target = doc.assignments.get(expr.name); return target ? evalExpr(target.expr, depth + 1) : null; } - case 'member': { - let base = evalExpr(expr.base, depth + 1); - for (const key of expr.path) { - base = base === null || base === undefined ? undefined : base[key]; - } - return base; - } + case 'member': + return resolveMember(evalExpr(expr.base, depth + 1), expr.path); case 'call': return expr; // calls materialize as DOM, not values default: @@ -628,9 +625,5 @@ function handleEvent(event) { } } -function escapeHtml(s) { - return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); -} - $('run').addEventListener('click', run); boot().catch((error) => setStatus(String(error), true)); diff --git a/packages/openui-playground/public/render-utils.js b/packages/openui-playground/public/render-utils.js new file mode 100644 index 00000000..2bd288a7 --- /dev/null +++ b/packages/openui-playground/public/render-utils.js @@ -0,0 +1,16 @@ +/** @param {any} base @param {string[]} path */ +export function resolveMember(base, path) { + let value = base; + for (const key of path) { + if (value === null || value === undefined) { + return undefined; + } + value = value[key]; + } + return value; +} + +/** @param {string} value */ +export function escapeHtml(value) { + return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`); +} diff --git a/packages/openui-playground/src/server.ts b/packages/openui-playground/src/server.ts index 64174c37..95d81209 100644 --- a/packages/openui-playground/src/server.ts +++ b/packages/openui-playground/src/server.ts @@ -14,13 +14,14 @@ import { readFile } from 'node:fs/promises'; import type { ServerResponse } from 'node:http'; import { createServer } from 'node:http'; -import { dirname, extname, join, normalize, sep } from 'node:path'; +import { dirname, extname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { OpenRouter } from '@openrouter/agent'; import { demoLibrary } from './demo-library.js'; import type { GenerateRequest, PlaygroundEvent } from './generate.js'; import { generate } from './generate.js'; import { libraryPrompt } from './lang/prompt.js'; +import { resolveStaticPath } from './static.js'; const PORT = Number(process.env['PORT'] ?? 5170); const DEFAULT_MODEL = process.env['OPENUI_PLAYGROUND_MODEL'] ?? 'anthropic/claude-sonnet-5'; @@ -90,11 +91,8 @@ function parseGenerateRequest(body: unknown): GenerateRequest | null { } async function serveStatic(res: ServerResponse, urlPath: string): Promise { - const rel = urlPath === '/' ? 'index.html' : urlPath.slice(1); - const file = normalize(join(PUBLIC_DIR, rel)); - // Trailing separator so `public.bak`/`public-anything` siblings can't - // satisfy a bare prefix check. - if (!file.startsWith(PUBLIC_DIR + sep)) { + const file = resolveStaticPath(PUBLIC_DIR, urlPath); + if (!file) { sendJson(res, 404, { error: 'not found', }); diff --git a/packages/openui-playground/src/static.ts b/packages/openui-playground/src/static.ts new file mode 100644 index 00000000..b073368c --- /dev/null +++ b/packages/openui-playground/src/static.ts @@ -0,0 +1,6 @@ +import { dirname, resolve, sep } from 'node:path'; + +export function resolveStaticPath(publicDir: string, urlPath: string): string | null { + const file = resolve(publicDir, urlPath === '/' ? 'index.html' : `.${urlPath}`); + return dirname(file) === publicDir || file.startsWith(publicDir + sep) ? file : null; +} diff --git a/packages/openui-playground/tests/security.test.ts b/packages/openui-playground/tests/security.test.ts new file mode 100644 index 00000000..af1e9655 --- /dev/null +++ b/packages/openui-playground/tests/security.test.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { escapeHtml, resolveMember } from '../public/render-utils.js'; +import { resolveStaticPath } from '../src/static.js'; + +describe('playground rendering', () => { + it('returns undefined when a nested member is missing', () => { + expect( + resolveMember( + { + rows: undefined, + }, + [ + 'rows', + 'title', + ], + ), + ).toBeUndefined(); + }); + + it('escapes every model-controlled diagnostics HTML character', () => { + expect(escapeHtml(`&`)).toBe( + '<img src="x" onerror='alert(1)'>&', + ); + }); +}); + +describe('static path containment', () => { + const publicDir = resolve('/tmp/openui-playground/public'); + + it('accepts files inside public', () => { + expect(resolveStaticPath(publicDir, '/app.js')).toBe(resolve(publicDir, 'app.js')); + expect(resolveStaticPath(publicDir, '/')).toBe(resolve(publicDir, 'index.html')); + }); + + it('rejects traversal and sibling-prefix paths', () => { + expect(resolveStaticPath(publicDir, '/../public-notes/secret.txt')).toBeNull(); + expect(resolveStaticPath(publicDir, '/../../secret.txt')).toBeNull(); + }); +}); From ff7098264e731b9e1913353f6cd9b0c8eb212bd0 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:06:07 -0500 Subject: [PATCH 13/20] fix(openui): release timed-out UI renders --- packages/agent/src/lib/model-result.ts | 33 +++++---- .../agent/tests/unit/openui-stream.test.ts | 73 +++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 249cd8c6..a4a2ee1f 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -4361,21 +4361,28 @@ export class ModelResult< if (this.pendingUiFragments.size === 0) { return; } + const pending = [ + ...this.pendingUiFragments, + ]; const timeoutMs = this.options.asyncTools?.drainTimeoutMs ?? 30_000; let timer: ReturnType | undefined; - await Promise.race([ - Promise.all([ - ...this.pendingUiFragments, - ]), - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs); - if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { - timer.unref(); - } - }), - ]); - if (timer !== undefined) { - clearTimeout(timer); + try { + await Promise.race([ + Promise.all(pending), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + for (const rendering of pending) { + this.pendingUiFragments.delete(rendering); + } } } diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index 26d11d88..7b912de1 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -627,6 +627,16 @@ describe('broadcastUiFragment', () => { turnBroadcaster: { push: (event: unknown) => void; } | null; + pendingUiFragments: Set>; + dispatchUiFragment: (value: { + toolCall: ParsedToolCall; + tool: Tool; + result: { + result: unknown; + error?: Error; + }; + }) => void; + drainUiFragments: () => Promise; broadcastUiFragment: (value: { toolCall: ParsedToolCall; tool: Tool; @@ -678,6 +688,69 @@ describe('broadcastUiFragment', () => { }; } + it('releases timed-out renders without removing renders added during the drain', async () => { + vi.useFakeTimers(); + try { + const { internal, pushed } = makeHarness(); + let releaseLater: (() => void) | undefined; + const hanging = tool({ + name: 'hanging_ui', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'old', + toUiOutput: () => new Promise(() => undefined), + }); + const later = tool({ + name: 'later_ui', + inputSchema: z.object({ + days: z.number(), + }), + execute: async () => 'new', + toUiOutput: async () => { + await new Promise((resolve) => { + releaseLater = resolve; + }); + return ui.Text('later'); + }, + }); + + internal.dispatchUiFragment( + makeCall(hanging, { + result: 'old', + }), + ); + const firstDrain = internal.drainUiFragments(); + internal.dispatchUiFragment( + makeCall(later, { + result: 'new', + }), + ); + + await vi.advanceTimersByTimeAsync(30_000); + await firstDrain; + expect(internal.pendingUiFragments).toHaveLength(1); + + let secondDrainFinished = false; + const secondDrain = internal.drainUiFragments().then(() => { + secondDrainFinished = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(secondDrainFinished).toBe(false); + + releaseLater?.(); + await secondDrain; + expect(internal.pendingUiFragments).toHaveLength(0); + expect(pushed).toContainEqual( + expect.objectContaining({ + toolName: 'later_ui', + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it('pushes a tool.ui_fragment event for a successful execution', async () => { const { internal, pushed } = makeHarness(); const t = tool({ From e956e1ec16f71ecd47026d64e55ffbdca8d5f58a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:16:26 -0500 Subject: [PATCH 14/20] fix(openui): drain renders added before close --- packages/agent/src/lib/model-result.ts | 38 +++-- .../agent/tests/unit/openui-stream.test.ts | 142 +++++++++++------- 2 files changed, 109 insertions(+), 71 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index a4a2ee1f..2cdc7317 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -4361,28 +4361,38 @@ export class ModelResult< if (this.pendingUiFragments.size === 0) { return; } - const pending = [ - ...this.pendingUiFragments, - ]; const timeoutMs = this.options.asyncTools?.drainTimeoutMs ?? 30_000; let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(true), timeoutMs); + if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { + timer.unref(); + } + }); try { - await Promise.race([ - Promise.all(pending), - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs); - if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') { - timer.unref(); + while (this.pendingUiFragments.size > 0) { + const pending = [ + ...this.pendingUiFragments, + ]; + try { + const timedOut = await Promise.race([ + Promise.all(pending).then(() => false), + deadline, + ]); + if (timedOut) { + this.pendingUiFragments.clear(); + return; } - }), - ]); + } finally { + for (const rendering of pending) { + this.pendingUiFragments.delete(rendering); + } + } + } } finally { if (timer !== undefined) { clearTimeout(timer); } - for (const rendering of pending) { - this.pendingUiFragments.delete(rendering); - } } } diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index 7b912de1..e72a86d8 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -454,6 +454,13 @@ describe('toUiOutput round lifecycle', () => { expect(events).toEqual([]); expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + expect( + ( + result as unknown as { + pendingUiFragments: Set>; + } + ).pendingUiFragments, + ).toHaveLength(0); }); it('advances the model while retaining ordinary async rendering until UI drain', async () => { @@ -688,67 +695,88 @@ describe('broadcastUiFragment', () => { }; } - it('releases timed-out renders without removing renders added during the drain', async () => { - vi.useFakeTimers(); - try { - const { internal, pushed } = makeHarness(); - let releaseLater: (() => void) | undefined; - const hanging = tool({ - name: 'hanging_ui', - inputSchema: z.object({ - days: z.number(), - }), - execute: async () => 'old', - toUiOutput: () => new Promise(() => undefined), - }); - const later = tool({ - name: 'later_ui', - inputSchema: z.object({ - days: z.number(), - }), - execute: async () => 'new', - toUiOutput: async () => { - await new Promise((resolve) => { - releaseLater = resolve; - }); - return ui.Text('later'); - }, - }); - - internal.dispatchUiFragment( - makeCall(hanging, { - result: 'old', - }), - ); - const firstDrain = internal.drainUiFragments(); - internal.dispatchUiFragment( - makeCall(later, { - result: 'new', - }), - ); - - await vi.advanceTimersByTimeAsync(30_000); - await firstDrain; - expect(internal.pendingUiFragments).toHaveLength(1); + it('delivers renders added during the production drain before closing the UI stream', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('initial_ui'); + let releaseInitial: (() => void) | undefined; + let releaseLater: (() => void) | undefined; + const initial = tool({ + name: 'initial_ui', + inputSchema: z.object({}), + execute: () => 'initial', + toUiOutput: async () => { + await new Promise((resolve) => { + releaseInitial = resolve; + }); + return ui.Text('initial'); + }, + }); + const later = tool({ + name: 'later_ui', + inputSchema: z.object({}), + execute: () => 'later', + toUiOutput: async () => { + await new Promise((resolve) => { + releaseLater = resolve; + }); + return ui.Text('later'); + }, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + initial, + ] as const, + }, + ); + const internal = result as unknown as Internal; + let notifyDrainStarted: (() => void) | undefined; + const drainStarted = new Promise((resolve) => { + notifyDrainStarted = resolve; + }); + const drainUiFragments = internal.drainUiFragments.bind(internal); + internal.drainUiFragments = async () => { + notifyDrainStarted?.(); + await drainUiFragments(); + }; - let secondDrainFinished = false; - const secondDrain = internal.drainUiFragments().then(() => { - secondDrainFinished = true; - }); - await vi.advanceTimersByTimeAsync(0); - expect(secondDrainFinished).toBe(false); + const events: unknown[] = []; + async function consumeUiStream() { + for await (const event of result.getUiStream()) { + events.push(event); + } + } + const consuming = consumeUiStream(); - releaseLater?.(); - await secondDrain; - expect(internal.pendingUiFragments).toHaveLength(0); - expect(pushed).toContainEqual( + await drainStarted; + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + internal.dispatchUiFragment( + makeCall(later, { + result: 'later', + }), + ); + releaseInitial?.(); + await vi.waitFor(() => + expect(events).toContainEqual( expect.objectContaining({ - toolName: 'later_ui', + toolName: 'initial_ui', }), - ); - } finally { - vi.useRealTimers(); - } + ), + ); + releaseLater?.(); + await consuming; + + expect(internal.pendingUiFragments).toHaveLength(0); + expect(events).toContainEqual( + expect.objectContaining({ + toolName: 'later_ui', + }), + ); }); it('pushes a tool.ui_fragment event for a successful execution', async () => { From 0f7eb506b0d6d1ecbabbea060506288b6b59c659 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:36:19 -0500 Subject: [PATCH 15/20] fix(agent): drain UI fragments before stream completion --- packages/agent/src/lib/model-result.ts | 36 +++--- .../agent/tests/unit/openui-stream.test.ts | 119 ++++++++++++++++++ packages/openui-playground/public/app.js | 7 +- .../openui-playground/public/render-utils.js | 5 + .../openui-playground/tests/security.test.ts | 14 ++- 5 files changed, 156 insertions(+), 25 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 2cdc7317..1a0eb788 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -627,6 +627,7 @@ export class ModelResult< ResponseStreamEvent, InferToolOutputsUnion> > | null = null; private pendingUiFragments = new Set>(); + private turnBroadcasterCompletionPromise: Promise | null = null; private initialStreamPipeStarted = false; private initialPipePromise: Promise | null = null; @@ -1019,7 +1020,7 @@ export class ModelResult< * Set up the turn broadcaster with tool execution and return the consumer. * Used by stream methods that need to iterate over all turns. */ - private startTurnBroadcasterExecution(options?: { drainUiFragments?: boolean }): { + private startTurnBroadcasterExecution(): { consumer: AsyncIterableIterator< ResponseStreamEvent, InferToolOutputsUnion> >; @@ -1028,22 +1029,21 @@ export class ModelResult< const broadcaster = this.ensureTurnBroadcaster(); this.startInitialStreamPipe(); const consumer = broadcaster.createConsumer(); - const executionPromise = this.executeToolsIfNeeded().finally(async () => { - // Wait for the initial stream pipe to finish pushing all events - // (including turn.end) before marking the broadcaster as complete. - // Without this, turn.end can be silently dropped if the pipe hasn't - // finished when executeToolsIfNeeded completes. - if (this.initialPipePromise) { - await this.initialPipePromise; - } - if (options?.drainUiFragments) { + if (!this.turnBroadcasterCompletionPromise) { + this.turnBroadcasterCompletionPromise = this.executeToolsIfNeeded().finally(async () => { + // Wait for every event producer before closing the shared broadcaster. + // UI rendering is best-effort and bounded; the drain is a no-op when + // no fragments are pending. + if (this.initialPipePromise) { + await this.initialPipePromise; + } await this.drainUiFragments(); - } - broadcaster.complete(); - }); + broadcaster.complete(); + }); + } return { consumer, - executionPromise, + executionPromise: this.turnBroadcasterCompletionPromise, }; } @@ -3098,12 +3098,12 @@ export class ModelResult< const tool = this.options.tools?.find( (candidate) => isClientTool(candidate) && candidate.function.name === task.name, ); - if (tool) { + if (tool && task.input !== undefined) { this.dispatchUiFragment({ toolCall: { id: task.callId, name: task.name, - arguments: task.input ?? {}, + arguments: task.input, } as ParsedToolCall, tool, result: { @@ -6883,9 +6883,7 @@ export class ModelResult< return; } - const { consumer, executionPromise } = this.startTurnBroadcasterExecution({ - drainUiFragments: true, - }); + const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); for await (const event of consumer) { const uiEvent = translateUiEvent(event); diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index e72a86d8..ebab007c 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -463,6 +463,69 @@ describe('toUiOutput round lifecycle', () => { ).toHaveLength(0); }); + it('delivers async UI when UI, text, and item streams are consumed concurrently', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('concurrent_ui'); + let release: (() => void) | undefined; + const rendering = new Promise((resolve) => { + release = resolve; + }); + const concurrentUi = tool({ + name: 'concurrent_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: async () => { + await rendering; + return ui.Text('concurrent'); + }, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + concurrentUi, + ] as const, + }, + ); + const text: string[] = []; + const items: unknown[] = []; + const events: unknown[] = []; + async function collect(stream: AsyncIterable, values: T[]) { + for await (const value of stream) { + values.push(value); + } + } + const consumeText = collect(result.getTextStream(), text); + const consumeItems = collect(result.getItemsStream(), items); + const consumeUi = collect(result.getUiStream(), events); + + await vi.waitFor(() => expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2)); + release?.(); + await Promise.all([ + consumeText, + consumeItems, + consumeUi, + ]); + + expect(text).toEqual([]); + expect(items).toContainEqual( + expect.objectContaining({ + type: 'function_call_output', + }), + ); + expect(events).toContainEqual({ + type: 'fragment', + toolCallId: 'c1', + toolName: 'concurrent_ui', + dialect: 'openui-lang/0.5', + source: 'root = Text("concurrent")', + }); + }); + it('advances the model while retaining ordinary async rendering until UI drain', async () => { mockBetaResponsesSend.mockReset(); mockToolRound('async_ui'); @@ -629,6 +692,62 @@ describe('async tool settlement', () => { }); }); +describe('late async tool UI settlement', () => { + it('skips rendering when deferred settlement has no retained input', async () => { + const toUiOutput = vi.fn(() => ui.Text('never')); + const deferred = tool({ + name: 'deferred_ui', + lifecycle: 'deferred', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + summary: z.string(), + }), + run: () => ({ + taskId: 'task_1', + }), + toUiOutput, + }); + const result = new ModelResult({ + request: { + model: 'test-model', + input: 'test', + tools: [ + deferred, + ], + }, + client: {} as OpenRouterCore, + }); + const internal = result as unknown as { + asyncToolRegistry: { + takeSettled: () => Array>; + }; + flushAsyncToolDeliveries: () => Promise; + injectAppendPromptMessage: () => Promise; + }; + internal.asyncToolRegistry = { + takeSettled: () => [ + { + callId: 'c1', + taskId: 'task_1', + name: 'deferred_ui', + status: 'completed', + result: { + summary: 'Clear', + }, + durationMs: 1, + }, + ], + }; + internal.injectAppendPromptMessage = async () => undefined; + + await internal.flushAsyncToolDeliveries(); + + expect(toUiOutput).not.toHaveBeenCalled(); + }); +}); + describe('broadcastUiFragment', () => { type Internal = { turnBroadcaster: { diff --git a/packages/openui-playground/public/app.js b/packages/openui-playground/public/app.js index ae2f90b6..e1218ea9 100644 --- a/packages/openui-playground/public/app.js +++ b/packages/openui-playground/public/app.js @@ -7,7 +7,7 @@ * client resolves refs/state and materializes DOM without its own parser. */ -import { escapeHtml, resolveMember } from './render-utils.js'; +import { escapeHtml, renderDiagnostic, resolveMember } from './render-utils.js'; const $ = (id) => document.getElementById(id); @@ -602,10 +602,7 @@ function handleEvent(event) { // offending line and `message` carries parser text built from it // (ParseFailure.message), or arrives verbatim off the wire in native // mode. Escaping only `source` left an injection through `message`. - .map( - (d) => - `
L${escapeHtml(String(d.line))}: ${escapeHtml(d.message)} — ${escapeHtml(d.source)}
`, - ) + .map(renderDiagnostic) .join(''); } else { $('diagnostics').innerHTML = diff --git a/packages/openui-playground/public/render-utils.js b/packages/openui-playground/public/render-utils.js index 2bd288a7..f8cd06ae 100644 --- a/packages/openui-playground/public/render-utils.js +++ b/packages/openui-playground/public/render-utils.js @@ -14,3 +14,8 @@ export function resolveMember(base, path) { export function escapeHtml(value) { return value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`); } + +/** @param {{ line: unknown, message: string, source: string }} diagnostic */ +export function renderDiagnostic({ line, message, source }) { + return `
L${escapeHtml(String(line))}: ${escapeHtml(message)} — ${escapeHtml(source)}
`; +} diff --git a/packages/openui-playground/tests/security.test.ts b/packages/openui-playground/tests/security.test.ts index af1e9655..d4b740cc 100644 --- a/packages/openui-playground/tests/security.test.ts +++ b/packages/openui-playground/tests/security.test.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { escapeHtml, resolveMember } from '../public/render-utils.js'; +import { escapeHtml, renderDiagnostic, resolveMember } from '../public/render-utils.js'; import { resolveStaticPath } from '../src/static.js'; describe('playground rendering', () => { @@ -23,6 +23,18 @@ describe('playground rendering', () => { '<img src="x" onerror='alert(1)'>&', ); }); + + it('escapes hostile diagnostic line, message, and source fields', () => { + expect( + renderDiagnostic({ + line: ``, + message: ``, + source: ``, + }), + ).toBe( + '
L<img src=x onerror='line()'>: <img src=x onerror='message()'> — <img src=x onerror='source()'>
', + ); + }); }); describe('static path containment', () => { From bccd1ad5ae87a454d68caa7020f0dfa9c565e130 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:23:30 -0500 Subject: [PATCH 16/20] fix(agent): isolate UI stream lifecycle --- packages/agent/src/lib/model-result.ts | 36 ++++- .../agent/tests/unit/openui-stream.test.ts | 148 ++++++++++++------ 2 files changed, 131 insertions(+), 53 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 1a0eb788..dd345c2e 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -258,6 +258,9 @@ function extractServerToolIdentity(item: ServerToolResultItem): Record, InferToolOutputsUnion> > | null = null; + private uiBroadcaster: ToolEventBroadcaster | null = null; private pendingUiFragments = new Set>(); + private uiBroadcasterCompletionPromise: Promise | null = null; private turnBroadcasterCompletionPromise: Promise | null = null; private initialStreamPipeStarted = false; private initialPipePromise: Promise | null = null; @@ -1031,13 +1036,10 @@ export class ModelResult< const consumer = broadcaster.createConsumer(); if (!this.turnBroadcasterCompletionPromise) { this.turnBroadcasterCompletionPromise = this.executeToolsIfNeeded().finally(async () => { - // Wait for every event producer before closing the shared broadcaster. - // UI rendering is best-effort and bounded; the drain is a no-op when - // no fragments are pending. + // Preserve turn.end, but never couple non-UI stream completion to UI rendering. if (this.initialPipePromise) { await this.initialPipePromise; } - await this.drainUiFragments(); broadcaster.complete(); }); } @@ -4352,6 +4354,9 @@ export class ModelResult< error?: Error; }; }): void { + if (!this.uiBroadcaster) { + return; + } const rendering = this.broadcastUiFragment(value); this.pendingUiFragments.add(rendering); rendering.finally(() => this.pendingUiFragments.delete(rendering)); @@ -4361,7 +4366,7 @@ export class ModelResult< if (this.pendingUiFragments.size === 0) { return; } - const timeoutMs = this.options.asyncTools?.drainTimeoutMs ?? 30_000; + const timeoutMs = DEFAULT_UI_DRAIN_TIMEOUT_MS; let timer: ReturnType | undefined; const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); @@ -4429,7 +4434,7 @@ export class ModelResult< if (!fragment) { return; } - this.turnBroadcaster?.push({ + this.uiBroadcaster?.push({ type: 'tool.ui_fragment' as const, toolCallId: value.toolCall.id, toolName: value.toolCall.name, @@ -6883,7 +6888,18 @@ export class ModelResult< return; } + if (!this.uiBroadcaster) { + this.uiBroadcaster = new ToolEventBroadcaster(); + } + const uiBroadcaster = this.uiBroadcaster; + const uiConsumer = uiBroadcaster.createConsumer(); const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); + if (!this.uiBroadcasterCompletionPromise) { + this.uiBroadcasterCompletionPromise = executionPromise.finally(async () => { + await this.drainUiFragments(); + uiBroadcaster.complete(); + }); + } for await (const event of consumer) { const uiEvent = translateUiEvent(event); @@ -6891,8 +6907,14 @@ export class ModelResult< yield uiEvent; } } + for await (const event of uiConsumer) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } + } - await executionPromise; + await this.uiBroadcasterCompletionPromise; }.call(this); } diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index ebab007c..9753e1b9 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -391,14 +391,15 @@ describe('getUiStream (no-tools fast path)', () => { }); describe('toUiOutput round lifecycle', () => { - it('does not block the run when rendering never settles', async () => { + it('does not render or block the run without a UI consumer', async () => { mockBetaResponsesSend.mockReset(); mockToolRound('hanging_ui'); + const toUiOutput = vi.fn(() => new Promise(() => undefined)); const hanging = tool({ name: 'hanging_ui', inputSchema: z.object({}), execute: () => 'ok', - toUiOutput: () => new Promise(() => undefined), + toUiOutput, }); const result = callModel( { @@ -413,54 +414,66 @@ describe('toUiOutput round lifecycle', () => { }, ); - await expect( - Promise.race([ - result.getText(), - new Promise((_, reject) => setTimeout(() => reject(new Error('run stalled')), 100)), - ]), - ).resolves.toBe('done'); + await expect(result.getText()).resolves.toBe('done'); + expect(toUiOutput).not.toHaveBeenCalled(); expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); }); - it('bounds UI-stream close with the configured drain deadline', async () => { - mockBetaResponsesSend.mockReset(); - mockToolRound('hanging_ui'); - const hanging = tool({ - name: 'hanging_ui', - inputSchema: z.object({}), - execute: () => 'ok', - toUiOutput: () => new Promise(() => undefined), - }); - const result = callModel( - { - _options: {}, - } as OpenRouterCore, - { - model: 'test-model', - input: 'test', - tools: [ - hanging, - ] as const, - asyncTools: { - drainTimeoutMs: 10, + it('finishes text immediately and closes hanging UI at its own deadline', async () => { + vi.useFakeTimers(); + try { + mockBetaResponsesSend.mockReset(); + mockToolRound('hanging_ui'); + const hanging = tool({ + name: 'hanging_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: () => new Promise(() => undefined), + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + hanging, + ] as const, + asyncTools: { + drainTimeoutMs: 1, + }, }, - }, - ); - - const events = []; - for await (const event of result.getUiStream()) { - events.push(event); - } + ); - expect(events).toEqual([]); - expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); - expect( - ( - result as unknown as { - pendingUiFragments: Set>; + async function consumeUiStream() { + for await (const _event of result.getUiStream()) { + // No fragment is produced by the hanging renderer. } - ).pendingUiFragments, - ).toHaveLength(0); + } + const uiDone = consumeUiStream(); + await expect(result.getText()).resolves.toBe('done'); + + let closed = false; + void uiDone.then(() => { + closed = true; + }); + await vi.advanceTimersByTimeAsync(29_999); + expect(closed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await uiDone; + + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + expect( + ( + result as unknown as { + pendingUiFragments: Set>; + } + ).pendingUiFragments, + ).toHaveLength(0); + } finally { + vi.useRealTimers(); + } }); it('delivers async UI when UI, text, and item streams are consumed concurrently', async () => { @@ -526,6 +539,49 @@ describe('toUiOutput round lifecycle', () => { }); }); + it('delivers the same fragment to concurrent UI consumers', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('shared_ui'); + const sharedUi = tool({ + name: 'shared_ui', + inputSchema: z.object({}), + execute: () => 'ok', + toUiOutput: () => ui.Text('shared'), + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + sharedUi, + ] as const, + }, + ); + const collect = async (stream: AsyncIterable) => { + const events = []; + for await (const event of stream) { + events.push(event); + } + return events; + }; + + const [first, second] = await Promise.all([ + collect(result.getUiStream()), + collect(result.getUiStream()), + ]); + + expect(first).toEqual(second); + expect(first).toContainEqual( + expect.objectContaining({ + type: 'fragment', + toolName: 'shared_ui', + }), + ); + }); + it('advances the model while retaining ordinary async rendering until UI drain', async () => { mockBetaResponsesSend.mockReset(); mockToolRound('async_ui'); @@ -750,7 +806,7 @@ describe('late async tool UI settlement', () => { describe('broadcastUiFragment', () => { type Internal = { - turnBroadcaster: { + uiBroadcaster: { push: (event: unknown) => void; } | null; pendingUiFragments: Set>; @@ -783,7 +839,7 @@ describe('broadcastUiFragment', () => { client: {} as OpenRouterCore, }); const internal = modelResult as unknown as Internal; - internal.turnBroadcaster = { + internal.uiBroadcaster = { push: (event: unknown) => { pushed.push(event); }, From cbfd884422bfec89e9ad9374a33f157de9a002bd Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:32:18 -0500 Subject: [PATCH 17/20] fix(agent): release exited UI consumers --- packages/agent/src/lib/model-result.ts | 50 +++-- .../agent/src/lib/tool-event-broadcaster.ts | 13 +- .../agent/tests/unit/openui-stream.test.ts | 173 ++++++++++++++++++ .../tests/unit/tool-event-broadcaster.test.ts | 2 + 4 files changed, 212 insertions(+), 26 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index dd345c2e..b3759bf4 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -4354,7 +4354,7 @@ export class ModelResult< error?: Error; }; }): void { - if (!this.uiBroadcaster) { + if (!this.uiBroadcaster?.activeConsumerCount) { return; } const rendering = this.broadcastUiFragment(value); @@ -4434,7 +4434,10 @@ export class ModelResult< if (!fragment) { return; } - this.uiBroadcaster?.push({ + if (!this.uiBroadcaster?.activeConsumerCount) { + return; + } + this.uiBroadcaster.push({ type: 'tool.ui_fragment' as const, toolCallId: value.toolCall.id, toolName: value.toolCall.name, @@ -6893,28 +6896,35 @@ export class ModelResult< } const uiBroadcaster = this.uiBroadcaster; const uiConsumer = uiBroadcaster.createConsumer(); - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - if (!this.uiBroadcasterCompletionPromise) { - this.uiBroadcasterCompletionPromise = executionPromise.finally(async () => { - await this.drainUiFragments(); - uiBroadcaster.complete(); - }); - } + try { + const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); + if (!this.uiBroadcasterCompletionPromise) { + this.uiBroadcasterCompletionPromise = executionPromise.finally(async () => { + await this.drainUiFragments(); + uiBroadcaster.complete(); + }); + } - for await (const event of consumer) { - const uiEvent = translateUiEvent(event); - if (uiEvent) { - yield uiEvent; + for await (const event of consumer) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } } - } - for await (const event of uiConsumer) { - const uiEvent = translateUiEvent(event); - if (uiEvent) { - yield uiEvent; + for await (const event of uiConsumer) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } } - } - await this.uiBroadcasterCompletionPromise; + await this.uiBroadcasterCompletionPromise; + } finally { + await uiConsumer.return?.(); + if (uiBroadcaster.activeConsumerCount === 0) { + this.pendingUiFragments.clear(); + } + } }.call(this); } diff --git a/packages/agent/src/lib/tool-event-broadcaster.ts b/packages/agent/src/lib/tool-event-broadcaster.ts index bc4fc069..a2c8a2e1 100644 --- a/packages/agent/src/lib/tool-event-broadcaster.ts +++ b/packages/agent/src/lib/tool-event-broadcaster.ts @@ -15,6 +15,11 @@ export class ToolEventBroadcaster { private isComplete = false; private completionError: Error | null = null; + /** Number of consumers currently subscribed to this broadcaster. */ + get activeConsumerCount(): number { + return this.consumers.size; + } + /** * Push a new event to all consumers. * Events are buffered so late-joining consumers can catch up. @@ -40,13 +45,9 @@ export class ToolEventBroadcaster { queueMicrotask(() => this.cleanup()); } - /** - * Clean up resources after all consumers have finished. - * Called automatically after complete(), but can be called manually. - */ + /** Release buffered events once no consumer can read them. */ private cleanup(): void { - // Only cleanup if complete and all consumers are done - if (this.isComplete && this.consumers.size === 0) { + if (this.consumers.size === 0) { this.buffer = []; } } diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index 9753e1b9..58d0481c 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -582,6 +582,177 @@ describe('toUiOutput round lifecycle', () => { ); }); + it('unsubscribes after an early break and skips later rendering', async () => { + mockBetaResponsesSend.mockReset(); + mockToolRound('first_ui'); + const firstUi = tool({ + name: 'first_ui', + inputSchema: z.object({}), + execute: () => 'first', + toUiOutput: () => ui.Text('first'), + }); + const laterRenderer = vi.fn(() => ui.Text('later')); + const laterUi = tool({ + name: 'later_ui', + inputSchema: z.object({}), + execute: () => 'later', + toUiOutput: laterRenderer, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + firstUi, + ] as const, + }, + ); + + for await (const event of result.getUiStream()) { + expect(event).toMatchObject({ + type: 'fragment', + toolName: 'first_ui', + }); + break; + } + + const internal = result as unknown as { + uiBroadcaster: { + activeConsumerCount: number; + }; + pendingUiFragments: Set>; + dispatchUiFragment: (value: { + toolCall: ParsedToolCall; + tool: Tool; + result: { + result: unknown; + }; + }) => void; + }; + expect(internal.uiBroadcaster.activeConsumerCount).toBe(0); + internal.dispatchUiFragment({ + toolCall: { + id: 'c2', + name: 'later_ui', + arguments: {}, + } as unknown as ParsedToolCall, + tool: laterUi, + result: { + result: 'later', + }, + }); + + expect(laterRenderer).not.toHaveBeenCalled(); + expect(internal.pendingUiFragments).toHaveLength(0); + }); + + it('keeps rendering for a remaining UI consumer after another exits', async () => { + mockBetaResponsesSend.mockReset(); + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: response('r1', [ + { + type: 'function_call', + id: 'fc1', + callId: 'c1', + name: 'fast_ui', + arguments: '{}', + status: 'completed', + }, + { + type: 'function_call', + id: 'fc2', + callId: 'c2', + name: 'slow_ui', + arguments: '{}', + status: 'completed', + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + value: response('r2', [ + { + type: 'message', + id: 'm1', + role: 'assistant', + status: 'completed', + content: [], + }, + ]), + }); + let releaseSlow: (() => void) | undefined; + const slowGate = new Promise((resolve) => { + releaseSlow = resolve; + }); + const fastUi = tool({ + name: 'fast_ui', + inputSchema: z.object({}), + execute: () => 'fast', + toUiOutput: () => ui.Text('fast'), + }); + const slowRenderer = vi.fn(async () => { + await slowGate; + return ui.Text('slow'); + }); + const slowUi = tool({ + name: 'slow_ui', + inputSchema: z.object({}), + execute: () => 'slow', + toUiOutput: slowRenderer, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + fastUi, + slowUi, + ] as const, + }, + ); + const first = result.getUiStream(); + const remaining = result.getUiStream(); + + const [firstEvent, remainingFirstEvent] = await Promise.all([ + first.next(), + remaining.next(), + ]); + expect(firstEvent.value).toMatchObject({ + type: 'fragment', + toolName: 'fast_ui', + }); + expect(remainingFirstEvent.value).toEqual(firstEvent.value); + await first.return(); + expect( + ( + result as unknown as { + uiBroadcaster: { + activeConsumerCount: number; + }; + } + ).uiBroadcaster.activeConsumerCount, + ).toBe(1); + + releaseSlow?.(); + await expect(remaining.next()).resolves.toMatchObject({ + done: false, + value: { + type: 'fragment', + toolName: 'slow_ui', + }, + }); + await remaining.return(); + expect(slowRenderer).toHaveBeenCalledOnce(); + }); + it('advances the model while retaining ordinary async rendering until UI drain', async () => { mockBetaResponsesSend.mockReset(); mockToolRound('async_ui'); @@ -807,6 +978,7 @@ describe('late async tool UI settlement', () => { describe('broadcastUiFragment', () => { type Internal = { uiBroadcaster: { + activeConsumerCount: number; push: (event: unknown) => void; } | null; pendingUiFragments: Set>; @@ -840,6 +1012,7 @@ describe('broadcastUiFragment', () => { }); const internal = modelResult as unknown as Internal; internal.uiBroadcaster = { + activeConsumerCount: 1, push: (event: unknown) => { pushed.push(event); }, diff --git a/packages/agent/tests/unit/tool-event-broadcaster.test.ts b/packages/agent/tests/unit/tool-event-broadcaster.test.ts index 290f13f6..405dd7c5 100644 --- a/packages/agent/tests/unit/tool-event-broadcaster.test.ts +++ b/packages/agent/tests/unit/tool-event-broadcaster.test.ts @@ -42,6 +42,7 @@ describe('ToolEventBroadcaster', () => { const broadcaster = new ToolEventBroadcaster(); const consumer = broadcaster.createConsumer(); + expect(broadcaster.activeConsumerCount).toBe(1); broadcaster.push(1); broadcaster.push(2); @@ -53,6 +54,7 @@ describe('ToolEventBroadcaster', () => { // Cancel consumer await consumer.return!(); + expect(broadcaster.activeConsumerCount).toBe(0); // Should be done now const after = await consumer.next(); expect(after.done).toBe(true); From 1f68c5e40433ede694d39e66d05363745eb6d451 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:52:27 -0500 Subject: [PATCH 18/20] fix(agent): merge OpenUI event streams --- packages/agent/src/lib/model-result.ts | 87 +++++++++++++++++-- .../agent/src/lib/tool-event-broadcaster.ts | 6 +- .../agent/tests/unit/openui-stream.test.ts | 76 ++++++++++++++++ .../tests/unit/tool-event-broadcaster.test.ts | 41 +++++++++ 4 files changed, 201 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index b3759bf4..f19fead3 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -261,6 +261,82 @@ const MAX_FORCE_RESUME_OVERRIDES = 3; /** Maximum time a UI consumer waits for asynchronous fragment rendering. */ const DEFAULT_UI_DRAIN_TIMEOUT_MS = 30_000; +type IteratorOutcome = + | { + source: 0 | 1; + result: IteratorResult; + } + | { + source: 0 | 1; + error: unknown; + }; + +async function* mergeAsyncIterators( + iterators: readonly [ + AsyncIterator, + AsyncIterator, + ], +): AsyncGenerator { + const active = [ + true, + true, + ]; + const pending: Array> | null> = [ + null, + null, + ]; + let preferred: 0 | 1 = 0; + const next = async (source: 0 | 1): Promise> => { + try { + return { + source, + result: await iterators[source].next(), + }; + } catch (error) { + return { + source, + error, + }; + } + }; + + try { + while (active[0] || active[1]) { + for (const source of [ + 0, + 1, + ] as const) { + if (active[source] && !pending[source]) { + pending[source] = next(source); + } + } + const other: 0 | 1 = preferred === 0 ? 1 : 0; + const outcome: IteratorOutcome = await Promise.race( + [ + pending[preferred], + pending[other], + ].filter((promise): promise is Promise> => promise !== null), + ); + pending[outcome.source] = null; + if ('error' in outcome) { + throw outcome.error; + } + if (outcome.result.done) { + active[outcome.source] = false; + preferred = outcome.source === 0 ? 1 : 0; + continue; + } + preferred = outcome.source === 0 ? 1 : 0; + yield outcome.result.value; + } + } finally { + await Promise.allSettled(iterators.map((iterator) => iterator.return?.())); + await Promise.all( + pending.filter((promise): promise is Promise> => !!promise), + ); + } +} + /** * Sentinel marking a tool-call id that appeared MORE THAN ONCE in one batch. * Ids are model-emitted and nothing upstream enforces uniqueness; a colliding @@ -6905,13 +6981,10 @@ export class ModelResult< }); } - for await (const event of consumer) { - const uiEvent = translateUiEvent(event); - if (uiEvent) { - yield uiEvent; - } - } - for await (const event of uiConsumer) { + for await (const event of mergeAsyncIterators([ + consumer, + uiConsumer, + ])) { const uiEvent = translateUiEvent(event); if (uiEvent) { yield uiEvent; diff --git a/packages/agent/src/lib/tool-event-broadcaster.ts b/packages/agent/src/lib/tool-event-broadcaster.ts index a2c8a2e1..0bc733d6 100644 --- a/packages/agent/src/lib/tool-event-broadcaster.ts +++ b/packages/agent/src/lib/tool-event-broadcaster.ts @@ -45,9 +45,9 @@ export class ToolEventBroadcaster { queueMicrotask(() => this.cleanup()); } - /** Release buffered events once no consumer can read them. */ + /** Release completed history once no consumer can read it. */ private cleanup(): void { - if (this.consumers.size === 0) { + if (this.isComplete && this.consumers.size === 0) { this.buffer = []; } } @@ -133,6 +133,7 @@ export class ToolEventBroadcaster { const consumer = self.consumers.get(consumerId); if (consumer) { consumer.cancelled = true; + consumer.waitingPromise?.resolve(); self.consumers.delete(consumerId); self.cleanup(); } @@ -146,6 +147,7 @@ export class ToolEventBroadcaster { const consumer = self.consumers.get(consumerId); if (consumer) { consumer.cancelled = true; + consumer.waitingPromise?.resolve(); self.consumers.delete(consumerId); self.cleanup(); } diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index 58d0481c..afaea68f 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -753,6 +753,82 @@ describe('toUiOutput round lifecycle', () => { expect(slowRenderer).toHaveBeenCalledOnce(); }); + it('yields a tool fragment before the later model round completes', async () => { + mockBetaResponsesSend.mockReset(); + let finishRound: ((value: { ok: true; value: models.OpenResponsesResult }) => void) | undefined; + const laterRound = new Promise<{ + ok: true; + value: models.OpenResponsesResult; + }>((resolve) => { + finishRound = resolve; + }); + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: response('r1', [ + { + type: 'function_call', + id: 'fc1', + callId: 'c1', + name: 'progressive_ui', + arguments: '{}', + status: 'completed', + }, + ]), + }) + .mockReturnValueOnce(laterRound); + const progressiveUi = tool({ + name: 'progressive_ui', + inputSchema: z.object({}), + execute: () => 'ready', + toUiOutput: () => ui.Text('progressive'), + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + progressiveUi, + ] as const, + }, + ); + const stream = result.getUiStream(); + let firstEvent: IteratorResult | undefined; + const pendingFirst = stream.next().then((event) => { + firstEvent = event; + return event; + }); + + await vi.waitFor(() => expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(firstEvent).toBeDefined()); + expect(await pendingFirst).toMatchObject({ + done: false, + value: { + type: 'fragment', + toolName: 'progressive_ui', + }, + }); + + finishRound?.({ + ok: true, + value: response('r2', [ + { + type: 'message', + id: 'm1', + role: 'assistant', + status: 'completed', + content: [], + }, + ]), + }); + while (!(await stream.next()).done) { + // Drain the completed round so no execution work escapes the test. + } + }); + it('advances the model while retaining ordinary async rendering until UI drain', async () => { mockBetaResponsesSend.mockReset(); mockToolRound('async_ui'); diff --git a/packages/agent/tests/unit/tool-event-broadcaster.test.ts b/packages/agent/tests/unit/tool-event-broadcaster.test.ts index 405dd7c5..1bd06641 100644 --- a/packages/agent/tests/unit/tool-event-broadcaster.test.ts +++ b/packages/agent/tests/unit/tool-event-broadcaster.test.ts @@ -59,6 +59,47 @@ describe('ToolEventBroadcaster', () => { const after = await consumer.next(); expect(after.done).toBe(true); }); + + it('preserves history when the last consumer exits before completion', async () => { + const broadcaster = new ToolEventBroadcaster(); + const first = broadcaster.createConsumer(); + + broadcaster.push(1); + expect(await first.next()).toMatchObject({ + value: 1, + }); + await first.return?.(); + broadcaster.push(2); + + const later = broadcaster.createConsumer(); + broadcaster.push(3); + broadcaster.complete(); + + const events: number[] = []; + for await (const event of later) { + events.push(event); + } + expect(events).toEqual([ + 1, + 2, + 3, + ]); + }); + + it('releases completed history when no consumers remain', async () => { + const broadcaster = new ToolEventBroadcaster(); + broadcaster.push(1); + broadcaster.complete(); + await Promise.resolve(); + + expect( + ( + broadcaster as unknown as { + buffer: number[]; + } + ).buffer, + ).toEqual([]); + }); }); describe('multiple consumers', () => { From 11eac753cd48a28e77b85754d9d977dff0516a44 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:18:16 -0500 Subject: [PATCH 19/20] fix(agent): render deferred tool UI results --- .../src/inner-loop/resume-tool-results.ts | 31 +++- packages/agent/src/lib/async-tool-registry.ts | 5 + packages/agent/src/lib/model-result.ts | 52 +++++- packages/agent/src/lib/tool-types.ts | 2 + .../tests/unit/async-tool-deferred.test.ts | 6 + .../tests/unit/async-tool-registry.test.ts | 29 +++ .../agent/tests/unit/openui-stream.test.ts | 173 +++++++++++++++--- 7 files changed, 274 insertions(+), 24 deletions(-) diff --git a/packages/agent/src/inner-loop/resume-tool-results.ts b/packages/agent/src/inner-loop/resume-tool-results.ts index a3c0145f..9320f28c 100644 --- a/packages/agent/src/inner-loop/resume-tool-results.ts +++ b/packages/agent/src/inner-loop/resume-tool-results.ts @@ -221,6 +221,12 @@ export async function resumeToolResults( const settledIds = new Set(state.settledAsyncCallIds ?? []); const envelopes: models.BaseInputsUnion[] = []; + const uiToolResults: Array<{ + callId: string; + name: string; + input: Record; + output: unknown; + }> = []; /** callId → the terminal lifecycle status persisted for that entry. */ const settledNow = new Map(); @@ -249,6 +255,7 @@ export async function resumeToolResults( const envelope = buildResumeEnvelope(entry, task, request.tools); envelopes.push(buildTaskResultMessage(envelope)); + collectUiToolResult(uiToolResults, envelope, task); // Persist the entry's real terminal status. 'expired' / 'timed_out' // have no ToolTaskStatus member — they persist as 'failed'. settledNow.set( @@ -313,7 +320,7 @@ export async function resumeToolResults( // Continue the conversation: the envelopes are already in persisted // history, so no fresh input is supplied. - return callModel( + const result = callModel( client, { ...request.run, @@ -325,6 +332,28 @@ export async function resumeToolResults( }, options, ); + result.queueUiToolResults(uiToolResults); + return result; +} + +function collectUiToolResult( + results: Array<{ + callId: string; + name: string; + input: Record; + output: unknown; + }>, + envelope: ToolTaskResultEnvelope, + task: PendingAsyncTool, +): void { + if (envelope.status === 'completed' && task.input !== undefined) { + results.push({ + callId: task.callId, + name: task.name, + input: task.input, + output: envelope.result, + }); + } } /** diff --git a/packages/agent/src/lib/async-tool-registry.ts b/packages/agent/src/lib/async-tool-registry.ts index 27f49146..aa8cda25 100644 --- a/packages/agent/src/lib/async-tool-registry.ts +++ b/packages/agent/src/lib/async-tool-registry.ts @@ -147,6 +147,7 @@ export class AsyncToolRegistry { callId: string; taskId: string; name: string; + input: Record; expiresAt?: number; pollAfterMs?: number; }): ToolTask { @@ -155,6 +156,7 @@ export class AsyncToolRegistry { callId: entry.callId, toolName: entry.name, mode: 'defer', + input: entry.input, ...(entry.expiresAt !== undefined && { expiresAt: entry.expiresAt, }), @@ -301,6 +303,9 @@ export class AsyncToolRegistry { mode: t.mode, status: t.status, startedAt: t.startedAt, + ...(t.input !== undefined && { + input: t.input, + }), ...(t.expiresAt !== undefined && { expiresAt: t.expiresAt, }), diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index f19fead3..87ab4910 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -707,6 +707,12 @@ export class ModelResult< > | null = null; private uiBroadcaster: ToolEventBroadcaster | null = null; private pendingUiFragments = new Set>(); + private queuedUiToolResults: Array<{ + callId: string; + name: string; + input: Record; + output: unknown; + }> = []; private uiBroadcasterCompletionPromise: Promise | null = null; private turnBroadcasterCompletionPromise: Promise | null = null; private initialStreamPipeStarted = false; @@ -2775,10 +2781,12 @@ export class ModelResult< } const registry = this.ensureAsyncToolRegistry(); + const input = (tc.arguments ?? {}) as Record; const liveTask = registry.trackDeferred({ callId: tc.id, taskId: invocation.taskId, name: String(tc.name), + input, ...(invocation.pollAfterMs !== undefined && { pollAfterMs: invocation.pollAfterMs, }), @@ -2793,6 +2801,7 @@ export class ModelResult< mode: 'defer', status: 'working', startedAt: liveTask.startedAt, + input, ...(invocation.pollAfterMs !== undefined && { pollAfterMs: invocation.pollAfterMs, }), @@ -3797,10 +3806,12 @@ export class ModelResult< if (collision) { return collision; } + const input = (toolCall.arguments ?? {}) as Record; const liveTask = registry.trackDeferred({ callId: toolCall.id, taskId: invocation.taskId, name: String(toolCall.name), + input, ...(invocation.expiresAt !== undefined && { expiresAt: invocation.expiresAt, }), @@ -3816,6 +3827,7 @@ export class ModelResult< mode: 'defer', status: 'working', startedAt: liveTask.startedAt, + input, ...(invocation.pollAfterMs !== undefined && { pollAfterMs: invocation.pollAfterMs, }), @@ -4422,6 +4434,41 @@ export class ModelResult< }; } + /** @internal Queue externally resumed tool results for the UI lifecycle. */ + queueUiToolResults( + results: Array<{ + callId: string; + name: string; + input: Record; + output: unknown; + }>, + ): void { + this.queuedUiToolResults.push(...results); + } + + private dispatchQueuedUiToolResults(): void { + const queued = this.queuedUiToolResults; + this.queuedUiToolResults = []; + for (const result of queued) { + const tool = this.options.tools?.find( + (candidate) => isClientTool(candidate) && candidate.function.name === result.name, + ); + if (tool) { + this.dispatchUiFragment({ + toolCall: { + id: result.callId, + name: result.name, + arguments: result.input, + } as ParsedToolCall, + tool, + result: { + result: result.output, + }, + }); + } + } + } + private dispatchUiFragment(value: { toolCall: ParsedToolCall; tool: Tool; @@ -6945,9 +6992,8 @@ export class ModelResult< */ getUiStream(): AsyncIterableIterator { return async function* (this: ModelResult) { - await this.initStreamGuarded(); - if (!this.options.tools?.length) { + await this.initStreamGuarded(); let streamFailed = false; try { if (this.reusableStream) { @@ -6973,6 +7019,8 @@ export class ModelResult< const uiBroadcaster = this.uiBroadcaster; const uiConsumer = uiBroadcaster.createConsumer(); try { + this.dispatchQueuedUiToolResults(); + await this.initStreamGuarded(); const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); if (!this.uiBroadcasterCompletionPromise) { this.uiBroadcasterCompletionPromise = executionPromise.finally(async () => { diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index c5c8b41d..e37efe02 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1622,6 +1622,8 @@ export interface PendingAsyncTool { status: ToolTaskStatus; /** Unix ms when the task started. */ startedAt: number; + /** The original call arguments, retained for deferred UI rendering. */ + input?: Record; /** Unix ms after which the task is considered expired. */ expiresAt?: number; /** Poll-interval hint surfaced to the model and external pollers. */ diff --git a/packages/agent/tests/unit/async-tool-deferred.test.ts b/packages/agent/tests/unit/async-tool-deferred.test.ts index 2a115bf8..257604df 100644 --- a/packages/agent/tests/unit/async-tool-deferred.test.ts +++ b/packages/agent/tests/unit/async-tool-deferred.test.ts @@ -152,6 +152,9 @@ describe('tool.deferred — pause & placeholder', () => { name: 'request_legal_review', mode: 'defer', status: 'working', + input: { + contractId: 'c-9', + }, }); // No follow-up request — the loop paused after the placeholder. @@ -724,6 +727,9 @@ describe('tool.deferred — cross-process resume', () => { callId: 'call_d1', taskId: 'ticket_c-9', mode: 'defer', + input: { + contractId: 'c-9', + }, }); }); diff --git a/packages/agent/tests/unit/async-tool-registry.test.ts b/packages/agent/tests/unit/async-tool-registry.test.ts index aeecb148..6cb10e79 100644 --- a/packages/agent/tests/unit/async-tool-registry.test.ts +++ b/packages/agent/tests/unit/async-tool-registry.test.ts @@ -84,6 +84,35 @@ describe('AsyncToolRegistry — timeout settlement', () => { }); }); +describe('AsyncToolRegistry — deferred input', () => { + it('retains input in settlement and persistence snapshots', () => { + const registry = new AsyncToolRegistry(); + const task = registry.trackDeferred({ + callId: 'call_d1', + taskId: 'task_d1', + name: 'weather', + input: { + city: 'Lisbon', + }, + }); + + expect(task.input).toEqual({ + city: 'Lisbon', + }); + expect(registry.snapshot()[0]).toMatchObject({ + input: { + city: 'Lisbon', + }, + }); + registry.cancelTask('task_d1'); + expect(registry.takeSettled()[0]).toMatchObject({ + input: { + city: 'Lisbon', + }, + }); + }); +}); + describe('AsyncToolRegistry — grace-window visibility (register/untrack)', () => { it('a registered (not yet tracked) task is reachable by steer and cancel', () => { const registry = new AsyncToolRegistry(); diff --git a/packages/agent/tests/unit/openui-stream.test.ts b/packages/agent/tests/unit/openui-stream.test.ts index afaea68f..919388c2 100644 --- a/packages/agent/tests/unit/openui-stream.test.ts +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -10,13 +10,19 @@ import { StreamEvents$inboundSchema } from '@openrouter/sdk/models/streamevents' import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { callModel } from '../../src/inner-loop/call-model.js'; +import { resumeToolResults } from '../../src/inner-loop/resume-tool-results.js'; import { ModelResult } from '../../src/lib/model-result.js'; import { fragment } from '../../src/lib/openui/fragment.js'; import { createLibrary, defineComponent } from '../../src/lib/openui/library.js'; import { translateUiEvent } from '../../src/lib/openui/ui-stream.js'; import { ReusableReadableStream } from '../../src/lib/reusable-stream.js'; import { tool } from '../../src/lib/tool.js'; -import type { ParsedToolCall, Tool } from '../../src/lib/tool-types.js'; +import type { + ConversationState, + ParsedToolCall, + StateAccessor, + Tool, +} from '../../src/lib/tool-types.js'; import { isToolUiFragmentEvent } from '../../src/lib/tool-types.js'; const mockBetaResponsesSend = vi.hoisted(() => vi.fn()); @@ -996,38 +1002,48 @@ describe('async tool settlement', () => { }); describe('late async tool UI settlement', () => { - it('skips rendering when deferred settlement has no retained input', async () => { - const toUiOutput = vi.fn(() => ui.Text('never')); - const deferred = tool({ - name: 'deferred_ui', - lifecycle: 'deferred', - inputSchema: z.object({ - city: z.string(), - }), - outputSchema: z.object({ - summary: z.string(), - }), - run: () => ({ - taskId: 'task_1', - }), - toUiOutput, - }); + const deferred = tool({ + name: 'deferred_ui', + lifecycle: 'deferred', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + summary: z.string(), + }), + run: () => ({ + taskId: 'task_1', + }), + toUiOutput: ({ input, output }) => ui.Text(`${input.city}: ${output.summary}`), + }); + + function makeSettlementHarness(input?: Record) { const result = new ModelResult({ request: { model: 'test-model', input: 'test', - tools: [ - deferred, - ], }, + tools: [ + deferred, + ], client: {} as OpenRouterCore, }); + const pushed: unknown[] = []; const internal = result as unknown as { asyncToolRegistry: { takeSettled: () => Array>; }; + uiBroadcaster: { + activeConsumerCount: number; + push: (event: unknown) => void; + }; flushAsyncToolDeliveries: () => Promise; injectAppendPromptMessage: () => Promise; + drainUiFragments: () => Promise; + }; + internal.uiBroadcaster = { + activeConsumerCount: 1, + push: (event) => pushed.push(event), }; internal.asyncToolRegistry = { takeSettled: () => [ @@ -1039,15 +1055,130 @@ describe('late async tool UI settlement', () => { result: { summary: 'Clear', }, + ...(input !== undefined && { + input, + }), durationMs: 1, }, ], }; internal.injectAppendPromptMessage = async () => undefined; + return { + internal, + pushed, + }; + } + + it('renders same-run deferred settlement with retained input', async () => { + const { internal, pushed } = makeSettlementHarness({ + city: 'Lisbon', + }); await internal.flushAsyncToolDeliveries(); + await internal.drainUiFragments(); - expect(toUiOutput).not.toHaveBeenCalled(); + expect(pushed).toContainEqual( + expect.objectContaining({ + type: 'tool.ui_fragment', + toolCallId: 'c1', + fragment: { + dialect: 'openui-lang/0.5', + source: 'root = Text("Lisbon: Clear")', + }, + }), + ); + }); + + it('renders externally resumed deferred settlement through the UI stream', async () => { + mockBetaResponsesSend.mockReset(); + mockBetaResponsesSend.mockResolvedValueOnce({ + ok: true, + value: response('r2', [ + { + type: 'message', + id: 'm1', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + annotations: [], + }, + ], + }, + ]), + }); + let state: ConversationState = { + id: 'conversation_1', + messages: [], + status: 'awaiting_async_tools', + pendingAsyncTools: [ + { + callId: 'c1', + taskId: 'task_1', + name: 'deferred_ui', + mode: 'defer', + status: 'working', + startedAt: Date.now(), + input: { + city: 'Lisbon', + }, + }, + ], + }; + const accessor: StateAccessor = { + load: async () => state, + save: async (next) => { + state = next; + }, + }; + + const result = await resumeToolResults( + { + _options: {}, + } as OpenRouterCore, + { + state: accessor, + tools: [ + deferred, + ] as const, + results: [ + { + taskId: 'task_1', + output: { + summary: 'Clear', + }, + }, + ], + run: { + model: 'test-model', + }, + }, + ); + const events: unknown[] = []; + if (result) { + for await (const event of result.getUiStream()) { + events.push(event); + } + } + + expect(events).toContainEqual({ + type: 'fragment', + toolCallId: 'c1', + toolName: 'deferred_ui', + dialect: 'openui-lang/0.5', + source: 'root = Text("Lisbon: Clear")', + }); + }); + + it('skips legacy deferred settlement without retained input', async () => { + const { internal, pushed } = makeSettlementHarness(); + + await internal.flushAsyncToolDeliveries(); + await internal.drainUiFragments(); + + expect(pushed).toEqual([]); }); }); From 2b84b102507800e0cabef9395e960db049a83a50 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:55:45 +0000 Subject: [PATCH 20/20] fix(agent): rebuild openui() wire payload on every call The WeakMap cache assumed UiLibrary was immutable, but the library and its component definitions are plain mutable objects. Later mutations reached fragment() but never the plugin payload sent to the API. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/agent/src/lib/openui/plugin.ts | 25 ++++--------- packages/agent/tests/unit/openui.test.ts | 45 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/agent/src/lib/openui/plugin.ts b/packages/agent/src/lib/openui/plugin.ts index 2745f8c8..8957d739 100644 --- a/packages/agent/src/lib/openui/plugin.ts +++ b/packages/agent/src/lib/openui/plugin.ts @@ -29,17 +29,16 @@ export interface OpenUiPlugin { dialect?: string; } -/* - * Libraries are immutable after `createLibrary`, so the wire shape (including - * the Zod→JSON-Schema conversion per component) is computed once per library - * rather than once per request — `plugins: [openui(library)]` inline per call - * is the documented usage. - */ -const wireCache = new WeakMap(); - /** * Build the `openui` plugin preference from a component library. * + * The wire shape is rebuilt on every call. `UiLibrary` and its component + * definitions are plain mutable objects, so caching the first payload would + * keep sending a stale library after a mutation while `fragment(library)` + * already sees the new one. The Zod-to-JSON-Schema conversion is cheap + * next to a model request. Hoist `openui(library)` out of the call site if + * you want to reuse one payload. + * * @example * ```typescript * const result = callModel(client, { @@ -50,16 +49,6 @@ const wireCache = new WeakMap(); * ``` */ export function openui(library: UiLibrary): OpenUiPlugin { - const cached = wireCache.get(library); - if (cached) { - return cached; - } - const plugin = buildPlugin(library); - wireCache.set(library, plugin); - return plugin; -} - -function buildPlugin(library: UiLibrary): OpenUiPlugin { return { id: 'openui', library: [ diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts index 49539f99..19d982a1 100644 --- a/packages/agent/tests/unit/openui.test.ts +++ b/packages/agent/tests/unit/openui.test.ts @@ -397,4 +397,49 @@ describe('openui() plugin helper', () => { expect(divider?.props).toBeUndefined(); expect(divider?.description).toBeUndefined(); }); + + it('reflects library mutations made after an earlier call', () => { + const mutable = createLibrary( + [ + defineComponent({ + name: 'Note', + props: z.object({ + text: z.string(), + }), + }), + ], + { + dialect: 'first', + }, + ); + const first = openui(mutable); + + mutable.dialect = 'second'; + const note = mutable.components.get('Note'); + if (note) { + note.description = 'Added later'; + note.props = z.object({ + text: z.string(), + tone: z.string(), + }); + } + + const second = openui(mutable); + expect(second).not.toBe(first); + expect(second.dialect).toBe('second'); + expect(second.library[0]?.description).toBe('Added later'); + expect( + Object.keys( + ( + second.library[0]?.props as { + properties: object; + } + ).properties, + ), + ).toEqual([ + 'text', + 'tone', + ]); + expect(first.dialect).toBe('first'); + }); });