Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
383c282
feat(agent): OpenUI library model, fragment builder, and plugin helpe…
LukasParke Jul 31, 2026
a3347b5
feat(agent): toUIOutput on tool(), tool.ui_fragment events, and getUi…
LukasParke Jul 31, 2026
978cab9
feat(playground): OpenUI test/bench/eval webapp (DEV-773)
LukasParke Jul 31, 2026
69bfb14
fix(openui): quote non-identifier object keys, guard non-finite numbers
LukasParke Aug 3, 2026
3720fde
refactor: clear the structural gate — god file and 4 complex functions
LukasParke Aug 3, 2026
e5759d3
docs(agent): list getUiStream in the README stream table
LukasParke Aug 3, 2026
4eb82c7
Merge remote-tracking branch 'origin/main' into lukeparke/dev-773-typ…
LukasParke Aug 3, 2026
b388f35
fix(playground): escape diagnostic messages; label rendered controls
LukasParke Aug 4, 2026
37f816d
fix(openui): address cortex review — security, a11y, perf
LukasParke Aug 4, 2026
6898806
Merge branch 'lukeparke/dev-773-typescript-agent-openui-module-librar…
LukasParke Aug 4, 2026
20ab446
Merge branch 'main' into lukeparke/dev-773-typescript-agent-openui-mo…
synapse-github-agent[bot] Aug 4, 2026
20c0e5c
Merge remote-tracking branch 'origin/main' into wt/pr92
LukasParke Aug 11, 2026
3d4a582
fix(openui): address review findings
LukasParke Aug 11, 2026
3cefbdc
refactor(openui): simplify native diagnostics counting
LukasParke Aug 11, 2026
829a94c
fix(agent): bound OpenUI fragment rendering
LukasParke Aug 11, 2026
6701156
fix(openui): finish review feedback
LukasParke Aug 11, 2026
ff70982
fix(openui): release timed-out UI renders
LukasParke Aug 11, 2026
e956e1e
fix(openui): drain renders added before close
LukasParke Aug 11, 2026
0f7eb50
fix(agent): drain UI fragments before stream completion
LukasParke Aug 11, 2026
bccd1ad
fix(agent): isolate UI stream lifecycle
LukasParke Aug 11, 2026
cbfd884
fix(agent): release exited UI consumers
LukasParke Aug 11, 2026
1f68c5e
fix(agent): merge OpenUI event streams
LukasParke Aug 11, 2026
11eac75
fix(agent): render deferred tool UI results
LukasParke Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,41 @@ export {
buildNextTurnParamsContext,
executeNextTurnParamsFunctions,
} from './lib/next-turn-params.js';
export type {
ComponentDefinition,
CreateLibraryOptions,
FragmentArg,
FragmentBuilder,
FragmentNode,
OpenUiPlugin,
OpenUiWireComponent,
PropSignature,
UiDocumentEvent,
UiExpr,
UiFragment,
UiFragmentEvent,
UiLibrary,
UiLiteralValue,
UiStatementEvent,
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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';
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
// Stop condition helpers
export {
finishReasonIs,
Expand Down Expand Up @@ -248,8 +283,10 @@ export type {
ToolResultEvent,
ToolResultItem,
ToolStreamEvent,
ToolUiFragmentEvent,
ToolWithExecute,
ToolWithGenerator,
ToUIOutputFunction,
TurnContext,
TurnEndEvent,
TurnStartEvent,
Expand All @@ -272,6 +309,7 @@ export {
isToolCallOutputEvent,
isToolPreliminaryResultEvent,
isToolResultEvent,
isToolUiFragmentEvent,
isTurnEndEvent,
isTurnStartEvent,
ToolType,
Expand Down
100 changes: 100 additions & 0 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
applyNextTurnParamsToRequest,
executeNextTurnParamsFunctions,
} from './next-turn-params.js';
import type { UiStreamEvent } from './openui/ui-stream.js';
import { translateUiEvent } from './openui/ui-stream.js';
import { ReusableReadableStream } from './reusable-stream.js';
import { isStopConditionMet } from './stop-conditions.js';
import type { ItemInProgress, StreamableOutputItem } from './stream-transformers.js';
Expand Down Expand Up @@ -78,6 +80,7 @@ import type {
ToolContextMapWithShared,
ToolResultItem,
ToolStreamEvent,
ToolUiFragmentEvent,
TurnContext,
TurnEndEvent,
TurnStartEvent,
Expand Down Expand Up @@ -2670,6 +2673,8 @@ export class ModelResult<
output: executedOutput,
timestamp: Date.now(),
} satisfies ToolCallOutputEvent);

await this.broadcastUiFragment(value);
Comment thread
LukasParke marked this conversation as resolved.
Outdated
}

return {
Expand All @@ -2678,6 +2683,54 @@ export class ModelResult<
};
}

