From 6ce110249debd9b38c8061b0dad8ae75f0793f63 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:46:43 +0530 Subject: [PATCH 01/10] fix assistant-ui follow-up actions --- packages/assistant-ui/README.md | 2 +- .../src/__tests__/renderers.test.tsx | 94 ++++++++++++++++++- .../src/__tests__/toolkit.test.ts | 11 +++ packages/assistant-ui/src/instructions.tsx | 4 +- packages/assistant-ui/src/renderers.tsx | 31 +++++- 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/packages/assistant-ui/README.md b/packages/assistant-ui/README.md index 1cc223922..ecb1fa125 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 and clickable list items start a new user turn. 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/src/__tests__/renderers.test.tsx b/packages/assistant-ui/src/__tests__/renderers.test.tsx index d26994425..11983354a 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,16 @@ 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 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 +45,89 @@ 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("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..0488c4717 100644 --- a/packages/assistant-ui/src/__tests__/toolkit.test.ts +++ b/packages/assistant-ui/src/__tests__/toolkit.test.ts @@ -50,6 +50,17 @@ describe("assistant-ui OpenUI toolkit", () => { expect(instructions).toContain("Panel(title: string)"); }); + it("allows present-tool follow-ups to start a new user turn", () => { + const instructions = createOpenUIInstructions(); + + expect(instructions).toContain( + "When using present_openui, FollowUpBlock and ListBlock clicks may start a new user turn", + ); + 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/instructions.tsx b/packages/assistant-ui/src/instructions.tsx index 5fd7f1803..3d26919de 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 and ListBlock clicks may start a new user turn, but do not include terminal @ToAssistant actions.`, `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 ( - + ); } From e911d9ffcdb46a88d5cd64199b92089e8d7df436 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:25:40 +0530 Subject: [PATCH 02/10] test: add assistant-ui follow-up example --- examples/assistant-ui-chat/.env.example | 1 + examples/assistant-ui-chat/.gitignore | 19 + examples/assistant-ui-chat/README.md | 31 ++ examples/assistant-ui-chat/eslint.config.mjs | 11 + examples/assistant-ui-chat/next.config.ts | 8 + examples/assistant-ui-chat/package.json | 37 ++ examples/assistant-ui-chat/postcss.config.mjs | 7 + .../src/app/api/chat/route.ts | 35 ++ .../src/app/fixture/page.tsx | 70 +++ .../assistant-ui-chat/src/app/globals.css | 34 ++ examples/assistant-ui-chat/src/app/layout.tsx | 19 + examples/assistant-ui-chat/src/app/page.tsx | 26 + .../src/components/thread.tsx | 102 ++++ examples/assistant-ui-chat/tsconfig.json | 30 + pnpm-lock.yaml | 513 ++++++++++-------- 15 files changed, 708 insertions(+), 235 deletions(-) create mode 100644 examples/assistant-ui-chat/.env.example create mode 100644 examples/assistant-ui-chat/.gitignore create mode 100644 examples/assistant-ui-chat/README.md create mode 100644 examples/assistant-ui-chat/eslint.config.mjs create mode 100644 examples/assistant-ui-chat/next.config.ts create mode 100644 examples/assistant-ui-chat/package.json create mode 100644 examples/assistant-ui-chat/postcss.config.mjs create mode 100644 examples/assistant-ui-chat/src/app/api/chat/route.ts create mode 100644 examples/assistant-ui-chat/src/app/fixture/page.tsx create mode 100644 examples/assistant-ui-chat/src/app/globals.css create mode 100644 examples/assistant-ui-chat/src/app/layout.tsx create mode 100644 examples/assistant-ui-chat/src/app/page.tsx create mode 100644 examples/assistant-ui-chat/src/components/thread.tsx create mode 100644 examples/assistant-ui-chat/tsconfig.json diff --git a/examples/assistant-ui-chat/.env.example b/examples/assistant-ui-chat/.env.example new file mode 100644 index 000000000..ed6ed73ba --- /dev/null +++ b/examples/assistant-ui-chat/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=sk-... diff --git a/examples/assistant-ui-chat/.gitignore b/examples/assistant-ui-chat/.gitignore new file mode 100644 index 000000000..a6200f6df --- /dev/null +++ b/examples/assistant-ui-chat/.gitignore @@ -0,0 +1,19 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# local environment files +.env* +!.env.example + +# misc +.DS_Store +*.pem +*.tsbuildinfo +next-env.d.ts diff --git a/examples/assistant-ui-chat/README.md b/examples/assistant-ui-chat/README.md new file mode 100644 index 000000000..d7c8a9b90 --- /dev/null +++ b/examples/assistant-ui-chat/README.md @@ -0,0 +1,31 @@ +# assistant-ui + OpenUI example + +This example runs assistant-ui against the local `@openuidev/assistant-ui` +workspace package. OpenUI renders the tool UI while assistant-ui owns the chat +runtime, messages, and tool lifecycle. + +## Run locally + +From the OpenUI repository root: + +```bash +cp examples/assistant-ui-chat/.env.example examples/assistant-ui-chat/.env.local +# Add a valid OPENAI_API_KEY to .env.local + +pnpm install +pnpm --filter @openuidev/assistant-ui... build +pnpm --filter assistant-ui-chat dev +``` + +Open [http://localhost:3000](http://localhost:3000), then choose **Trip summary +with follow-ups**. Clicking a rendered follow-up should append its label as a +new user message and start the next assistant-ui turn. + +For a deterministic check that does not call a model, open +[http://localhost:3000/fixture](http://localhost:3000/fixture). That route +seeds a completed `present_openui` tool call. Clicking either follow-up uses +the real package renderer to append a user turn, and a local adapter confirms +the received message. + +The example deliberately uses `workspace:*` for every OpenUI package, so it +does not require `@openuidev/assistant-ui@0.0.2` to be published. diff --git a/examples/assistant-ui-chat/eslint.config.mjs b/examples/assistant-ui-chat/eslint.config.mjs new file mode 100644 index 000000000..c22de0bd9 --- /dev/null +++ b/examples/assistant-ui-chat/eslint.config.mjs @@ -0,0 +1,11 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; +import { defineConfig, globalIgnores } from "eslint/config"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]), +]); + +export default eslintConfig; diff --git a/examples/assistant-ui-chat/next.config.ts b/examples/assistant-ui-chat/next.config.ts new file mode 100644 index 000000000..33fad52ad --- /dev/null +++ b/examples/assistant-ui-chat/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + turbopack: {}, + transpilePackages: ["@assistant-ui/react", "@assistant-ui/react-ai-sdk"], +}; + +export default nextConfig; diff --git a/examples/assistant-ui-chat/package.json b/examples/assistant-ui-chat/package.json new file mode 100644 index 000000000..73478add0 --- /dev/null +++ b/examples/assistant-ui-chat/package.json @@ -0,0 +1,37 @@ +{ + "name": "assistant-ui-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@ai-sdk/openai": "^4.0.40", + "@assistant-ui/react": "^0.15.14", + "@assistant-ui/react-ai-sdk": "^1.4.5", + "@openuidev/assistant-ui": "workspace:*", + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "ai": "^7.0.62", + "lucide-react": "^0.575.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "eslint": "catalog:", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "catalog:" + } +} diff --git a/examples/assistant-ui-chat/postcss.config.mjs b/examples/assistant-ui-chat/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/assistant-ui-chat/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/assistant-ui-chat/src/app/api/chat/route.ts b/examples/assistant-ui-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..fcdb0a4bc --- /dev/null +++ b/examples/assistant-ui-chat/src/app/api/chat/route.ts @@ -0,0 +1,35 @@ +import { openai } from "@ai-sdk/openai"; +import { frontendTools } from "@assistant-ui/react-ai-sdk"; +import { + convertToModelMessages, + createUIMessageStreamResponse, + type JSONSchema7, + streamText, + toUIMessageStream, + type UIMessage, +} from "ai"; + +export const maxDuration = 30; + +export async function POST(req: Request) { + const { + messages, + system, + tools, + }: { + messages: UIMessage[]; + system?: string; + tools?: Record; + } = await req.json(); + + const result = streamText({ + model: openai("gpt-5.5"), + messages: await convertToModelMessages(messages), + ...(system ? { system } : {}), + tools: frontendTools(tools ?? {}), + }); + + return createUIMessageStreamResponse({ + stream: toUIMessageStream({ stream: result.stream }), + }); +} diff --git a/examples/assistant-ui-chat/src/app/fixture/page.tsx b/examples/assistant-ui-chat/src/app/fixture/page.tsx new file mode 100644 index 000000000..70343e60f --- /dev/null +++ b/examples/assistant-ui-chat/src/app/fixture/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Thread } from "@/components/thread"; +import { + AssistantRuntimeProvider, + AuiConfig, + type ChatModelAdapter, + type ThreadMessageLike, + Tools, + useLocalRuntime, +} from "@assistant-ui/react"; +import { openuiIntegration } from "@openuidev/assistant-ui"; + +const fixtureUI = `root = Card([title, description, followups]) +title = CardHeader("Tokyo trip") +description = TextContent("Your itinerary is ready. Choose what to do next.") +followups = FollowUpBlock([plan, budget]) +plan = FollowUpItem("Plan the first day") +budget = FollowUpItem("Review the budget")`; + +const initialMessages: ThreadMessageLike[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "local-follow-up-fixture", + toolName: openuiIntegration.toolNames.present, + args: { ui: fixtureUI }, + argsText: JSON.stringify({ ui: fixtureUI }), + result: { displayed: true }, + }, + ], + status: { type: "complete", reason: "stop" }, + }, +]; + +const fixtureAdapter: ChatModelAdapter = { + async *run({ messages }) { + const latestMessage = messages.at(-1); + const receivedText = latestMessage?.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(" "); + + yield { + content: [ + { + type: "text", + text: `Follow-up received by assistant-ui: ${receivedText ?? "unknown"}`, + }, + ], + }; + }, +}; + +export default function FollowUpFixture() { + const runtime = useLocalRuntime(fixtureAdapter, { initialMessages }); + const config = AuiConfig({ + tools: Tools({ toolkit: openuiIntegration.toolkit }), + }); + + return ( + +
+ +
+
+ ); +} diff --git a/examples/assistant-ui-chat/src/app/globals.css b/examples/assistant-ui-chat/src/app/globals.css new file mode 100644 index 000000000..b6c99ad5d --- /dev/null +++ b/examples/assistant-ui-chat/src/app/globals.css @@ -0,0 +1,34 @@ +@layer theme, base, openui, components, utilities; + +@import "tailwindcss"; +@import "@openuidev/react-ui/layered/styles/index.css"; + +:root { + --background: #ffffff; + --foreground: #18181b; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --border: #e4e4e7; +} + +@layer base { + * { + box-sizing: border-box; + } + + html, + body { + height: 100%; + margin: 0; + } + + body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; + } + + button { + cursor: pointer; + } +} diff --git a/examples/assistant-ui-chat/src/app/layout.tsx b/examples/assistant-ui-chat/src/app/layout.tsx new file mode 100644 index 000000000..425e15f3a --- /dev/null +++ b/examples/assistant-ui-chat/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "assistant-ui + OpenUI", + description: "Local workspace example for the assistant-ui OpenUI integration", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/assistant-ui-chat/src/app/page.tsx b/examples/assistant-ui-chat/src/app/page.tsx new file mode 100644 index 000000000..31d35957e --- /dev/null +++ b/examples/assistant-ui-chat/src/app/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Thread } from "@/components/thread"; +import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react"; +import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; +import { OpenUIInstructions, openuiIntegration } from "@openuidev/assistant-ui"; +import { shouldContinueAfterOpenUIPrompt } from "@openuidev/assistant-ui/ai-sdk"; + +export default function Home() { + const runtime = useChatRuntime({ + sendAutomaticallyWhen: shouldContinueAfterOpenUIPrompt, + }); + + const config = AuiConfig({ + tools: Tools({ toolkit: openuiIntegration.toolkit }), + }); + + return ( + + +
+ +
+
+ ); +} diff --git a/examples/assistant-ui-chat/src/components/thread.tsx b/examples/assistant-ui-chat/src/components/thread.tsx new file mode 100644 index 000000000..8a3e15238 --- /dev/null +++ b/examples/assistant-ui-chat/src/components/thread.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { AuiIf, ComposerPrimitive, MessagePrimitive, ThreadPrimitive } from "@assistant-ui/react"; +import { ArrowUp, Square } from "lucide-react"; + +const starters = [ + { + label: "Trip summary with follow-ups", + prompt: + "Show a trip summary for my Tokyo trip: flying March 14 to 21, staying at Park Hyatt Tokyo at $310 a night, total budget $4,200, with three confirmed activities: teamLab Planets, a Tsukiji food tour, and a Hakone day trip. Use present_openui and end with a FollowUpBlock offering to plan the first day or review the budget.", + }, + { + label: "Interactive meeting choice", + prompt: + "Use prompt_openui to ask me to pick a meeting slot from Tuesday 10:00, Wednesday 14:30, or Friday 09:15.", + }, +]; + +export function Thread() { + return ( + +
+

assistant-ui + OpenUI

+

+ Running against the local OpenUI workspace packages +

+
+ + + +
+
+

Test the OpenUI integration

+

+ Generate a display card with follow-ups, then click one to start the next turn. +

+
+ {starters.map(({ label, prompt }) => ( + + + + ))} +
+
+
+
+ + + + + + +