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/19] 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/19] 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/19] 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/19] 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/19] =?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/19] 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/19] 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/19] =?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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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([]); }); });