diff --git a/docs/content/docs/api-reference/assistant-ui.mdx b/docs/content/docs/api-reference/assistant-ui.mdx index 08201a604..93e611ca1 100644 --- a/docs/content/docs/api-reference/assistant-ui.mdx +++ b/docs/content/docs/api-reference/assistant-ui.mdx @@ -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)` @@ -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. diff --git a/packages/assistant-ui/README.md b/packages/assistant-ui/README.md index 1cc223922..120c2a3eb 100644 --- a/packages/assistant-ui/README.md +++ b/packages/assistant-ui/README.md @@ -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 diff --git a/packages/assistant-ui/package.json b/packages/assistant-ui/package.json index be9c194b5..f7c692ec1 100644 --- a/packages/assistant-ui/package.json +++ b/packages/assistant-ui/package.json @@ -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": { diff --git a/packages/assistant-ui/src/__tests__/renderers.test.tsx b/packages/assistant-ui/src/__tests__/renderers.test.tsx index d26994425..12eb34fe2 100644 --- a/packages/assistant-ui/src/__tests__/renderers.test.tsx +++ b/packages/assistant-ui/src/__tests__/renderers.test.tsx @@ -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") @@ -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; +type PresentProps = ComponentProps; const makeProps = (overrides: Partial = {}): PromptProps => ({ @@ -30,12 +49,117 @@ const makeProps = (overrides: Partial = {}): PromptProps => ...overrides, }) as PromptProps; +const makePresentProps = (overrides: Partial = {}): 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(); + }); + + 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( + , + ); + }); + + const item = Array.from(container.querySelectorAll('[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( + , + ); + }); + + 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; diff --git a/packages/assistant-ui/src/__tests__/toolkit.test.ts b/packages/assistant-ui/src/__tests__/toolkit.test.ts index dcbf4fdff..0595a6275 100644 --- a/packages/assistant-ui/src/__tests__/toolkit.test.ts +++ b/packages/assistant-ui/src/__tests__/toolkit.test.ts @@ -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"); @@ -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({ diff --git a/packages/assistant-ui/src/constants.ts b/packages/assistant-ui/src/constants.ts index a4c575b78..f31a62a0d 100644 --- a/packages/assistant-ui/src/constants.ts +++ b/packages/assistant-ui/src/constants.ts @@ -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; diff --git a/packages/assistant-ui/src/instructions.tsx b/packages/assistant-ui/src/instructions.tsx index 5fd7f1803..bf87c54e4 100644 --- a/packages/assistant-ui/src/instructions.tsx +++ b/packages/assistant-ui/src/instructions.tsx @@ -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(" "); @@ -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, ], diff --git a/packages/assistant-ui/src/renderers.tsx b/packages/assistant-ui/src/renderers.tsx index e06796247..e34fad5b5 100644 --- a/packages/assistant-ui/src/renderers.tsx +++ b/packages/assistant-ui/src/renderers.tsx @@ -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, @@ -104,8 +108,31 @@ export type OpenUIPresentProps = ToolCallMessagePartProps { + 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 ( - + ); }