/**
Comment thread
LukasParke marked this conversation as resolved.
* 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: Tool;
result: {
result: unknown;
error?: Error;
};
}): Promise<void> {
if (
value.result.error ||
!isAutoResolvableTool(value.tool) ||
!value.tool.function.toUIOutput
) {
return;
}
const rawArgs: unknown = value.toolCall.arguments;
if (!isRecord(rawArgs)) {
return;
}
try {
const fragment = await value.tool.function.toUIOutput({
output: value.result.result,
input: rawArgs,
});
if (!fragment) {
return;
}
this.turnBroadcaster?.push({
type: 'tool.ui_fragment' as const,
toolCallId: value.toolCall.id,
toolName: value.toolCall.name,
fragment: {
dialect: fragment.dialect,
source: fragment.source,
},
timestamp: Date.now(),
} satisfies ToolUiFragmentEvent);
} catch {
// Fragment construction failed — drop it; rendering is best-effort.
}
}

/**
* Resolve async functions for the current turn.
* Updates the resolved request with turn-specific parameter values.
Expand Down Expand Up @@ -4592,6 +4645,53 @@ export class ModelResult<
}.call(this);
}

/**
* Stream OpenUI events from all turns: completed OpenUI Lang statements
* authored by the model (`response.openui.*` wire events from the `openui`
* plugin) and tool-authored fragments (`tool.ui_fragment` events produced
* by tools declaring `toUIOutput`).
*
* Wire events not yet in the SDK's stream-event union arrive through its
* forward-compat catch-all; translation reads the raw payload, so this
* stream works both before and after the SDK regen picks them up.
*/
getUiStream(): AsyncIterableIterator<UiStreamEvent> {
Comment thread
LukasParke marked this conversation as resolved.
Comment thread
LukasParke marked this conversation as resolved.
return async function* (this: ModelResult<TTools, TShared>) {
await this.initStreamGuarded();

if (!this.options.tools?.length) {
let streamFailed = false;
try {
if (this.reusableStream) {
for await (const event of this.reusableStream.createConsumer()) {
const uiEvent = translateUiEvent(event);
if (uiEvent) {
yield uiEvent;
}
}
}
} catch (error) {
streamFailed = true;
throw error;
} finally {
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
}
return;
}

const { consumer, executionPromise } = this.startTurnBroadcasterExecution();

for await (const event of consumer) {
const uiEvent = translateUiEvent(event);
if (uiEvent) {
yield uiEvent;
}
}

await executionPromise;
}.call(this);
}

/**
* Stream tool call argument deltas and preliminary results from all turns.
* Preliminary results are streamed in REAL-TIME as generator tools yield.
Expand Down
84 changes: 84 additions & 0 deletions packages/agent/src/lib/openui/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* OpenUI Lang expression model + serialization.
*
* OpenUI Lang is a line-oriented assignment language: one statement per line,
* `name = expression`. The SDK only *authors* OpenUI Lang (tool-authored
* fragments, wire-format libraries) — parsing, validation, and prompt
* injection are API-side responsibilities. This module is therefore the
* minimal expression tree and serializer shared by the fragment builder.
*/

/** The OpenUI Lang dialect this package emits. */
export const OPENUI_LANG_DIALECT = 'openui-lang/0.5';

/** The reserved assignment ref that designates the document root. */
export const OPENUI_ROOT_REF = 'root';

export type UiLiteralValue = string | number | boolean | null;

/** Expression tree for one assignment's right-hand side. */
export type UiExpr =
| {
kind: 'literal';
value: UiLiteralValue;
}
| {
kind: 'ref';
name: string;
}
| {
kind: 'state-ref';
name: string;
}
| {
kind: 'member';
base: UiExpr;
path: string[];
}
| {
kind: 'array';
items: UiExpr[];
}
| {
kind: 'object';
entries: Array<{
key: string;
value: UiExpr;
}>;
}
| {
kind: 'call';
fn: string;
builtin: boolean;
args: UiExpr[];
};

/**
* A renderable piece of UI: the dialect it's expressed in plus its serialized
* OpenUI Lang source. This is the shape carried on `tool.ui_fragment` stream
* events and (for server tools) `response.openui.fragment` wire events.
*/
export interface UiFragment {
dialect: string;
source: string;
}

/** Serialize an expression to OpenUI Lang source. */
export function serializeExpr(expr: UiExpr): string {
switch (expr.kind) {
case 'literal':
return typeof expr.value === 'string' ? JSON.stringify(expr.value) : String(expr.value);
case 'ref':
return expr.name;
case 'state-ref':
return `$${expr.name}`;
case 'member':
return `${serializeExpr(expr.base)}.${expr.path.join('.')}`;
case 'array':
return `[${expr.items.map(serializeExpr).join(', ')}]`;
case 'object':
return `{${expr.entries.map((e) => `${e.key}: ${serializeExpr(e.value)}`).join(', ')}}`;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
case 'call':
return `${expr.builtin ? '@' : ''}${expr.fn}(${expr.args.map(serializeExpr).join(', ')})`;
}
}
Loading
Loading