Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions docs/content/docs/api-reference/assistant-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ export function OpenUIRuntimeProvider({ runtime, children }) {

The default toolkit registers two standalone tools:

| Tool | assistant-ui type | Behavior |
| :--------------- | :---------------- | :--------------------------------------------------------------------------------------- |
| `present_openui` | `frontend` | Renders display-only OpenUI Lang and completes when the streamed `ui` argument is ready. |
| `prompt_openui` | `human` | Waits for an OpenUI `@ToAssistant` action, then submits its message and form state. |
| Tool | assistant-ui type | Behavior |
| :--------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `present_openui` | `frontend` | Renders a complete OpenUI Lang response. Follow-ups and optional list drill-down actions start new user turns; the tool completes when `ui` is ready. |
| `prompt_openui` | `human` | Waits for the required OpenUI `@ToAssistant` action, then submits its message and form state. |

## `createOpenUIIntegration(options)`

Expand Down Expand Up @@ -170,6 +170,9 @@ interface OpenUIToolUIOptions {
while `ui` is streaming, so the default error fallback appears only after streaming finishes. Set
`ErrorFallback: null` to suppress it.

`OpenUIPresent` appends `FollowUpBlock` clicks and optional `ListBlock` `@ToAssistant` actions to the
assistant-ui thread as new user messages. OpenUI `@OpenUrl` actions open in a new isolated tab.

`OpenUIPrompt` calls assistant-ui's `addResult` once for a terminal `@ToAssistant` action. Its result
contains the action type, message, parameters, optional form name, and form state. Replayed messages
use the saved form state as the renderer's initial state.
Expand Down
2 changes: 1 addition & 1 deletion packages/assistant-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ schemas, renderers, and instructions on the runtime's model-context client so

The default toolkit registers two standalone tools:

- `present_openui` is a frontend tool for display-only cards, tables, charts, and other interfaces. It completes as soon as the streamed `ui` argument is available.
- `present_openui` is a frontend tool for complete cards, tables, charts, and other interfaces. Follow-up suggestions start a new user turn automatically, and list items can use optional `@ToAssistant` actions for drill-downs. The tool completes as soon as the streamed `ui` argument is available.
- `prompt_openui` is a human tool for forms and choices. It completes only when an OpenUI `@ToAssistant(...)` action submits a result.

`openuiIntegration` contains a toolkit and instruction string created from the
Expand Down
2 changes: 1 addition & 1 deletion packages/assistant-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openuidev/assistant-ui",
"version": "0.0.2",
"version": "0.0.3",
"description": "OpenUI Lang tool renderers and instruction wiring for assistant-ui",
"license": "MIT",
"engines": {
Expand Down
126 changes: 125 additions & 1 deletion packages/assistant-ui/src/__tests__/renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OpenUIPrompt } from "../renderers";
import { OpenUIPresent, OpenUIPrompt } from "../renderers";

const { appendToThread } = vi.hoisted(() => ({ appendToThread: vi.fn() }));

vi.mock("@assistant-ui/react", () => ({
useAui: () => ({ thread: { append: appendToThread } }),
}));

const FORM = `root = Card([title, form])
title = TextContent("Contact Us", "large-heavy")
Expand All @@ -14,7 +20,20 @@ btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary")])`

const MALFORMED = "root = MissingComponent([])";

const FOLLOW_UPS = `root = Card([followUps])
followUps = FollowUpBlock([first, second])
first = FollowUpItem("Tell me more")
second = FollowUpItem("Show another example")`;

const CLICKABLE_LIST = `root = Card([list])
list = ListBlock([item])
item = ListItem("Compare regions", "See the regional breakdown", null, "Explore", Action([@ToAssistant("Compare regions")]))`;

const OPEN_URL = `root = Card([buttons])
buttons = Buttons([Button("Open docs", Action([@OpenUrl("https://openui.com/docs")]), "primary")])`;

type PromptProps = ComponentProps<typeof OpenUIPrompt>;
type PresentProps = ComponentProps<typeof OpenUIPresent>;

const makeProps = (overrides: Partial<PromptProps> = {}): PromptProps =>
({
Expand All @@ -30,12 +49,117 @@ const makeProps = (overrides: Partial<PromptProps> = {}): PromptProps =>
...overrides,
}) as PromptProps;

const makePresentProps = (overrides: Partial<PresentProps> = {}): PresentProps =>
({
type: "tool-call",
toolCallId: "openui-present-call",
toolName: "present_openui",
args: { ui: FOLLOW_UPS },
argsText: JSON.stringify({ ui: FOLLOW_UPS }),
status: { type: "complete" },
result: { displayed: true },
addResult: vi.fn(),
resume: vi.fn(),
respondToApproval: vi.fn(),
...overrides,
}) as PresentProps;

const setInputValue = (input: HTMLInputElement, value: string) => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
};

describe("OpenUIPresent", () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
appendToThread.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
vi.restoreAllMocks();
container.remove();
});

it("starts a new user turn when a follow-up is clicked", async () => {
await act(async () => {
root.render(<OpenUIPresent {...makePresentProps()} />);
});

const followUp = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Tell me more",
);
expect(followUp).toBeDefined();

await act(async () => followUp!.click());

expect(appendToThread).toHaveBeenCalledOnce();
expect(appendToThread).toHaveBeenCalledWith({
role: "user",
content: [{ type: "text", text: "Tell me more" }],
});
});

