diff --git a/.changeset/openui-bindings.md b/.changeset/openui-bindings.md new file mode 100644 index 00000000..39dae3d1 --- /dev/null +++ b/.changeset/openui-bindings.md @@ -0,0 +1,74 @@ +--- +'@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: [ + openui(library), + ], +}); + +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/README.md b/packages/agent/README.md index 32f7abfe..bb385373 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -114,6 +114,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, …) — output items **only**, no usage/response metadata | +| `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, and each round's `response.completed` (with that round's usage block) | #### Usage across a multi-round tool loop @@ -150,7 +151,6 @@ Same `SessionUsageTotals` shape and numbers as the `SessionEnd` hook's `totalUsage`. For **per-call** granularity use the `PostModelCall` hook (one emit per model call, with `turnType`/`turnNumber`) or read each round's `response.completed` off `getFullResponsesStream()`. - ### Tool Types The `tool()` factory creates type-safe tools with full Zod schema inference. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents). diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3b612660..9f3e933c 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -201,6 +201,42 @@ export { buildNextTurnParamsContext, executeNextTurnParamsFunctions, } from './lib/next-turn-params.js'; +export type { + ComponentDefinition, + CreateLibraryOptions, + FragmentArg, + FragmentBuilder, + FragmentNode, + OpenUiPlugin, + OpenUiWireComponent, + PropSignature, + UiBuiltinOptions, + UiDocumentEvent, + UiExpr, + UiFragment, + UiFragmentEvent, + UiLibrary, + UiLiteralValue, + UiStatementEvent, + UiStreamEvent, +} 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_WIRE_EVENT, + openui, + serializeExpr, + translateUiEvent, + uiBuiltin, + uiRef, + uiState, +} from './lib/openui/index.js'; // Stop condition helpers export { finishReasonIs, @@ -294,8 +330,10 @@ export type { ToolStreamEvent, ToolTaskHandle, ToolTaskStatus, + ToolUiFragmentEvent, ToolWithExecute, ToolWithGenerator, + ToUiOutputFunction, TurnContext, TurnEndEvent, TurnStartEvent, @@ -325,6 +363,7 @@ export { isToolCallOutputEvent, isToolPreliminaryResultEvent, isToolResultEvent, + isToolUiFragmentEvent, isTurnEndEvent, isTurnStartEvent, isUnifiedTool, 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-params.ts b/packages/agent/src/lib/async-params.ts index 9cabd7c1..300afb7f 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -4,6 +4,7 @@ import type { DoomLoopOption } from './doom-loop.js'; import type { HooksManager } from './hooks-manager.js'; import type { InlineHookConfig } from './hooks-types.js'; import type { Item } from './item-types.js'; +import type { OpenUiPlugin } from './openui/plugin.js'; import type { ContextInput } from './tool-context.js'; import type { ParsedToolCall, @@ -56,11 +57,16 @@ type BaseCallModelInput< TTools extends readonly Tool[] = readonly Tool[], TShared extends Record = Record, > = { - [K in keyof Omit]?: FieldOrAsyncFunction< - models.ResponsesRequest[K] - >; + [K in keyof Omit< + models.ResponsesRequest, + 'stream' | 'tools' | 'input' | 'plugins' + >]?: FieldOrAsyncFunction; } & { input: FieldOrAsyncFunction | string; + /** Responses plugins, including the OpenUI binding pending SDK schema regeneration. */ + plugins?: FieldOrAsyncFunction< + Array[number] | OpenUiPlugin> + >; tools?: TTools; stopWhen?: StopWhen; /** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */ 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 e8d07757..87ab4910 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -55,7 +55,7 @@ import { } from './next-turn-params.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, @@ -71,6 +71,7 @@ import { itemsStreamHandlers, responseHasToolCalls, streamTerminationEvents, + translateUiEvent, tryExtractCompletionFromBuffer, } from './stream-transformers.js'; import { @@ -112,6 +113,7 @@ import type { ToolContextMapWithShared, ToolResultItem, ToolStreamEvent, + ToolUiFragmentEvent, TurnContext, TurnEndEvent, TurnStartEvent, @@ -256,6 +258,85 @@ function extractServerToolIdentity(item: ServerToolResultItem): Record = + | { + 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 @@ -624,6 +705,16 @@ export class ModelResult< private turnBroadcaster: ToolEventBroadcaster< ResponseStreamEvent, InferToolOutputsUnion> > | 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; private initialPipePromise: Promise | null = null; @@ -1025,19 +1116,18 @@ 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; - } - broadcaster.complete(); - }); + if (!this.turnBroadcasterCompletionPromise) { + this.turnBroadcasterCompletionPromise = this.executeToolsIfNeeded().finally(async () => { + // Preserve turn.end, but never couple non-UI stream completion to UI rendering. + if (this.initialPipePromise) { + await this.initialPipePromise; + } + broadcaster.complete(); + }); + } return { consumer, - executionPromise, + executionPromise: this.turnBroadcasterCompletionPromise, }; } @@ -2691,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, }), @@ -2709,6 +2801,7 @@ export class ModelResult< mode: 'defer', status: 'working', startedAt: liveTask.startedAt, + input, ...(invocation.pollAfterMs !== undefined && { pollAfterMs: invocation.pollAfterMs, }), @@ -3089,6 +3182,22 @@ export class ModelResult< this.toolSourceByName(task.name), task.result as InferToolOutputsUnion, ); + const tool = this.options.tools?.find( + (candidate) => isClientTool(candidate) && candidate.function.name === task.name, + ); + if (tool && task.input !== undefined) { + this.dispatchUiFragment({ + toolCall: { + id: task.callId, + name: task.name, + arguments: task.input, + } as ParsedToolCall, + tool, + result: { + result: task.result, + }, + }); + } } // PostToolUse/PostToolUseFailure fire at SETTLEMENT for async tools — // the observation-only audit surface (secret scanning, output review) @@ -3533,10 +3642,7 @@ export class ModelResult< // Start ALL async invocations before consuming any outcome: the work // (and its grace window) begins in handleAsyncInvocation, so awaiting - // it inside the ordered loop below would serialize N background calls - // into (N-1)×graceMs of stagger. Kicked off here in parallel; the - // ordered loop awaits the per-call promise, so OUTPUT order stays call - // order (prompt-cache stability). + // it inside the ordered loop below would serialize background calls. const asyncOutcomes = new Map< number, Promise<{ @@ -3658,6 +3764,8 @@ export class ModelResult< output: executedOutput, timestamp: Date.now(), } satisfies ToolCallOutputEvent); + + this.dispatchUiFragment(value); } return { @@ -3698,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, }), @@ -3717,6 +3827,7 @@ export class ModelResult< mode: 'defer', status: 'working', startedAt: liveTask.startedAt, + input, ...(invocation.pollAfterMs !== undefined && { pollAfterMs: invocation.pollAfterMs, }), @@ -3810,13 +3921,15 @@ export class ModelResult< source, settled.result as InferToolOutputsUnion, ); - const outputForModel = await this.computeToolOutputForModel({ + const settledValue = { toolCall, tool, result: { result: settled.result, }, - }); + }; + const outputForModel = await this.computeToolOutputForModel(settledValue); + this.dispatchUiFragment(settledValue); return { output: { type: 'function_call_output' as const, @@ -4321,6 +4434,153 @@ 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; + result: { + result: unknown; + error?: Error; + }; + }): void { + if (!this.uiBroadcaster?.activeConsumerCount) { + return; + } + const rendering = this.broadcastUiFragment(value); + this.pendingUiFragments.add(rendering); + rendering.finally(() => this.pendingUiFragments.delete(rendering)); + } + + private async drainUiFragments(): Promise { + if (this.pendingUiFragments.size === 0) { + return; + } + const timeoutMs = DEFAULT_UI_DRAIN_TIMEOUT_MS; + 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 { + 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); + } + } + } + + /** + * 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; + } + if (!this.uiBroadcaster?.activeConsumerCount) { + return; + } + this.uiBroadcaster.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 (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, + ); + } + } + /** * Resolve async functions for the current turn. * Updates the resolved request with turn-specific parameter values. @@ -6720,6 +6980,75 @@ 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) { + if (!this.options.tools?.length) { + await this.initStreamGuarded(); + 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; + } + + if (!this.uiBroadcaster) { + this.uiBroadcaster = new ToolEventBroadcaster(); + } + 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 () => { + await this.drainUiFragments(); + uiBroadcaster.complete(); + }); + } + + for await (const event of mergeAsyncIterators([ + consumer, + uiConsumer, + ])) { + const uiEvent = translateUiEvent(event); + if (uiEvent) { + yield uiEvent; + } + } + + await this.uiBroadcasterCompletionPromise; + } finally { + await uiConsumer.return?.(); + if (uiBroadcaster.activeConsumerCount === 0) { + this.pendingUiFragments.clear(); + } + } + }.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/document.ts b/packages/agent/src/lib/openui/document.ts new file mode 100644 index 00000000..7a496fef --- /dev/null +++ b/packages/agent/src/lib/openui/document.ts @@ -0,0 +1,114 @@ +/** + * 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; +} + +/** + * 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': + 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': + 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) => `${serializeKey(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..fdd1e19e --- /dev/null +++ b/packages/agent/src/lib/openui/fragment.ts @@ -0,0 +1,188 @@ +/** + * 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 { assertIdent, componentProps, OPENUI_BUILTIN_COMPONENTS } 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 = + | undefined + | 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).flatMap(([key, value]) => + value === undefined + ? [] + : [ + { + key, + value: toExpr(value), + }, + ], + ), + }; + } + if (arg === undefined) { + return { + kind: 'literal', + value: null, + }; + } + 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: assertIdent('uiRef', 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: assertIdent('uiState', name), + }); +} + +/** 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; +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, + args: args.map(toExpr), + }); +} + +/** One constructor per component: builds a validated fragment node. */ +export type FragmentBuilder = Record< + N | (typeof OPENUI_BUILTIN_COMPONENTS)[number], + (...args: FragmentArg[]) => 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 (arg !== undefined && 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, + }); + }; + } + for (const name of OPENUI_BUILTIN_COMPONENTS) { + builder[name] ??= (...args: FragmentArg[]) => + makeNode(library.dialect, { + kind: 'call', + fn: name, + builtin: false, + args: args.map(toExpr), + }); + } + 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..d18646b8 --- /dev/null +++ b/packages/agent/src/lib/openui/index.ts @@ -0,0 +1,45 @@ +/** + * 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, + type UiBuiltinOptions, + 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'; +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/library.ts b/packages/agent/src/lib/openui/library.ts new file mode 100644 index 00000000..338b9e9e --- /dev/null +++ b/packages/agent/src/lib/openui/library.ts @@ -0,0 +1,104 @@ +/** + * 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; +} + +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; +} + +/** + * 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) { + assertIdent('component', def.name); + 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..2745f8c8 --- /dev/null +++ b/packages/agent/src/lib/openui/plugin.ts @@ -0,0 +1,81 @@ +/** + * 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; +} + +/* + * 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. + * + * @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 { + 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: [ + ...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, 'input'); + } + return component; + }), + dialect: library.dialect, + }; +} 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..6d8910cb --- /dev/null +++ b/packages/agent/src/lib/openui/ui-stream.ts @@ -0,0 +1,214 @@ +/** + * 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). + */ +/** `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; +} + +/** `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, + }; +} + +/** `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; +} + +/** 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 []; + } + 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 ff00c567..7848cf95 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, @@ -1306,3 +1308,14 @@ export function getUnsupportedContentSummary(message: ClaudeMessage): Record { 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,12 +45,8 @@ export class ToolEventBroadcaster { queueMicrotask(() => this.cleanup()); } - /** - * Clean up resources after all consumers have finished. - * Called automatically after complete(), but can be called manually. - */ + /** Release completed history once no consumer can read it. */ private cleanup(): void { - // Only cleanup if complete and all consumers are done if (this.isComplete && this.consumers.size === 0) { this.buffer = []; } @@ -132,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(); } @@ -145,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/src/lib/tool-executor.ts b/packages/agent/src/lib/tool-executor.ts index 5ccdc7b7..d8392156 100644 --- a/packages/agent/src/lib/tool-executor.ts +++ b/packages/agent/src/lib/tool-executor.ts @@ -94,13 +94,17 @@ function isZodSchema(value: unknown): value is z4.ZodType { * The resulting schema is sanitized to remove metadata properties (like ~standard) * that would cause 400 errors with downstream providers. */ -export function convertZodToJsonSchema(zodSchema: $ZodType): Record { +export function convertZodToJsonSchema( + zodSchema: $ZodType, + io: 'input' | 'output' = 'output', +): Record { if (!isZodSchema(zodSchema)) { throw new Error('Invalid Zod schema provided'); } // Use draft-7 as it's closest to OpenAPI 3.0's JSON Schema variant const jsonSchema = z4.toJSONSchema(zodSchema, { target: 'draft-7', + io, }); // jsonSchema is always a Record from toJSONSchema // The overloaded sanitizeJsonSchema preserves this type diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 0ea4140d..e37efe02 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'; import type { TaskLogLimits, ToolTaskMode, ToolTaskStatus } from './tool-task.js'; /** @@ -414,6 +415,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 @@ -509,6 +526,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>; } /** @@ -550,6 +569,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>; } /** @@ -599,6 +620,8 @@ export interface HITLToolFunction< context?: ToolExecuteContext, ): Promise> | zodInfer; toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert tool execution output to a renderable OpenUI fragment */ + toUiOutput?: ToUiOutputFunction, zodInfer>; } /** @@ -726,6 +749,8 @@ export interface UnifiedToolFunction< * output verbatim. */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert a settled result to a renderable OpenUI fragment. */ + toUiOutput?: ToUiOutputFunction, zodInfer>; /** Absent on unified tools — keeps them disjoint from legacy kinds. */ readonly execute?: undefined; readonly onToolCalled?: undefined; @@ -1335,29 +1360,27 @@ export type ToolCallOutputEvent = { timestamp: number; }; -/** - * Emitted when an async tool call escapes the round: a background tool's - * execute outlived its grace window, or a deferred tool's start returned a - * task handle. The model has received a pending placeholder output. - */ +/** Tool-authored OpenUI fragment. Client-render only; never sent to the model. */ +export type ToolUiFragmentEvent = { + type: 'tool.ui_fragment'; + toolCallId: string; + toolName: string; + fragment: UiFragment; + timestamp: number; +}; + +/** Emitted when an async tool call escapes the round. */ export type ToolAsyncStartedEvent = { type: 'tool.async_started'; toolCallId: string; toolName: string; taskId: string; mode: ToolTaskMode; - /** The model-facing acknowledgement carried in the placeholder, if any. */ ack?: unknown; timestamp: number; }; -/** - * Emitted when an async tool task settles — completed, failed, cancelled, - * timed out, or expired. `delivery` reports how (or whether) the outcome - * reached the model: `'injected'` into this run's conversation, - * `'pending_resume'` recorded on state for the next run, or `'dropped'` - * (run ended under `onRunEnd: 'detach'`). - */ +/** Emitted when an async tool task settles. */ export type ToolAsyncSettledEvent = { type: 'tool.async_settled'; toolCallId: string; @@ -1400,6 +1423,7 @@ export type ResponseStreamEvent = | ToolPreliminaryResultEvent | ToolResultEvent | ToolCallOutputEvent + | ToolUiFragmentEvent | ToolAsyncStartedEvent | ToolAsyncSettledEvent | TurnStartEvent @@ -1448,6 +1472,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 */ @@ -1591,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/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 3731e676..7454db0a 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -28,6 +28,7 @@ import type { ToolRunContext, ToolWithExecute, ToolWithGenerator, + ToUiOutputFunction, UnifiedTool, } from './tool-types.js'; import { isClientTool, SHARED_CONTEXT_KEY, ToolType } from './tool-types.js'; @@ -68,6 +69,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>; }; /** @@ -102,6 +105,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>; }; /** @@ -137,6 +142,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>; }; /** @@ -215,6 +222,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>; }; /** @@ -253,6 +262,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>; }; /** @@ -318,6 +329,8 @@ type RunToolConfigWithOutput< | AsyncGenerator, zodInfer | DeferredHandle>>; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, zodInfer>; + /** Convert a settled result to a renderable OpenUI fragment. */ + toUiOutput?: ToUiOutputFunction, zodInfer>; }; /** @@ -342,6 +355,8 @@ type SyncRunToolConfigWithoutOutput< ) => Promise | TReturn | AsyncGenerator, TReturn>; /** Convert tool execution output to model-facing output */ toModelOutput?: ToModelOutputFunction, TReturn>; + /** Convert a settled result to a renderable OpenUI fragment. */ + toUiOutput?: ToUiOutputFunction, TReturn>; }; //#endregion @@ -595,6 +610,10 @@ export function tool( fn.toModelOutput = config.toModelOutput; } + if (config.toUiOutput !== undefined) { + fn.toUiOutput = config.toUiOutput; + } + return { type: ToolType.Function, function: fn, @@ -700,6 +719,10 @@ export function tool( fn.toModelOutput = config.toModelOutput; } + if ('toUiOutput' in config && config.toUiOutput !== undefined) { + fn.toUiOutput = config.toUiOutput; + } + if (config.strict !== undefined) { fn.strict = config.strict; } @@ -746,6 +769,10 @@ export function tool( config.toModelOutput !== undefined && { toModelOutput: config.toModelOutput, }), + ...('toUiOutput' in config && + config.toUiOutput !== undefined && { + toUiOutput: config.toUiOutput, + }), }; return { @@ -851,6 +878,7 @@ function assignCommonToolFields( 'timeoutMs', 'maxConcurrency', 'toModelOutput', + 'toUiOutput', 'eventSchema', 'ack', 'graceMs', 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 new file mode 100644 index 00000000..919388c2 --- /dev/null +++ b/packages/agent/tests/unit/openui-stream.test.ts @@ -0,0 +1,1439 @@ +/** + * 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 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'; +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 { + ConversationState, + ParsedToolCall, + StateAccessor, + Tool, +} from '../../src/lib/tool-types.js'; +import { isToolUiFragmentEvent } from '../../src/lib/tool-types.js'; + +const mockBetaResponsesSend = vi.hoisted(() => vi.fn()); + +vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({ + betaResponsesSend: mockBetaResponsesSend, +})); + +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); + +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({ + 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 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, + }); + expect(translateUiEvent(encoded)).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('toUiOutput round lifecycle', () => { + 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, + }); + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'test', + tools: [ + hanging, + ] as const, + }, + ); + + await expect(result.getText()).resolves.toBe('done'); + expect(toUiOutput).not.toHaveBeenCalled(); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + }); + + 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, + }, + }, + ); + + async function consumeUiStream() { + for await (const _event of result.getUiStream()) { + // No fragment is produced by the hanging renderer. + } + } + 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 () => { + 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('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('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('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'); + 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: 'async_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(); + let release: ((value: { summary: string }) => void) | undefined; + const gate = new Promise<{ + summary: string; + }>((resolve) => { + release = resolve; + }); + const weather = tool({ + name: 'weather', + lifecycle: 'background', + graceMs: 0, + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + summary: z.string(), + }), + run: () => gate, + toUiOutput: ({ input, output }) => ui.Card(`${input.city}: ${output.summary}`), + }); + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: response('r1', [ + { + type: 'function_call', + id: 'fc1', + callId: 'c1', + name: 'weather', + arguments: '{"city":"Lisbon"}', + status: 'completed', + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + value: response('r2', [ + { + type: 'message', + id: 'm1', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'waiting', + annotations: [], + }, + ], + }, + ]), + }) + .mockResolvedValueOnce({ + ok: true, + value: response('r3', [ + { + type: 'message', + id: 'm2', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + annotations: [], + }, + ], + }, + ]), + }); + + const result = callModel( + { + _options: {}, + } as OpenRouterCore, + { + model: 'test-model', + input: 'weather', + tools: [ + weather, + ] as const, + asyncTools: { + onRunEnd: 'drain', + }, + }, + ); + 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)); + release?.({ + summary: 'Clear', + }); + await consuming; + + expect(events).toContainEqual({ + type: 'fragment', + toolCallId: 'c1', + toolName: 'weather', + dialect: 'openui-lang/0.5', + source: 'root = Card("Lisbon: Clear")', + }); + }); +}); + +describe('late async tool UI settlement', () => { + 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, + ], + 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: () => [ + { + callId: 'c1', + taskId: 'task_1', + name: 'deferred_ui', + status: 'completed', + 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(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([]); + }); +}); + +describe('broadcastUiFragment', () => { + type Internal = { + uiBroadcaster: { + activeConsumerCount: number; + 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; + 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.uiBroadcaster = { + activeConsumerCount: 1, + 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('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(); + }; + + const events: unknown[] = []; + async function consumeUiStream() { + for await (const event of result.getUiStream()) { + events.push(event); + } + } + const consuming = consumeUiStream(); + + await drainStarted; + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + internal.dispatchUiFragment( + makeCall(later, { + result: 'later', + }), + ); + releaseInitial?.(); + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + toolName: 'initial_ui', + }), + ), + ); + 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 () => { + 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([]); + }); +}); diff --git a/packages/agent/tests/unit/openui.test.ts b/packages/agent/tests/unit/openui.test.ts new file mode 100644 index 00000000..49539f99 --- /dev/null +++ b/packages/agent/tests/unit/openui.test.ts @@ -0,0 +1,400 @@ +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'); + }); + + /* + * 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({ + 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('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(); + 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)])'); + expect(ui.Action([]).source).toBe('root = Action([])'); + 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', [ + node, + { + nested: [ + 1, + true, + null, + ], + } as never, + ]); + 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/); + }); + + 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 defaults = openui( + createLibrary([ + defineComponent({ + name: 'Defaults', + props: z.object({ + label: z.string().default('hello'), + }), + }), + ]), + ); + expect(defaults.library[0]?.props).toMatchObject({ + type: 'object', + }); + expect(defaults.library[0]?.props).not.toHaveProperty('required'); + + const divider = plugin.library[2]; + expect(divider?.props).toBeUndefined(); + expect(divider?.description).toBeUndefined(); + }); +}); diff --git a/packages/agent/tests/unit/tool-event-broadcaster.test.ts b/packages/agent/tests/unit/tool-event-broadcaster.test.ts index 290f13f6..1bd06641 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,10 +54,52 @@ 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); }); + + 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', () => { 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..fb54fd1b --- /dev/null +++ b/packages/openui-playground/package.json @@ -0,0 +1,25 @@ +{ + "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", + "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..e1218ea9 --- /dev/null +++ b/packages/openui-playground/public/app.js @@ -0,0 +1,626 @@ +/** + * 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. + */ + +import { escapeHtml, renderDiagnostic, resolveMember } from './render-utils.js'; + +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': + return resolveMember(evalExpr(expr.base, depth + 1), expr.path); + 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; +} + +/* + * Form controls and meters, split out of `renderCall` so neither function + * exceeds the structural gate's per-function complexity ceiling. `val` and + * `children` are passed in rather than recomputed — they close over `depth`. + */ +function renderControl(call, val) { + switch (call.fn) { + 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); + } + // 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); + 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}%`; + // 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)} (${value}%)`, '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 + } + 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); + + 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' : ''}`, [ + 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': + return renderTable(val); + 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) => + `${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 = + `${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); + } +} + +/* + * 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 = ''; + $('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)); + } + } + if (!streamErrored) { + 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 + // 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(renderDiagnostic) + .join(''); + } else { + $('diagnostics').innerHTML = + 'clean parse — no diagnostics'; + } + break; + } + case 'stats': + renderStats(event); + history.unshift(event); + renderHistory(); + break; + case 'error': + streamErrored = true; + setStatus(event.message, true); + break; + } +} + +$('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..6aac0969 --- /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/public/render-utils.js b/packages/openui-playground/public/render-utils.js new file mode 100644 index 00000000..f8cd06ae --- /dev/null +++ b/packages/openui-playground/public/render-utils.js @@ -0,0 +1,21 @@ +/** @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)};`); +} + +/** @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/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..5ba035ee --- /dev/null +++ b/packages/openui-playground/src/generate.ts @@ -0,0 +1,289 @@ +/** + * 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, 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'; +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']), + }; +} + +/* + * 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 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 { + 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. + */ +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 diagnostics = 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), + ], + }); + + 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; + } + diagnostics += countDiagnostics(event); + yield toPlaygroundEvent(event, Date.now() - start, statements); + } + + const usage = extractUsage(await result.getResponse()); + yield { + type: 'stats', + mode, + model: request.model, + ttfbMs, + firstStatementMs, + totalMs: Date.now() - start, + statements, + diagnostics, + 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..25c77012 --- /dev/null +++ b/packages/openui-playground/src/lang/parser.ts @@ -0,0 +1,591 @@ +/** + * 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. + */ +/** 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) { + if (ch === '\n' && state.depth <= 0 && !state.inString) { + flushStatement(state, completed); + continue; + } + state.buffer += ch; + if (state.inString) { + scanInString(state, ch); + continue; + } + if (ch === '"') { + state.inString = true; + continue; + } + scanDepth(state, ch); + } + 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 === '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; + } + } + + // 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 { + ExprParser.NUMBER_RE.lastIndex = this.pos; + const match = ExprParser.NUMBER_RE.exec(this.src); + if (!match) { + throw new ParseFailure('invalid number'); + } + this.pos += match[0].length; + return Number(match[0]); + } + + private parseIdent(): string { + 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)}'`); + } + 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 { + // 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; + } + } + } +} + +//#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..e1efb1bc --- /dev/null +++ b/packages/openui-playground/src/lang/prompt.ts @@ -0,0 +1,75 @@ +/** + * 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', + }); + /* + * 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; + } + 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(' | '); + } + } + } 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..95d81209 --- /dev/null +++ b/packages/openui-playground/src/server.ts @@ -0,0 +1,194 @@ +/** + * 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 } 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'; +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 file = resolveStaticPath(PUBLIC_DIR, urlPath); + if (!file) { + 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(); + } + }); +}); + +// 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/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/parser.test.ts b/packages/openui-playground/tests/parser.test.ts new file mode 100644 index 00000000..f3f1db8b --- /dev/null +++ b/packages/openui-playground/tests/parser.test.ts @@ -0,0 +1,185 @@ +import { serializeExpr } from '@openrouter/agent'; +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('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. + expect(doc.diagnostics.length + doc.order.length).toBeGreaterThan(0); + expect(parseDocument('x = = =').diagnostics).toHaveLength(1); + }); +}); 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'); + }); +}); diff --git a/packages/openui-playground/tests/security.test.ts b/packages/openui-playground/tests/security.test.ts new file mode 100644 index 00000000..d4b740cc --- /dev/null +++ b/packages/openui-playground/tests/security.test.ts @@ -0,0 +1,52 @@ +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { escapeHtml, renderDiagnostic, 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)'>&', + ); + }); + + 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', () => { + 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(); + }); +}); diff --git a/packages/openui-playground/tsconfig.json b/packages/openui-playground/tsconfig.json new file mode 100644 index 00000000..6e3a0dad --- /dev/null +++ b/packages/openui-playground/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "tests"], + "exclude": ["node_modules"] +} 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 8d118b7e..3c51eb78 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.12)) 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.12 + 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.12)) + packages: '@babel/helper-string-parser@7.29.7': @@ -207,156 +229,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + 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.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + 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.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + 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.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + 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.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + 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.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + 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.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + 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.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + 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.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + 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.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + 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.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + 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.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + 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.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + 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.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + 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.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + 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.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + 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.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + 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.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + 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.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + 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.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + 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.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + 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.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + 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.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + 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.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + 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.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + 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.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -694,6 +872,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1064,6 +1247,11 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.9.6: resolution: {integrity: sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg==} hasBin: true @@ -1395,81 +1583,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.27.4': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 @@ -1654,7 +1920,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.12)) '@vitest/expect@4.1.10': dependencies: @@ -1665,13 +1931,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.12))': 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.12) '@vitest/pretty-format@4.1.10': dependencies: @@ -1785,6 +2051,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + esprima@4.0.1: {} estree-walker@3.0.3: @@ -2109,6 +2404,12 @@ snapshots: tr46@0.0.3: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + turbo@2.9.6: optionalDependencies: '@turbo/darwin-64': 2.9.6 @@ -2124,7 +2425,7 @@ snapshots: universalify@0.1.2: {} - vite@7.3.1(@types/node@22.19.15): + vite@7.3.1(@types/node@22.19.15)(tsx@4.23.12): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -2135,11 +2436,12 @@ snapshots: optionalDependencies: '@types/node': 22.19.15 fsevents: 2.3.3 + tsx: 4.23.12 - 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.12)): 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.12)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2156,7 +2458,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.12) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.15