it("starts a new user turn when a clickable list item is selected", async () => {
const args = { ui: CLICKABLE_LIST };

await act(async () => {
root.render(
<OpenUIPresent
{...makePresentProps({
args,
argsText: JSON.stringify(args),
})}
/>,
);
});

const item = Array.from(container.querySelectorAll<HTMLElement>('[role="button"]')).find(
(element) => element.textContent?.includes("Compare regions"),
);
expect(item).toBeDefined();

await act(async () => item!.click());

expect(appendToThread).toHaveBeenCalledOnce();
expect(appendToThread).toHaveBeenCalledWith({
role: "user",
content: [{ type: "text", text: "Compare regions" }],
});
});

it("opens URL actions in a new isolated tab", async () => {
const open = vi.spyOn(window, "open").mockImplementation(() => null);
const args = { ui: OPEN_URL };

await act(async () => {
root.render(
<OpenUIPresent
{...makePresentProps({
args,
argsText: JSON.stringify(args),
})}
/>,
);
});

const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent === "Open docs",
);
expect(button).toBeDefined();

await act(async () => button!.click());

expect(open).toHaveBeenCalledWith("https://openui.com/docs", "_blank", "noopener,noreferrer");
});
});

describe("OpenUIPrompt", () => {
let container: HTMLDivElement;
let root: Root;
Expand Down
15 changes: 15 additions & 0 deletions packages/assistant-ui/src/__tests__/toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("assistant-ui OpenUI toolkit", () => {
expect(openuiToolkit["prompt_openui"]?.type).toBe("human");
expect(openuiToolkit["present_openui"]?.display).toBe("standalone");
expect(openuiToolkit["prompt_openui"]?.display).toBe("standalone");
expect(openuiToolkit["present_openui"]?.description).toContain("optional follow-up actions");

const execute = openuiToolkit["present_openui"]?.execute;
expect(execute).toBeTypeOf("function");
Expand Down Expand Up @@ -50,6 +51,20 @@ describe("assistant-ui OpenUI toolkit", () => {
expect(instructions).toContain("Panel(title: string)");
});

it("allows optional present-tool actions to start a new user turn", () => {
const instructions = createOpenUIInstructions();

expect(instructions).toContain(
"When using present_openui, FollowUpBlock clicks start a new user turn automatically",
);
expect(instructions).toContain(
"ListBlock items may use @ToAssistant for optional drill-down actions",
);
expect(instructions).toContain(
"When using prompt_openui, include exactly one terminal @ToAssistant action",
);
});

it("rejects ambiguous tool names", () => {
expect(() =>
createOpenUIToolkit({
Expand Down
2 changes: 1 addition & 1 deletion packages/assistant-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ export const OPENUI_PRESENT_TOOL_NAME = "present_openui";
export const OPENUI_PROMPT_TOOL_NAME = "prompt_openui";

export const openuiToolDescriptions = {
present: "Render a display-only interface from an OpenUI Lang program.",
present: "Render a complete OpenUI Lang interface that may include optional follow-up actions.",
prompt: "Render an interactive OpenUI Lang form or choice and wait for the user to submit it.",
} as const;
4 changes: 2 additions & 2 deletions packages/assistant-ui/src/instructions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function createOpenUIInstructions({
}: CreateOpenUIInstructionsOptions = {}): string {
const defaultPreamble = [
`Render requested interfaces by calling ${presentToolName} or ${promptToolName}.`,
`Use ${presentToolName} for display-only responses and ${promptToolName} when the user must submit a choice or form.`,
`Use ${presentToolName} for complete responses that may include optional follow-up suggestions, and ${promptToolName} when the current response requires the user to submit a choice or form.`,
"Set the ui argument to valid OpenUI Lang without markdown fences.",
"Make the tool call the entire response and never return OpenUI Lang as assistant text.",
].join(" ");
Expand All @@ -34,7 +34,7 @@ export function createOpenUIInstructions({
preamble: preamble ?? defaultPreamble,
additionalRules: [
...(promptOptions.additionalRules ?? []),
`When using ${presentToolName}, do not include actions that continue the conversation.`,
`When using ${presentToolName}, FollowUpBlock clicks start a new user turn automatically, and ListBlock items may use @ToAssistant for optional drill-down actions. Do not use ${presentToolName} for a required form or choice submission.`,
`When using ${promptToolName}, include exactly one terminal @ToAssistant action that submits the form or choice.`,
...additionalRules,
],
Expand Down
31 changes: 29 additions & 2 deletions packages/assistant-ui/src/renderers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"use client";

import type { ToolCallMessagePartComponent, ToolCallMessagePartProps } from "@assistant-ui/react";
import {
useAui,
type ToolCallMessagePartComponent,
type ToolCallMessagePartProps,
} from "@assistant-ui/react";
import {
BuiltinActionType,
Renderer,
Expand Down Expand Up @@ -104,8 +108,31 @@ export type OpenUIPresentProps = ToolCallMessagePartProps<OpenUIToolArgs, OpenUI
OpenUIToolUIOptions;

export function OpenUIPresent({ args, status, ...options }: OpenUIPresentProps) {
const aui = useAui();
const onAction = useCallback(
(event: ActionEvent) => {
if (event.type === BuiltinActionType.ContinueConversation) {
aui.thread.append({
role: "user",
content: [{ type: "text", text: event.humanFriendlyMessage }],
});
} else if (event.type === BuiltinActionType.OpenUrl) {
const url = event.params?.["url"];
if (typeof window !== "undefined" && typeof url === "string") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
},
[aui],
);

return (
<OpenUIContent {...options} response={args.ui ?? ""} isStreaming={status.type === "running"} />
<OpenUIContent
{...options}
response={args.ui ?? ""}
isStreaming={status.type === "running"}
onAction={onAction}
/>
);
}

Expand Down
Loading