From 37c58947e28d158d770d07b450291ec2f2e232f3 Mon Sep 17 00:00:00 2001 From: Rohit Sharma <42964735+creazyfrog@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:18:09 -0700 Subject: [PATCH 1/4] docs(base/data-table): replace asChild with render prop in DropdownMenuTrigger examples (fixes #10934) (#10980) --- .../v4/content/docs/components/base/data-table.mdx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/v4/content/docs/components/base/data-table.mdx b/apps/v4/content/docs/components/base/data-table.mdx index 738be1a84cf..bb7de2ce9a6 100644 --- a/apps/v4/content/docs/components/base/data-table.mdx +++ b/apps/v4/content/docs/components/base/data-table.mdx @@ -399,11 +399,9 @@ export const columns = columnHelper.columns([ return ( - - + }> + Open menu + Actions @@ -703,10 +701,8 @@ export function DataTable({ className="max-w-sm" /> - - + }> + Columns {table From dcf56edd57d106825193b5432fdc99c8d3d6b8fa Mon Sep 17 00:00:00 2001 From: shadcn Date: Tue, 11 Aug 2026 21:28:22 +0400 Subject: [PATCH 2/4] fix(docs): use render prop instead of asChild in base ui docs (#11478) * fix(docs): use render prop instead of asChild in base ui docs The base ui docs were copied from the radix docs without adapting the code examples to the Base UI API. Replace asChild with render, remove the radix-specific --radix-popper-anchor-width CSS variable (base DropdownMenuContent already sizes to --anchor-width), and update data-[state=open] to data-open. Closes #9049 * style: code format --- .../docs/components/base/button-group.mdx | 12 +++--- .../docs/components/base/data-table.mdx | 4 +- .../v4/content/docs/components/base/field.mdx | 7 ++-- .../content/docs/components/base/sidebar.mdx | 40 ++++++++----------- 4 files changed, 27 insertions(+), 36 deletions(-) diff --git a/apps/v4/content/docs/components/base/button-group.mdx b/apps/v4/content/docs/components/base/button-group.mdx index 7db528a2033..cf41b1c3316 100644 --- a/apps/v4/content/docs/components/base/button-group.mdx +++ b/apps/v4/content/docs/components/base/button-group.mdx @@ -213,9 +213,9 @@ The `ButtonGroupSeparator` component visually divides buttons within a group. Use this component to display text within a button group. -| Prop | Type | Default | -| --------- | --------- | ------- | -| `asChild` | `boolean` | `false` | +| Prop | Type | Default | +| -------- | -------------------- | ------- | +| `render` | `React.ReactElement` | | ```tsx @@ -224,7 +224,7 @@ Use this component to display text within a button group. ``` -Use the `asChild` prop to render a custom component as the text, for example a label. +Use the `render` prop to render a custom component as the text, for example a label. ```tsx showLineNumbers import { ButtonGroupText } from "@/components/ui/button-group" @@ -233,9 +233,7 @@ import { Label } from "@/components/ui/label" export function ButtonGroupTextDemo() { return ( - - - + }>Text ) diff --git a/apps/v4/content/docs/components/base/data-table.mdx b/apps/v4/content/docs/components/base/data-table.mdx index bb7de2ce9a6..e1b9addf24a 100644 --- a/apps/v4/content/docs/components/base/data-table.mdx +++ b/apps/v4/content/docs/components/base/data-table.mdx @@ -399,7 +399,9 @@ export const columns = columnHelper.columns([ return ( - }> + } + > Open menu diff --git a/apps/v4/content/docs/components/base/field.mdx b/apps/v4/content/docs/components/base/field.mdx index 5ca8ba4e4a9..fa77feb27e9 100644 --- a/apps/v4/content/docs/components/base/field.mdx +++ b/apps/v4/content/docs/components/base/field.mdx @@ -339,10 +339,9 @@ Flex column that groups control and descriptions when the label sits beside the Label styled for both direct inputs and nested `Field` children. -| Prop | Type | Default | -| ----------- | --------- | ------- | -| `className` | `string` | | -| `asChild` | `boolean` | `false` | +| Prop | Type | Default | +| ----------- | -------- | ------- | +| `className` | `string` | | ```tsx Email diff --git a/apps/v4/content/docs/components/base/sidebar.mdx b/apps/v4/content/docs/components/base/sidebar.mdx index fe52323fd59..9f6f9edae0a 100644 --- a/apps/v4/content/docs/components/base/sidebar.mdx +++ b/apps/v4/content/docs/components/base/sidebar.mdx @@ -274,13 +274,11 @@ Use the `SidebarHeader` component to add a sticky header to the sidebar. - - - Select Workspace - - + }> + Select Workspace + - + Acme Inc @@ -344,11 +342,9 @@ To make a `SidebarGroup` collapsible, wrap it in a `Collapsible`. ```tsx showLineNumbers - - - Help - - + }> + Help + @@ -380,11 +376,9 @@ The `SidebarMenu` component is used for building a menu within a `SidebarGroup`. {projects.map((project) => ( - - - - {project.name} - + }> + + {project.name} ))} @@ -395,13 +389,13 @@ The `SidebarMenu` component is used for building a menu within a `SidebarGroup`. The `SidebarMenuButton` component is used to render a menu button within a `SidebarMenuItem`. -By default, the `SidebarMenuButton` renders a button but you can use the `asChild` prop to render a different component such as a `Link` or an `a` tag. +By default, the `SidebarMenuButton` renders a button but you can use the `render` prop to render a different component such as a `Link` or an `a` tag. Use the `isActive` prop to mark a menu item as active. ```tsx showLineNumbers - - Home +} isActive> + Home ``` @@ -411,11 +405,9 @@ The `SidebarMenuAction` component is used to render a menu action within a `Side ```tsx showLineNumbers - - - - Home - + }> + + Home Add Project From 401d10b8180b17eb1fdd36d537abaa8aeb68708f Mon Sep 17 00:00:00 2001 From: shadcn Date: Wed, 12 Aug 2026 00:34:51 +0400 Subject: [PATCH 3/4] feat(helpers): human-in-the-loop mocking for the AI SDK (#11484) * feat(helpers): add human-in-the-loop mocking * feat(helpers): adopt useChat message generics and finalize human-in-the-loop * chore(helpers): tighten changesets * refactor(registry): use named message type aliases with createChat * docs(changelog): add helpers human-in-the-loop entry * docs: add changelog --- .changeset/helpers-human-in-the-loop.md | 5 + .changeset/helpers-message-type-generic.md | 5 + .../2026-08-helpers-human-in-the-loop.mdx | 62 +++ apps/v4/content/docs/helpers/ai-sdk.mdx | 250 ++++++++--- .../bases/aria/examples/message-example.tsx | 9 +- .../bases/base/examples/message-example.tsx | 9 +- .../blocks/preview-03/cards/group-chat.tsx | 2 +- .../blocks/preview-03/cards/tool-chat.tsx | 5 +- .../bases/radix/examples/message-example.tsx | 9 +- packages/helpers/README.md | 55 +++ packages/helpers/package.json | 3 +- packages/helpers/src/ai-sdk/chat.test.ts | 74 +-- packages/helpers/src/ai-sdk/chat.ts | 88 ++-- packages/helpers/src/ai-sdk/format.test.ts | 261 ++++++++++- packages/helpers/src/ai-sdk/format.ts | 26 +- packages/helpers/src/ai-sdk/parts.test.ts | 79 +++- packages/helpers/src/ai-sdk/parts.ts | 86 +++- packages/helpers/src/ai-sdk/test-utils.ts | 4 + packages/helpers/src/core/chat.test.ts | 248 +++++++++- packages/helpers/src/core/chat.ts | 425 ++++++++++++++++-- packages/helpers/src/core/ids.test.ts | 3 + packages/helpers/src/core/ids.ts | 10 + packages/helpers/src/core/lower.ts | 12 + packages/helpers/src/core/test-utils.ts | 28 ++ packages/helpers/src/core/types.ts | 63 +++ packages/helpers/src/core/utils.ts | 7 + packages/helpers/src/core/writer.ts | 30 ++ packages/helpers/src/tanstack-ai/format.ts | 1 + packages/helpers/src/tanstack-ai/parts.ts | 1 + pnpm-lock.yaml | 57 +-- 30 files changed, 1693 insertions(+), 224 deletions(-) create mode 100644 .changeset/helpers-human-in-the-loop.md create mode 100644 .changeset/helpers-message-type-generic.md create mode 100644 apps/v4/content/docs/changelog/2026-08-helpers-human-in-the-loop.mdx diff --git a/.changeset/helpers-human-in-the-loop.md b/.changeset/helpers-human-in-the-loop.md new file mode 100644 index 00000000000..50f76893a87 --- /dev/null +++ b/.changeset/helpers-human-in-the-loop.md @@ -0,0 +1,5 @@ +--- +"@shadcn/helpers": minor +--- + +Add human-in-the-loop mocking for the AI SDK. diff --git a/.changeset/helpers-message-type-generic.md b/.changeset/helpers-message-type-generic.md new file mode 100644 index 00000000000..3cf80c3674a --- /dev/null +++ b/.changeset/helpers-message-type-generic.md @@ -0,0 +1,5 @@ +--- +"@shadcn/helpers": minor +--- + +Make `createChat` generic over the UI message type, like `useChat`. diff --git a/apps/v4/content/docs/changelog/2026-08-helpers-human-in-the-loop.mdx b/apps/v4/content/docs/changelog/2026-08-helpers-human-in-the-loop.mdx new file mode 100644 index 00000000000..3c64519242d --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-08-helpers-human-in-the-loop.mdx @@ -0,0 +1,62 @@ +--- +title: August 2026 - Human in the Loop +description: Mock paused tool calls, approvals, and continuations for the AI SDK with @shadcn/helpers. +date: 2026-08-12 +--- + +**@shadcn/helpers** can now mock human-in-the-loop flows for the AI SDK. A +scripted conversation can pause for real user input, wait for an approval, and +continue with whatever the user decided. + +Everything streams through the real +`useChat` lifecycle, so your tool cards, approval prompts, and question flows +behave exactly as they would in production. + +```ts showLineNumbers +import { createChat } from "@shadcn/helpers/ai-sdk" + +const chat = createChat() + .user("Help me plan the next prototype.") + .assistant(({ writer }) => { + writer.text("A couple of questions before I start.") + writer.tool("askQuestions", { input: { questions } }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.name === "askQuestions" && toolCall.output + ? `Starting with ${toolCall.output.answers.direction}.` + : "Starting now." + ) + }) +``` + +Pass `needsApproval` to pause behind the user's decision. The scripted output +streams after approval, and denial streams automatically. + +```ts showLineNumbers +chat + .assistant(({ writer }) => { + writer.text("That will archive 3 drafts. I need your approval.") + writer.tool("archiveDrafts", { + input: { count: 3 }, + needsApproval: true, + output: { archived: 3 }, + }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.approved ? "Archived 3 drafts." : "Okay, leaving them in place." + ) + }) +``` + +
+ +
diff --git a/apps/v4/content/docs/helpers/ai-sdk.mdx b/apps/v4/content/docs/helpers/ai-sdk.mdx index de7256f7178..5f29f6d0047 100644 --- a/apps/v4/content/docs/helpers/ai-sdk.mdx +++ b/apps/v4/content/docs/helpers/ai-sdk.mdx @@ -10,7 +10,8 @@ Because the conversation streams through the real `useChat` lifecycle, your components behave exactly as they would in production. It supports every part type the AI SDK does: reasoning, tools, data, files, -sources, and custom parts. +sources, and custom parts. Tool calls can also pause for real user input, +including approvals. See [Human in the Loop](#human-in-the-loop). ```ts showLineNumbers import { createChat } from "@shadcn/helpers/ai-sdk" @@ -323,10 +324,12 @@ writer.tool("getWeather", { }) ``` -Type the tool name, input, and output by passing your tool definitions to -`createChat`. +Type the tool name, input, and output by passing your message type to +`createChat`. It takes the same type parameter as `useChat`. ```ts +import type { UIMessage } from "ai" + type Tools = { getWeather: { input: { city: string } @@ -334,11 +337,148 @@ type Tools = { } } -type DataParts = Record +type ChatMessage = UIMessage, Tools> + +const chat = createChat() +``` + +Define the message type once and use it for both `createChat` and `useChat`. -const chat = createChat() +```ts +const { messages } = useChat({ + messages: chat.get(0), + transport: chat.transport(), +}) ``` +A tool call can also wait for the user instead of finishing in the script. +See [Human in the Loop](#human-in-the-loop). + +--- + +## Human in the Loop + +A tool call can pause and hand control to the real user. The helper supports +both AI SDK human-in-the-loop flows: client-executed tools, where the user +supplies the output, and approval-gated tools, where the user approves or +denies before a scripted output streams. + +### Pause a Tool Call + +Leave a tool call unresolved to pause the turn. The input streams, the turn +finishes, and the part stays in the `input-available` state until the client +supplies its output with `addToolOutput`. + +```ts +chat.assistant(({ writer }) => { + writer.text("Answer these and I'll tailor the prototype.") + writer.tool("askQuestions", { input: { questions } }) +}) +``` + +### Require Approval + +Pass `needsApproval: true` to pause behind the user's decision. `output` then +means "stream this after approval" instead of "resolve immediately". Denial +streams `tool-output-denied` automatically, and `errorText` with +`needsApproval` scripts a tool that fails after approval. + +```ts +chat.assistant(({ writer }) => { + writer.text("That will archive 3 drafts. I need your approval.") + writer.tool("archiveDrafts", { + input: { count: 3 }, + needsApproval: true, + output: { archived: 3 }, + }) +}) +``` + +Calling `output()`, `error()`, or `denied()` on the handle of a +`needsApproval` call throws. The user's decision resolves it. + +### Continuations + +A callback turn scripted immediately after a paused turn becomes a +continuation. It does not materialize when the script is built. It runs when +the follow-up request arrives, and its context includes `toolCall`: the +paused call joined with what the user did. + +```ts +chat + .assistant(({ writer }) => { + writer.tool("askQuestions", { input: { questions } }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.name === "askQuestions" && toolCall.output + ? `Starting with ${toolCall.output.answers.direction}.` + : "Starting now." + ) + }) +``` + +For a client-executed tool, `toolCall.output` is the output the user +submitted. For an approval, `toolCall.approved` and `toolCall.denied` carry +the decision, and the scripted output streams before the callback's content. + +| Property | Description | +| ------------ | -------------------------------------------------------------- | +| `name` | The tool name. Comparing it narrows `input` and `output`. | +| `toolCallId` | The paused call's ID. | +| `input` | The tool input. | +| `output` | The user-submitted output, or the gated output after approval. | +| `approved` | `true` when the user approved. Approval calls only. | +| `denied` | `true` when the user denied. Approval calls only. | + +Continuation context also includes `messages`, the live transcript, and +`toolCalls`, every paused call when a turn has more than one. + +Continuations must stay pure. Regenerating re-resolves them against the +current transcript, so a changed decision produces the other branch. + +### Client Wiring + +The client side uses the AI SDK as-is. Submit a client-executed tool's output +with `addToolOutput`, answer an approval with `addToolApprovalResponse`, and +let `sendAutomaticallyWhen` send the follow-up request. + +```tsx +import { + lastAssistantMessageIsCompleteWithApprovalResponses, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai" + +const { messages, addToolOutput, addToolApprovalResponse } = useChat({ + messages: chat.get(0), + transport: chat.transport(), + sendAutomaticallyWhen: (options) => + lastAssistantMessageIsCompleteWithToolCalls(options) || + lastAssistantMessageIsCompleteWithApprovalResponses(options), +}) +``` + +```tsx +// In a pending tool part renderer. +addToolOutput({ + tool: "askQuestions", + toolCallId: part.toolCallId, + output: { answers }, +}) + +// In an approval-requested part renderer. +addToolApprovalResponse({ id: part.approval.id, approved: true }) +``` + +The continuation streams as a new step of the paused assistant message. The +client merges its parts into that message instead of adding a new one, and +the helper emits the step boundary and omits the continuation's message id so +the merge stays in place and the automatic send does not re-trigger. + +In development, the helper warns when a `needsApproval` call has no +continuation turn after it, and when a continuation resolves without a +pending tool call. + --- ## Data @@ -355,13 +495,15 @@ type DataParts = { } } -const chat = createChat().assistant(({ writer }) => { - writer.data({ - type: "data-weather", - id: "weather-sf", - data: { city: "San Francisco", status: "loading" }, - }) -}) +const chat = createChat>().assistant( + ({ writer }) => { + writer.data({ + type: "data-weather", + id: "weather-sf", + data: { city: "San Francisco", status: "loading" }, + }) + } +) ``` Send the same `type` and `id` again to update the part in place. This is useful @@ -499,7 +641,9 @@ chat.get(2) // The first two messages. chat.get(0) // An empty initial conversation. ``` -`get()` returns cloned messages and does not change the chat. +`get()` returns cloned messages and does not change the chat. It stops before +the first [continuation turn](#continuations), since a continuation has no +message without a live transcript, and throws when `count` reaches past one. Use `next()` to find the next predefined user message after the messages already shown. @@ -550,7 +694,9 @@ const { messages, sendMessage } = useChat({ When `sendMessage()` runs, the transport finds the assistant message that follows the current transcript and streams it through the normal AI SDK chat lifecycle. It uses message IDs first and falls back to matching the role and -text of the latest message. +text of the latest message. Automatic sends after tool results and approval +responses resolve the next [continuation](#continuations); only a +regeneration replays a turn by its message ID. ### Options @@ -665,7 +811,7 @@ type Metadata = { model: string } -const chat = createChat() +const chat = createChat>() .user("Hello", { metadata: { model: "demo" } }) .assistant("Hi.", { id: "assistant-welcome", @@ -696,32 +842,33 @@ export and option. Creates a typed conversation and returns the fluent chat interface. ```ts -function createChat< - METADATA = unknown, - DATA_PARTS extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools, ->( - options?: CreateChatOptions -): AiSdkChat +function createChat( + options?: CreateChatOptions +): AiSdkChat ``` #### Type Parameters -| Parameter | Description | -| ------------ | --------------------------------------------------------- | -| `METADATA` | The metadata shape stored on each `UIMessage`. | -| `DATA_PARTS` | A map of names to payloads for typed `data-*` parts. | -| `TOOLS` | A map of tool names to their `input` and `output` shapes. | +| Parameter | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | +| `UI_MESSAGE` | The `UIMessage` type for the conversation, the same type parameter `useChat` accepts. Define it once and share it. | + +The message type carries the metadata shape, the typed `data-*` parts, and the +tool definitions. + +```ts +type ChatMessage = UIMessage +``` #### Options -| Option | Type | Default | Description | -| ------------------ | ------------------------------------------ | ---------------------------- | ------------------------------------------------------- | -| `messages` | `UIMessage[]` | `[]` | Start from an existing transcript. Messages are cloned. | -| `messageIdPrefix` | `string` | `"msg"` | Prefix for generated message IDs. | -| `toolCallIdPrefix` | `string` | `"call"` | Prefix for generated tool call IDs. | -| `sourceIdPrefix` | `string` | `"source"` | Prefix for generated source IDs. | -| `now` | `Date \| string` | `"2026-01-01T00:00:00.000Z"` | Fixed time used for generated `createdAt` metadata. | +| Option | Type | Default | Description | +| ------------------ | ---------------- | ---------------------------- | ------------------------------------------------------- | +| `messages` | `UIMessage[]` | `[]` | Start from an existing transcript. Messages are cloned. | +| `messageIdPrefix` | `string` | `"msg"` | Prefix for generated message IDs. | +| `toolCallIdPrefix` | `string` | `"call"` | Prefix for generated tool call IDs. | +| `sourceIdPrefix` | `string` | `"source"` | Prefix for generated source IDs. | +| `now` | `Date \| string` | `"2026-01-01T00:00:00.000Z"` | Fixed time used for generated `createdAt` metadata. | IDs found in `messages` are reserved, so newly generated IDs continue after the existing transcript. @@ -767,11 +914,11 @@ type FilePayload = { #### `assistant()` Input -| Input | Result | -| ---------------------- | ---------------------------------------------------------------------- | -| `string` | One text part that streams word by word. | -| `UIMessagePart[]` | Static AI SDK parts that stream in their existing order. | -| `({ writer }) => void` | A synchronous callback for scripting parts, tools, errors, and timing. | +| Input | Result | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `string` | One text part that streams word by word. | +| `UIMessagePart[]` | Static AI SDK parts that stream in their existing order. | +| `({ writer }) => void` | A synchronous callback for scripting parts, tools, errors, and timing. After a paused turn it becomes a [continuation](#continuations) and receives `toolCall`, `toolCalls`, and `messages`. | #### `assistant()` Options @@ -825,21 +972,24 @@ Calling `reasoning()` without content uses #### Tool Options -| Option | Type | Description | -| ------------------ | ------------------------- | ---------------------------------------------------- | -| `toolCallId` | `string` | Use a specific tool call ID. | -| `title` | `string` | Add a display title to the tool part. | -| `toolMetadata` | `Record` | Add provider or application metadata. | -| `providerExecuted` | `boolean` | Mark the call as executed by the provider. | -| `input` | `TOOLS[NAME]["input"]` | Set the typed tool input. | -| `output` | `TOOLS[NAME]["output"]` | Immediately finish with a typed output. | -| `errorText` | `string` | Immediately finish with an error. | -| `dynamic` | `boolean` | Emit a `dynamic-tool` part instead of `tool-`. | +| Option | Type | Description | +| ------------------ | ------------------------- | ----------------------------------------------------------------------------------------------- | +| `toolCallId` | `string` | Use a specific tool call ID. | +| `title` | `string` | Add a display title to the tool part. | +| `toolMetadata` | `Record` | Add provider or application metadata. | +| `providerExecuted` | `boolean` | Mark the call as executed by the provider. | +| `input` | `TOOLS[NAME]["input"]` | Set the typed tool input. | +| `output` | `TOOLS[NAME]["output"]` | Immediately finish with a typed output. With `needsApproval`, stream it after approval instead. | +| `errorText` | `string` | Immediately finish with an error. With `needsApproval`, stream it after approval instead. | +| `dynamic` | `boolean` | Emit a `dynamic-tool` part instead of `tool-`. | +| `needsApproval` | `boolean` | Pause the turn behind the user's decision. See [Human in the Loop](#human-in-the-loop). | +| `approvalId` | `string` | Use a specific approval ID. | `tool()` returns a handle with `sleep(delayMs)`, `output(value)`, `error(errorText?)`, and `denied()`. Use the handle when the tool lifecycle needs events between its input and result. Calling `tool.error()` without a -message uses `"Tool call failed."`. +message uses `"Tool call failed."`. The resolution methods throw on a +`needsApproval` call. #### Data Input diff --git a/apps/v4/registry/bases/aria/examples/message-example.tsx b/apps/v4/registry/bases/aria/examples/message-example.tsx index 3d7c5deaac2..d5b64c9c9d9 100644 --- a/apps/v4/registry/bases/aria/examples/message-example.tsx +++ b/apps/v4/registry/bases/aria/examples/message-example.tsx @@ -1,3 +1,5 @@ +import type { UIMessage } from "ai" + import { createChat } from "@/lib/ai" import { type MessagePartRenderProps } from "@/components/message-parts" import { @@ -65,11 +67,13 @@ type MessagePartsTools = { } } -const messagePartsChat = createChat< +type MessagePartsMessage = UIMessage< unknown, MessagePartsData, MessagePartsTools ->() +> + +const messagePartsChat = createChat() .user("Can you review this screenshot and check the deployment?", { files: [ { @@ -126,7 +130,6 @@ const messagePartsChat = createChat< const messagePartsMessages = messagePartsChat.get() -type MessagePartsMessage = (typeof messagePartsMessages)[number] type MessagePartsPart = MessagePartsMessage["parts"][number] export default function MessageExample() { diff --git a/apps/v4/registry/bases/base/examples/message-example.tsx b/apps/v4/registry/bases/base/examples/message-example.tsx index f4fc1701e64..efe428ad7b4 100644 --- a/apps/v4/registry/bases/base/examples/message-example.tsx +++ b/apps/v4/registry/bases/base/examples/message-example.tsx @@ -1,3 +1,5 @@ +import type { UIMessage } from "ai" + import { createChat } from "@/lib/ai" import { type MessagePartRenderProps } from "@/components/message-parts" import { @@ -65,11 +67,13 @@ type MessagePartsTools = { } } -const messagePartsChat = createChat< +type MessagePartsMessage = UIMessage< unknown, MessagePartsData, MessagePartsTools ->() +> + +const messagePartsChat = createChat() .user("Can you review this screenshot and check the deployment?", { files: [ { @@ -126,7 +130,6 @@ const messagePartsChat = createChat< const messagePartsMessages = messagePartsChat.get() -type MessagePartsMessage = (typeof messagePartsMessages)[number] type MessagePartsPart = MessagePartsMessage["parts"][number] export default function MessageExample() { diff --git a/apps/v4/registry/bases/radix/blocks/preview-03/cards/group-chat.tsx b/apps/v4/registry/bases/radix/blocks/preview-03/cards/group-chat.tsx index f6684313bab..76845fa1dc3 100644 --- a/apps/v4/registry/bases/radix/blocks/preview-03/cards/group-chat.tsx +++ b/apps/v4/registry/bases/radix/blocks/preview-03/cards/group-chat.tsx @@ -77,7 +77,7 @@ const scriptedEvents = [ { type: "user-left", name: "Aleena", order: 5.5, delayMs: 2000 }, ] satisfies ScriptedChatEvent[] -const chat = createChat() +const chat = createChat() .user( "Hey everyone — dinner this Saturday? I was thinking something casual at my place." ) diff --git a/apps/v4/registry/bases/radix/blocks/preview-03/cards/tool-chat.tsx b/apps/v4/registry/bases/radix/blocks/preview-03/cards/tool-chat.tsx index 11025e9de93..4f3e18ab7a5 100644 --- a/apps/v4/registry/bases/radix/blocks/preview-03/cards/tool-chat.tsx +++ b/apps/v4/registry/bases/radix/blocks/preview-03/cards/tool-chat.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useChat } from "@ai-sdk/react" +import type { UIMessage } from "ai" import { createChat, getMessageText } from "@/lib/ai" import { PartReasoning } from "@/registry/bases/radix/blocks/preview-03/components/part-reasoning" @@ -51,7 +52,9 @@ type Tools = { } } -const chat = createChat, Tools>() +type ChatMessage = UIMessage, Tools> + +const chat = createChat() .user("Check the deployment health for the checkout flow.") .sleep(1000) .assistant(({ writer }) => { diff --git a/apps/v4/registry/bases/radix/examples/message-example.tsx b/apps/v4/registry/bases/radix/examples/message-example.tsx index d8a297a8354..867fc180017 100644 --- a/apps/v4/registry/bases/radix/examples/message-example.tsx +++ b/apps/v4/registry/bases/radix/examples/message-example.tsx @@ -1,3 +1,5 @@ +import type { UIMessage } from "ai" + import { createChat } from "@/lib/ai" import { type MessagePartRenderProps } from "@/components/message-parts" import { @@ -65,11 +67,13 @@ type MessagePartsTools = { } } -const messagePartsChat = createChat< +type MessagePartsMessage = UIMessage< unknown, MessagePartsData, MessagePartsTools ->() +> + +const messagePartsChat = createChat() .user("Can you review this screenshot and check the deployment?", { files: [ { @@ -126,7 +130,6 @@ const messagePartsChat = createChat< const messagePartsMessages = messagePartsChat.get() -type MessagePartsMessage = (typeof messagePartsMessages)[number] type MessagePartsPart = MessagePartsMessage["parts"][number] export default function MessageExample() { diff --git a/packages/helpers/README.md b/packages/helpers/README.md index ad70b7a96a5..1d2ceb3a933 100644 --- a/packages/helpers/README.md +++ b/packages/helpers/README.md @@ -20,6 +20,61 @@ const chat = createChat() }) ``` +## Human in the loop + +A tool call left unresolved pauses the turn: the input streams, the turn +finishes, and the client decides what happens next. A callback turn scripted +after a paused turn becomes a continuation. It materializes when the follow-up +request arrives and receives the resolved `toolCall` in its context. + +For client-executed tools, the user supplies the output through +`addToolOutput` and the continuation reads it: + +```ts +const chat = createChat() + .user("Help me plan the release.") + .assistant(({ writer }) => { + writer.tool("askQuestions", { dynamic: true, input: { questions } }) + }) + .assistant(({ writer, toolCall }) => { + writer.text(`Got it. Starting with ${toolCall?.output?.answers.direction}.`) + }) +``` + +For approval-gated tools, `needsApproval` pauses behind the user's decision. +`output` (or `errorText`) then means "stream this if approved"; denial streams +`tool-output-denied` automatically: + +```ts +const chat = createChat() + .user("Clean up old deployments.") + .assistant(({ writer }) => { + writer.text("This will delete 3 deployments. Approve?") + writer.tool("deleteDeployments", { + input: { count: 3 }, + needsApproval: true, + output: { deleted: 3 }, + }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.approved + ? "Deleted 3 deployments." + : "Okay, leaving them in place." + ) + }) +``` + +Wire the client with the AI SDK's own triggers: +`sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` for +client-executed tools, or `lastAssistantMessageIsCompleteWithApprovalResponses` +plus `addToolApprovalResponse({ id: part.approval.id, approved })` for +approvals. + +Continuation callbacks must be pure; regenerating re-resolves them against the +current transcript. `get()` stops before the first continuation turn, since a +continuation has no message without a live transcript. + ## Installation ```bash diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 3bea3bf8573..bc7d0c5d60c 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -71,7 +71,8 @@ "devDependencies": { "@tanstack/ai": "0.40.0", "@tanstack/ai-client": "0.20.0", - "ai": "7.0.22", + "ai": "7.0.0-canary.159", + "zod": "3.25.76", "rimraf": "^6.0.1", "tsup": "^8.5.0", "typescript": "^5.9.2", diff --git a/packages/helpers/src/ai-sdk/chat.test.ts b/packages/helpers/src/ai-sdk/chat.test.ts index 43b14d05e8f..b2bcdb80780 100644 --- a/packages/helpers/src/ai-sdk/chat.test.ts +++ b/packages/helpers/src/ai-sdk/chat.test.ts @@ -10,17 +10,35 @@ import { weatherOutput, weatherSuccess, } from "./test-utils" -import type { DataParts, Tools } from "./test-utils" +import type { DataParts, TestMessage, Tools } from "./test-utils" testChatContract("AI SDK", () => createChat().user("One").assistant("A").user("Two").assistant("B") ) describe("AI SDK chat", () => { + it("narrows the continuation toolCall by name", () => { + createChat() + .user("Weather?") + .assistant(({ writer }) => { + writer.tool("getWeather", { input: { city: "San Francisco" } }) + }) + .assistant(({ toolCall }) => { + if (toolCall?.name === "getWeather") { + expectTypeOf(toolCall.input).toEqualTypeOf< + Tools["getWeather"]["input"] + >() + expectTypeOf(toolCall.output).toEqualTypeOf< + Tools["getWeather"]["output"] | undefined + >() + } + }) + }) + it("preserves the native message type through get and next", () => { type Metadata = { model: string } - const chat = createChat() + const chat = createChat>() .user("Weather?", { metadata: { model: "test" } }) .assistant("Sunny.", { metadata: { model: "test" } }) const messages = chat.get() @@ -97,7 +115,7 @@ describe("AI SDK chat", () => { }) it("supports assistant ids and metadata in chat fixtures", () => { - const chat = createChat<{ model: string }>() + const chat = createChat>() .user("Hello") .assistant("Hey.", { id: "assistant-1", @@ -243,34 +261,32 @@ describe("AI SDK chat", () => { }) it("materializes assistant writer parts into the final chat transcript", () => { - const chat = createChat().assistant( - ({ writer }) => { - writer.reasoning("I need to call the weather tool.") - writer.data({ - type: "data-weather", - id: "weather-1", - data: weatherLoading, - }) + const chat = createChat().assistant(({ writer }) => { + writer.reasoning("I need to call the weather tool.") + writer.data({ + type: "data-weather", + id: "weather-1", + data: weatherLoading, + }) - const weather = writer.tool("getWeather", { - title: "Checking weather", - input: { - city: "San Francisco", - }, - }) + const weather = writer.tool("getWeather", { + title: "Checking weather", + input: { + city: "San Francisco", + }, + }) - writer.sleep(100) + writer.sleep(100) - weather.output(weatherOutput) + weather.output(weatherOutput) - writer.data({ - type: "data-weather", - id: "weather-1", - data: weatherSuccess, - }) - writer.text("It's sunny and 72 degrees in San Francisco.") - } - ) + writer.data({ + type: "data-weather", + id: "weather-1", + data: weatherSuccess, + }) + writer.text("It's sunny and 72 degrees in San Francisco.") + }) const [message] = chat.get() @@ -298,7 +314,7 @@ describe("AI SDK chat", () => { it("produces identical transcripts for identical scripts", () => { function script() { - return createChat() + return createChat() .user("Hello") .assistant(({ writer }) => { writer.reasoning("Checking the forecast.") @@ -336,7 +352,7 @@ describe("AI SDK chat", () => { }) it("hydrates a chat from an existing transcript", () => { - const source = createChat() + const source = createChat() .user("What's the weather?") .assistant(({ writer }) => { writer diff --git a/packages/helpers/src/ai-sdk/chat.ts b/packages/helpers/src/ai-sdk/chat.ts index 32899a4d82c..3e07a3bb264 100644 --- a/packages/helpers/src/ai-sdk/chat.ts +++ b/packages/helpers/src/ai-sdk/chat.ts @@ -10,14 +10,32 @@ import { createChatRuntime, wait } from "../core" import type { Chat, ChatOptions } from "../core" import { createAiSdkFormat } from "./format" +// Local equivalents of the AI SDK's InferUIMessage* utilities; not all of +// them are exported from the pinned ai version. +type MessageMetadata = + UI_MESSAGE extends UIMessage ? METADATA : unknown + +type MessageData = + UI_MESSAGE extends UIMessage + ? DATA_PARTS + : UIDataTypes + +type MessageTools = + UI_MESSAGE extends UIMessage + ? TOOLS + : UITools + /** Options for creating an AI SDK chat, optionally hydrated from existing messages. */ -export type CreateChatOptions< - METADATA = unknown, - DATA_PARTS extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools, -> = ChatOptions & { - messages?: Array> -} +export type CreateChatOptions = + ChatOptions & { + messages?: Array< + UIMessage< + MessageMetadata, + MessageData, + MessageTools + > + > + } export type YieldMessagePartsOptions = { /** Also yield an initial snapshot with zero parts. */ @@ -30,17 +48,23 @@ export type StreamMessagePartsOptions = YieldMessagePartsOptions & { } /** The chat type returned by the AI SDK adapter's `createChat`. */ -export type AiSdkChat< - METADATA = unknown, - DATA_PARTS extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools, -> = Chat< - UIMessage, - UIMessagePart, - ChatTransport>, - METADATA, - DATA_PARTS, - TOOLS +export type AiSdkChat = Chat< + UIMessage< + MessageMetadata, + MessageData, + MessageTools + >, + UIMessagePart, MessageTools>, + ChatTransport< + UIMessage< + MessageMetadata, + MessageData, + MessageTools + > + >, + MessageMetadata, + MessageData, + MessageTools > /** @@ -96,16 +120,24 @@ export async function* streamMessageParts< } } -/** Creates an AI SDK chat, optionally hydrated from existing messages. */ -export function createChat< - METADATA = unknown, - DATA_PARTS extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools, ->(options: CreateChatOptions = {}) { +/** + * Creates an AI SDK chat, optionally hydrated from existing messages. Generic + * over the UI message type, mirroring `useChat`. + */ +export function createChat( + options: CreateChatOptions = {} +) { const { messages, ...chatOptions } = options - return createChatRuntime(createAiSdkFormat(), { - ...chatOptions, - messages, - }) + return createChatRuntime( + createAiSdkFormat< + MessageMetadata, + MessageData, + MessageTools + >(), + { + ...chatOptions, + messages, + } + ) } diff --git a/packages/helpers/src/ai-sdk/format.test.ts b/packages/helpers/src/ai-sdk/format.test.ts index d4718a92f23..cd32689d62a 100644 --- a/packages/helpers/src/ai-sdk/format.test.ts +++ b/packages/helpers/src/ai-sdk/format.test.ts @@ -1,14 +1,18 @@ -import { readUIMessageStream } from "ai" +import { + lastAssistantMessageIsCompleteWithApprovalResponses, + lastAssistantMessageIsCompleteWithToolCalls, + readUIMessageStream, +} from "ai" import type { ChatTransport, UIMessage } from "ai" import { describe, expect, it } from "vitest" import { createChat } from "./index" import { readStream, weatherLoading, weatherOutput } from "./test-utils" -import type { DataParts, Tools } from "./test-utils" +import type { DataParts, TestMessage, Tools } from "./test-utils" describe("AI SDK transport", () => { it("chains tool output after an in-stream sleep", async () => { - const chat = createChat() + const chat = createChat() .user("Weather?") .assistant(({ writer }) => { writer @@ -42,7 +46,7 @@ describe("AI SDK transport", () => { }) it("streams denied static tool parts", async () => { - const chat = createChat() + const chat = createChat() .user("Use the available tools.") .assistant([ { @@ -88,7 +92,7 @@ describe("AI SDK transport", () => { }) it("streams the next scripted assistant response through the transport", async () => { - const chat = createChat() + const chat = createChat() .user("Hello") .assistant("Hey, how's it going?") .user("What's the weather?") @@ -423,7 +427,7 @@ describe("AI SDK transport", () => { ).toBeGreaterThan(1) }) it("streams hydrated assistant turns through the transport", async () => { - const source = createChat() + const source = createChat() .user("What's the weather?") .assistant(({ writer }) => { writer @@ -434,7 +438,7 @@ describe("AI SDK transport", () => { writer.text("It's sunny.", { mode: "instant" }) }) - const replay = createChat({ + const replay = createChat({ messages: source.get(), }) const [userMessage] = replay.get(1) @@ -682,3 +686,246 @@ describe("AI SDK live client integration", () => { expect(getText(regenerated)).toBe("Still nothing scripted.") }) }) + +describe("AI SDK human-in-the-loop", () => { + function createApprovalChat() { + return createChat() + .user("Fetch the weather?") + .assistant(({ writer }) => { + writer.text("I need your approval first.", { mode: "instant" }) + writer.tool("getWeather", { + input: { city: "San Francisco" }, + needsApproval: true, + output: weatherOutput, + }) + }) + .assistant(({ writer, toolCall }) => { + writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.", { + mode: "instant", + }) + }) + } + + async function readTurnChunks( + transport: ChatTransport>, + messages: Array> + ) { + // Mirrors useChat's automatic sends, which pass the last message's id + // with the submit-message trigger. The transport must not treat that as + // a regeneration of the identified turn. + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: messages[messages.length - 1]?.id, + messages, + abortSignal: undefined, + }) + + return readStream(stream) + } + + function respondToApproval( + message: UIMessage, + approved: boolean + ) { + return { + ...message, + parts: message.parts.map((part) => + part.type === "tool-getWeather" + ? ({ + ...part, + state: "approval-responded", + approval: { id: "approval-1", approved }, + } as (typeof message.parts)[number]) + : part + ), + } + } + + it("streams the approval request and the client parks the call", async () => { + const chat = createApprovalChat() + const chunks = await readTurnChunks( + chat.transport({ delayMs: undefined }), + chat.get(1) + ) + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "start", + "text-start", + "text-delta", + "text-end", + "tool-input-available", + "tool-approval-request", + "finish", + ]) + expect(chunks[5]).toMatchObject({ + approvalId: "approval-1", + toolCallId: "call-1", + }) + + const stream = await chat.transport({ delayMs: undefined }).sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: chat.get(1), + abortSignal: undefined, + }) + let clientMessage: UIMessage | undefined + + for await (const message of readUIMessageStream({ stream })) { + clientMessage = message as UIMessage + } + + expect(clientMessage?.parts).toMatchObject([ + { type: "text", text: "I need your approval first." }, + { + type: "tool-getWeather", + state: "approval-requested", + approval: { id: "approval-1" }, + }, + ]) + }) + + it("streams the gated output and approved wording after approval", async () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const chunks = await readTurnChunks( + chat.transport({ delayMs: undefined }), + [userMessage, respondToApproval(assistantMessage, true)] + ) + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "start", + "start-step", + "tool-output-available", + "text-start", + "text-delta", + "text-end", + "finish", + ]) + // Continuations omit the message id so the client updates the paused + // assistant message in place instead of forking a duplicate. + expect(chunks[0]).toMatchObject({ messageId: undefined }) + expect(chunks[2]).toMatchObject({ + toolCallId: "call-1", + output: weatherOutput, + }) + expect(chunks[4]).toMatchObject({ delta: "Fetched it." }) + }) + + it("streams the denial and denied wording after denial", async () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const chunks = await readTurnChunks( + chat.transport({ delayMs: undefined }), + [userMessage, respondToApproval(assistantMessage, false)] + ) + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "start", + "start-step", + "tool-output-denied", + "text-start", + "text-delta", + "text-end", + "finish", + ]) + expect(chunks[2]).toMatchObject({ toolCallId: "call-1" }) + expect(chunks[4]).toMatchObject({ delta: "Okay, skipping." }) + }) + + it("does not retrigger automatic sending after a continuation merges", async () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const respondedMessage = respondToApproval(assistantMessage, true) + const chunks = await readTurnChunks( + chat.transport({ delayMs: undefined }), + [userMessage, respondedMessage] + ) + + // The client merges these chunks into the prior assistant message as a + // new step. Rebuild that merged shape and assert the triggers ignore the + // already-resolved tool call behind the step boundary. + const mergedMessage = { + ...respondedMessage, + parts: [ + ...respondedMessage.parts.map((part) => + part.type === "tool-getWeather" + ? ({ + ...part, + state: "output-available", + output: weatherOutput, + approval: { id: "approval-1", approved: true }, + } as (typeof respondedMessage.parts)[number]) + : part + ), + { type: "step-start" } as (typeof respondedMessage.parts)[number], + { + type: "text", + text: "Fetched it.", + } as (typeof respondedMessage.parts)[number], + ], + } + + expect(chunks.some((chunk) => chunk.type === "start-step")).toBe(true) + expect( + lastAssistantMessageIsCompleteWithToolCalls({ + messages: [userMessage, mergedMessage], + }) + ).toBe(false) + expect( + lastAssistantMessageIsCompleteWithApprovalResponses({ + messages: [userMessage, mergedMessage], + }) + ).toBe(false) + }) + + it("continues an elicitation with the user-submitted output", async () => { + const chat = createChat() + .user("Help me plan the release.") + .assistant(({ writer }) => { + writer.tool("createFile", { + dynamic: true, + input: { filename: "plan.md", content: "" }, + }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.name === "createFile" && toolCall.output + ? `Saved ${toolCall.output.filename}.` + : "No file yet.", + { mode: "instant" } + ) + }) + const [userMessage, assistantMessage] = chat.get(2) + const answeredMessage = { + ...assistantMessage, + parts: assistantMessage.parts.map((part) => + part.type === "dynamic-tool" + ? ({ + ...part, + state: "output-available", + output: { + filename: "plan.md", + url: "https://example.com/plan.md", + }, + } as (typeof assistantMessage.parts)[number]) + : part + ), + } + const chunks = await readTurnChunks( + chat.transport({ delayMs: undefined }), + [userMessage, answeredMessage] + ) + + expect(chunks.map((chunk) => chunk.type)).toEqual([ + "start", + "start-step", + "text-start", + "text-delta", + "text-end", + "finish", + ]) + expect(chunks[3]).toMatchObject({ delta: "Saved plan.md." }) + }) +}) diff --git a/packages/helpers/src/ai-sdk/format.ts b/packages/helpers/src/ai-sdk/format.ts index 55493faceac..bf05b067916 100644 --- a/packages/helpers/src/ai-sdk/format.ts +++ b/packages/helpers/src/ai-sdk/format.ts @@ -10,7 +10,12 @@ import type { import { assertNever } from "../core" import type { ChatFormat, ChunkEncoder } from "../core" -import { eventsFromParts, getMessageText, materializeParts } from "./parts" +import { + eventsFromParts, + getMessageText, + getToolCalls, + materializeParts, +} from "./parts" /** * The AI SDK `ChatFormat` plugin. Pass to `createChatRuntime` for advanced @@ -92,6 +97,10 @@ export function createAiSdkFormat< return message.metadata }, + getToolCalls(message) { + return getToolCalls(message) + }, + encodeChunk(chunk) { switch (chunk.type) { case "start": @@ -172,6 +181,12 @@ export function createAiSdkFormat< dynamic: chunk.dynamic, title: chunk.title, } as Chunk + case "tool-approval-request": + return { + type: "tool-approval-request", + approvalId: chunk.approvalId, + toolCallId: chunk.toolCallId, + } as Chunk case "tool-output-available": return { type: "tool-output-available", @@ -230,8 +245,13 @@ export function createAiSdkFormat< createTransport(transportContext, options = {}) { return { - async sendMessages({ messages, messageId, abortSignal }) { - const turn = transportContext.resolveTurn(messages, messageId) + async sendMessages({ messages, messageId, abortSignal, trigger }) { + // Automatic sends after tool results and approvals pass the last + // assistant message's id; only regeneration may replay a turn by id. + const turn = transportContext.resolveTurn( + messages, + trigger === "regenerate-message" ? messageId : undefined + ) if (!turn) { throw new Error("No assistant response found for this transcript.") diff --git a/packages/helpers/src/ai-sdk/parts.test.ts b/packages/helpers/src/ai-sdk/parts.test.ts index 458a1fc432a..a440351bd01 100644 --- a/packages/helpers/src/ai-sdk/parts.test.ts +++ b/packages/helpers/src/ai-sdk/parts.test.ts @@ -2,17 +2,18 @@ import { describe, expect, it } from "vitest" import { streamMessageParts, yieldMessageParts } from "./chat" import { createChat } from "./index" +import { getToolCalls } from "./parts" import { artifact, weatherLoading, weatherOutput, weatherSuccess, } from "./test-utils" -import type { DataParts, Tools } from "./test-utils" +import type { DataParts, TestMessage, Tools } from "./test-utils" describe("AI SDK parts", () => { it("creates typed messages with explicit parts", () => { - const [message] = createChat() + const [message] = createChat() .assistant(({ writer }) => { writer.text("Here is the forecast.") writer.data({ @@ -56,7 +57,7 @@ describe("AI SDK parts", () => { }) it("creates rich messages with the fluent writer", () => { - const [message] = createChat() + const [message] = createChat() .assistant( ({ writer }) => { writer.stepStart() @@ -135,7 +136,7 @@ describe("AI SDK parts", () => { }) it("materializes all writer part variants", () => { - const [message] = createChat() + const [message] = createChat() .assistant( ({ writer }) => { writer.stepStart() @@ -197,3 +198,73 @@ describe("AI SDK parts", () => { ) }) }) + +describe("AI SDK approval parts", () => { + function createApprovalMessage() { + const [, assistantMessage] = createChat() + .user("Fetch the weather?") + .assistant(({ writer }) => { + writer.tool("getWeather", { + input: { city: "San Francisco" }, + needsApproval: true, + output: weatherOutput, + }) + }) + .get(2) + + return assistantMessage + } + + it("materializes a needsApproval call as an approval-requested part", () => { + expect(createApprovalMessage().parts).toMatchObject([ + { + type: "tool-getWeather", + toolCallId: "call-1", + state: "approval-requested", + input: { city: "San Francisco" }, + approval: { id: "approval-1" }, + }, + ]) + }) + + it("summarizes tool calls with their approval state", () => { + const message = createApprovalMessage() + const respondedMessage = { + ...message, + parts: message.parts.map((part) => + part.type === "tool-getWeather" + ? ({ + ...part, + state: "approval-responded", + approval: { id: "approval-1", approved: true }, + } as (typeof message.parts)[number]) + : part + ), + } + + expect(getToolCalls(respondedMessage)).toMatchObject([ + { + toolCallId: "call-1", + name: "getWeather", + state: "approval-responded", + input: { city: "San Francisco" }, + approval: { id: "approval-1", approved: true }, + }, + ]) + }) + + it("hydrates approval-requested parts back into approval events", () => { + const chat = createChat({ + messages: [createApprovalMessage()], + }) + const [message] = chat.get(1) + + expect(message.parts).toMatchObject([ + { + type: "tool-getWeather", + state: "approval-requested", + approval: { id: "approval-1" }, + }, + ]) + }) +}) diff --git a/packages/helpers/src/ai-sdk/parts.ts b/packages/helpers/src/ai-sdk/parts.ts index 42a4c5bb479..d7e7a17ecc7 100644 --- a/packages/helpers/src/ai-sdk/parts.ts +++ b/packages/helpers/src/ai-sdk/parts.ts @@ -16,7 +16,7 @@ import type { } from "ai" import { assertNever } from "../core" -import type { ChatEvent } from "../core" +import type { ChatEvent, ToolCallSummary } from "../core" /** Concatenates the text of every `text` part in a message. */ export function getMessageText(message: Pick) { @@ -26,6 +26,56 @@ export function getMessageText(message: Pick) { .join("") } +function toToolCallSummary( + name: string, + part: ToolUIPart | DynamicToolUIPart, + dynamic?: boolean +): ToolCallSummary { + return { + toolCallId: part.toolCallId, + name, + dynamic, + input: "input" in part ? part.input : undefined, + output: part.state === "output-available" ? part.output : undefined, + errorText: part.state === "output-error" ? part.errorText : undefined, + state: part.state, + approval: + "approval" in part && part.approval + ? { + id: part.approval.id, + approved: part.approval.approved, + reason: part.approval.reason, + } + : undefined, + } +} + +/** Reads a message's typed and dynamic tool calls into framework-neutral summaries. */ +export function getToolCalls(message: Pick) { + const summaries: ToolCallSummary[] = [] + + for (const part of message.parts) { + if (part.type === "dynamic-tool") { + const dynamicToolPart = part as DynamicToolUIPart + + summaries.push( + toToolCallSummary(dynamicToolPart.toolName, dynamicToolPart, true) + ) + continue + } + + if (part.type.startsWith("tool-") && "toolCallId" in part) { + const toolPart = part as ToolUIPart + + summaries.push( + toToolCallSummary(part.type.replace("tool-", ""), toolPart) + ) + } + } + + return summaries +} + /** Replaces the part with the same `type` + `id` in place, or appends. */ export function replaceOrPushPart< DATA_PARTS extends UIDataTypes, @@ -115,6 +165,18 @@ export function materializeParts< } as UIMessagePart) break } + case "tool-approval-request": { + const index = findToolPartIndex(parts, event.toolCallId) + + if (index !== -1) { + parts[index] = { + ...parts[index], + state: "approval-requested", + approval: { id: event.approvalId }, + } as UIMessagePart + } + break + } case "tool-output": { const index = findToolPartIndex(parts, event.toolCallId) @@ -266,6 +328,17 @@ export function eventsFromParts< input: "input" in toolPart ? toolPart.input : {}, }) + if ( + toolPart.state === "approval-requested" || + toolPart.state === "approval-responded" + ) { + events.push({ + kind: "tool-approval-request", + toolCallId: toolPart.toolCallId, + approvalId: toolPart.approval.id, + }) + } + if (toolPart.state === "output-available") { events.push({ kind: "tool-output", @@ -308,6 +381,17 @@ export function eventsFromParts< input: "input" in dynamicToolPart ? dynamicToolPart.input : {}, }) + if ( + dynamicToolPart.state === "approval-requested" || + dynamicToolPart.state === "approval-responded" + ) { + events.push({ + kind: "tool-approval-request", + toolCallId: dynamicToolPart.toolCallId, + approvalId: dynamicToolPart.approval.id, + }) + } + if (dynamicToolPart.state === "output-available") { events.push({ kind: "tool-output", diff --git a/packages/helpers/src/ai-sdk/test-utils.ts b/packages/helpers/src/ai-sdk/test-utils.ts index a359984c87a..233b525f9b5 100644 --- a/packages/helpers/src/ai-sdk/test-utils.ts +++ b/packages/helpers/src/ai-sdk/test-utils.ts @@ -1,3 +1,7 @@ +import type { UIMessage } from "ai" + +export type TestMessage = UIMessage + export type DataParts = { weather: { city: string diff --git a/packages/helpers/src/core/chat.test.ts b/packages/helpers/src/core/chat.test.ts index f4ba8796b7a..0f41b65e204 100644 --- a/packages/helpers/src/core/chat.test.ts +++ b/packages/helpers/src/core/chat.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { createChatRuntime } from "./chat" import { createTestChat, createTestFormat, readStream } from "./test-utils" @@ -373,3 +373,249 @@ describe("createChatRuntime hydration and fallback", () => { expect(chat.get()).toHaveLength(2) }) }) + +describe("human-in-the-loop turns", () => { + function createApprovalChat() { + return createTestChat() + .user("Fetch the weather?") + .assistant(({ writer }) => { + writer.tool("getWeather", { + input: { city: "Paris" }, + needsApproval: true, + output: { temperature: 21 }, + }) + }) + .assistant(({ writer, toolCall }) => { + writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.") + }) + } + + function withApprovalResponse(message: TestMessage, approved: boolean) { + return { + ...message, + parts: message.parts.map((part) => + part.type === "tool-getWeather" + ? { + ...part, + state: "approval-responded", + approval: { id: "approval-1", approved }, + } + : part + ), + } + } + + it("scripts the approval request and pauses the turn", () => { + const chat = createApprovalChat() + const turn = chat.transport().context.resolveTurn(chat.get(1)) + + expect(turn?.events).toMatchObject([ + { kind: "tool-input", toolCallId: "call-1" }, + { + kind: "tool-approval-request", + toolCallId: "call-1", + approvalId: "approval-1", + output: { temperature: 21 }, + }, + ]) + expect(turn?.message.parts).toMatchObject([ + { + type: "tool-getWeather", + state: "approval-requested", + approval: { id: "approval-1" }, + }, + ]) + }) + + it("bounds get() at the first continuation turn", () => { + const chat = createApprovalChat() + + expect(chat.get()).toHaveLength(2) + expect(() => chat.get(3)).toThrow(/continuation/) + }) + + it("streams the gated output and approved context after approval", () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const turn = chat + .transport() + .context.resolveTurn([ + userMessage, + withApprovalResponse(assistantMessage, true), + ]) + + expect(turn?.message.id).toBe("msg-3") + expect(turn?.events).toMatchObject([ + { kind: "step-start" }, + { + kind: "tool-output", + toolCallId: "call-1", + output: { temperature: 21 }, + }, + { kind: "text", text: "Fetched it." }, + ]) + }) + + it("streams the denial and denied context after denial", () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const turn = chat + .transport() + .context.resolveTurn([ + userMessage, + withApprovalResponse(assistantMessage, false), + ]) + + expect(turn?.events).toMatchObject([ + { kind: "step-start" }, + { kind: "tool-denied", toolCallId: "call-1" }, + { kind: "text", text: "Okay, skipping." }, + ]) + }) + + it("re-resolves a continuation to the same message", () => { + const chat = createApprovalChat() + const [userMessage, assistantMessage] = chat.get(2) + const transcript = [ + userMessage, + withApprovalResponse(assistantMessage, true), + ] + const { context } = chat.transport() + const first = context.resolveTurn(transcript) + const second = context.resolveTurn(transcript) + + expect(second?.message).toEqual(first?.message) + }) + + it("reads a user-submitted elicitation output into the continuation", () => { + const chat = createTestChat() + .user("Plan the release?") + .assistant(({ writer }) => { + writer.tool("getWeather", { input: { city: "Paris" } }) + }) + .assistant(({ writer, toolCall }) => { + writer.text( + toolCall?.name === "getWeather" && toolCall.output + ? `Temperature ${toolCall.output.temperature}.` + : "No answer." + ) + }) + const [userMessage, assistantMessage] = chat.get(2) + const answeredMessage = { + ...assistantMessage, + parts: assistantMessage.parts.map((part) => + part.type === "tool-getWeather" + ? { ...part, state: "output-available", output: { temperature: 72 } } + : part + ), + } + const turn = chat + .transport() + .context.resolveTurn([userMessage, answeredMessage]) + + expect(turn?.events).toMatchObject([ + { kind: "step-start" }, + { kind: "text", text: "Temperature 72." }, + ]) + }) + + it("chains continuations and warns when nothing is pending", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const chat = createTestChat() + .user("Plan the release?") + .assistant(({ writer }) => { + writer.tool("getWeather", { input: { city: "Paris" } }) + }) + .assistant(({ writer }) => { + writer.text("Thanks.") + }) + .assistant(({ writer, toolCalls }) => { + writer.text(`Pending ${toolCalls?.length ?? 0}.`) + }) + const [userMessage, assistantMessage] = chat.get(2) + const answeredMessage = { + ...assistantMessage, + parts: assistantMessage.parts.map((part) => + part.type === "tool-getWeather" + ? { ...part, state: "output-available", output: { temperature: 72 } } + : part + ), + } + const { context } = chat.transport() + const firstContinuation = context.resolveTurn([ + userMessage, + answeredMessage, + ]) + const secondContinuation = context.resolveTurn([ + userMessage, + answeredMessage, + firstContinuation!.message, + ]) + + expect(firstContinuation?.events).toMatchObject([ + { kind: "step-start" }, + { kind: "text", text: "Thanks." }, + ]) + expect(secondContinuation?.events).toMatchObject([ + { kind: "step-start" }, + { kind: "text", text: "Pending 0." }, + ]) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("without a pending tool call") + ) + warn.mockRestore() + }) + + it("starts eager continuation turns with a step boundary", () => { + const chat = createTestChat() + .user("Plan the release?") + .assistant(({ writer }) => { + writer.tool("getWeather", { input: { city: "Paris" } }) + }) + .assistant("Thanks.") + const [userMessage, assistantMessage] = chat.get(2) + const turn = chat + .transport() + .context.resolveTurn([userMessage, assistantMessage]) + + expect(turn?.events).toMatchObject([ + { kind: "step-start" }, + { kind: "text", text: "Thanks." }, + ]) + }) + + it("warns when an approval request has no continuation turn", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + createTestChat() + .user("Fetch the weather?") + .assistant(({ writer }) => { + writer.tool("getWeather", { + input: { city: "Paris" }, + needsApproval: true, + output: { temperature: 21 }, + }) + }) + .transport() + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("no continuation turn follows") + ) + warn.mockRestore() + }) + + it("throws when a needsApproval handle is resolved by the script", () => { + createTestChat() + .user("Fetch the weather?") + .assistant(({ writer }) => { + const handle = writer.tool("getWeather", { + input: { city: "Paris" }, + needsApproval: true, + }) + + expect(() => handle.output({ temperature: 21 })).toThrow( + /needsApproval/ + ) + }) + }) +}) diff --git a/packages/helpers/src/core/chat.ts b/packages/helpers/src/core/chat.ts index e9240bd08b8..8c2f1a30094 100644 --- a/packages/helpers/src/core/chat.ts +++ b/packages/helpers/src/core/chat.ts @@ -10,10 +10,17 @@ import type { ChatTurn, ChatUserOptions, DataTypes, + MessageRole, + PendingToolCall, ToolSet, TurnStreamOptions, } from "./types" -import { cloneValue, DEFAULT_STREAM_DELAY_MS, DEFAULT_TEXT } from "./utils" +import { + cloneValue, + DEFAULT_STREAM_DELAY_MS, + DEFAULT_TEXT, + devWarn, +} from "./utils" import { createEventWriter } from "./writer" import type { EventWriter } from "./writer" @@ -50,6 +57,23 @@ export type ChatTransportOptions< fallback?: ChatFallback } +/** + * The context handed to assistant writer callbacks. A continuation turn — a + * callback turn scripted after a turn that pauses for user input — also + * receives the live transcript and the resolved human-in-the-loop tool + * calls; eager turns receive only the writer. + */ +export type AssistantTurnContext< + WRITER, + MESSAGE, + TOOLS extends ToolSet = ToolSet, +> = { + writer: WRITER + messages?: MESSAGE[] + toolCall?: PendingToolCall + toolCalls?: Array> +} + /** A deterministic conversation: chain turns, then read them back or stream them through a transport. */ export type Chat< MESSAGE, @@ -68,10 +92,16 @@ export type Chat< /** * Scripts an assistant turn from a string (one streamed text part), a * parts array (static parts, streamed instantly), or a writer callback - * (full control over parts and timing). + * (full control over parts and timing). A callback turn scripted after a + * paused turn — an unresolved tool call or `needsApproval` — becomes a + * continuation: it materializes when the follow-up request arrives, with + * the live transcript and resolved tool calls in its context. */ assistant( - input?: string | PART[] | ((context: { writer: WRITER }) => void), + input?: + | string + | PART[] + | ((context: AssistantTurnContext) => void), options?: ChatAssistantOptions ): Chat /** Scripts an assistant turn that streams a bare error chunk and ends. */ @@ -82,7 +112,12 @@ export type Chat< sleep( delayMs: number ): Chat - /** Returns clones of the first `count` configured messages, or all messages when omitted. */ + /** + * Returns clones of the first `count` configured messages, or all + * materializable messages when omitted. Continuation turns have no message + * without a live transcript, so `get` stops before the first one and + * throws when `count` reaches past it. + */ get(count?: number): MESSAGE[] /** * Returns the next configured user message after the given transcript, or @@ -117,8 +152,26 @@ export function createChatRuntime< messages?: MESSAGE[] } = {} ): Chat { - type InternalTurn = ChatTurn & { + type InternalTurn = { + role: MessageRole + events: ChatEvent[] + messageId: string metadata?: METADATA + // Continuation turns carry `resolve` instead of a materialized message. + message?: MESSAGE + resolve?: (context: AssistantTurnContext) => void + // Continuations stream without a message id so the client keeps merging + // into the assistant message it is already continuing. + continuation?: boolean + // The events last streamed for a continuation turn. Later continuations + // read pending calls from here so re-runs cannot drift generated ids + // away from the transcript. + lastEvents?: ChatEvent[] + } + + type ResolvedTurn = ChatTurn & { + metadata?: METADATA + continuation?: boolean } const ids = createChatIds(options) @@ -134,17 +187,56 @@ export function createChatRuntime< event.kind === "tool-input" || event.kind === "tool-output" || event.kind === "tool-error" || - event.kind === "tool-denied" + event.kind === "tool-denied" || + event.kind === "tool-approval-request" ) { ids.reserveToolCallId(event.toolCallId) } + if (event.kind === "tool-approval-request") { + ids.reserveApprovalId(event.approvalId) + } + if (event.kind === "source-url" || event.kind === "source-document") { ids.reserveSourceId(event.part.sourceId) } } } + function getPendingToolInputs(events: ChatEvent[]) { + const resolvedToolCallIds = new Set() + + for (const event of events) { + if ( + event.kind === "tool-output" || + event.kind === "tool-error" || + event.kind === "tool-denied" + ) { + resolvedToolCallIds.add(event.toolCallId) + } + } + + return events.flatMap((event) => + event.kind === "tool-input" && !resolvedToolCallIds.has(event.toolCallId) + ? [ + { + input: event, + approval: events.find( + ( + candidate + ): candidate is Extract< + ChatEvent, + { kind: "tool-approval-request" } + > => + candidate.kind === "tool-approval-request" && + candidate.toolCallId === event.toolCallId + ), + }, + ] + : [] + ) + } + function resolveMessageId(id?: string) { if (id !== undefined) { ids.reserveMessageId(id) @@ -165,6 +257,7 @@ export function createChatRuntime< turns.push({ role: format.getMessageRole(message), message, + messageId: format.getMessageId(message), events, metadata: format.getMessageMetadata?.(message), }) @@ -187,7 +280,7 @@ export function createChatRuntime< let latestChatIndex = -1 for (let index = 0; index < turns.length; index++) { - if (messageIds.has(format.getMessageId(turns[index].message))) { + if (messageIds.has(turns[index].messageId)) { latestChatIndex = index } } @@ -198,8 +291,8 @@ export function createChatRuntime< latestChatIndex = turns.findIndex( (turn) => lastMessage !== undefined && - format.getMessageRole(turn.message) === - format.getMessageRole(lastMessage) && + turn.message !== undefined && + turn.role === format.getMessageRole(lastMessage) && format.getMessageText(turn.message) === format.getMessageText(lastMessage) ) @@ -208,32 +301,33 @@ export function createChatRuntime< return latestChatIndex } - function findNextAssistantTurn(messages: MESSAGE[], messageId?: string) { + function findAssistantTurnIndexAfter(startIndex: number) { + for (let index = startIndex; index < turns.length; index++) { + if (turns[index].role === "assistant") { + return index + } + } + + return -1 + } + + function findNextAssistantTurnIndex(messages: MESSAGE[], messageId?: string) { if (messageId) { - const index = turns.findIndex( - (turn) => format.getMessageId(turn.message) === messageId - ) - const turn = turns[index] + const index = turns.findIndex((turn) => turn.messageId === messageId) - if (turn?.role === "assistant") { - return turn + if (turns[index]?.role === "assistant") { + return index } // Unknown ids fall through to transcript matching so regenerating a // fallback-produced message falls back again instead of replaying the // first configured response. if (index !== -1) { - return turns - .slice(index + 1) - .find((nextTurn) => nextTurn.role === "assistant") + return findAssistantTurnIndexAfter(index + 1) } } - const latestChatIndex = findLatestChatIndex(messages) - - return turns - .slice(latestChatIndex + 1) - .find((turn) => turn.role === "assistant") + return findAssistantTurnIndexAfter(findLatestChatIndex(messages) + 1) } function findNextUserTurn(messages: readonly MESSAGE[]) { @@ -243,7 +337,10 @@ export function createChatRuntime< } function materializeAssistantInput( - input: string | PART[] | ((context: { writer: WRITER }) => void), + input: + | string + | PART[] + | ((context: AssistantTurnContext) => void), events: ChatEvent[] ): PART[] { if (typeof input === "string") { @@ -281,7 +378,7 @@ export function createChatRuntime< function createFallbackTurn( fallback: ChatFallback, messages: MESSAGE[] - ): InternalTurn { + ): ResolvedTurn { const events: ChatEvent[] = [] const input = typeof fallback === "function" @@ -303,6 +400,185 @@ export function createChatRuntime< } } + function findPreviousAssistantIndex(turnIndex: number) { + for (let index = turnIndex - 1; index >= 0; index--) { + if (turns[index].role === "assistant") { + return index + } + } + + return -1 + } + + function isPreviousTurnPausedAssistant() { + const previousTurn = turns[turns.length - 1] + + if (previousTurn?.role !== "assistant") { + return false + } + + // A continuation may itself pause, so turns after one stay continuations. + return ( + previousTurn.resolve !== undefined || + getPendingToolInputs(previousTurn.events).length > 0 + ) + } + + function resolvePendingToolCalls(turnIndex: number, messages: MESSAGE[]) { + const previousIndex = findPreviousAssistantIndex(turnIndex) + const previousTurn = turns[previousIndex] + + if (!previousTurn) { + return [] + } + + const previousEvents = previousTurn.resolve + ? (previousTurn.lastEvents ?? + materializeDeferredTurn(previousIndex, messages).events) + : previousTurn.events + const pendingInputs = getPendingToolInputs(previousEvents) + + if (!pendingInputs.length) { + return [] + } + + const previousMessage = messages.find( + (message) => format.getMessageId(message) === previousTurn.messageId + ) + const summaries = + previousMessage && format.getToolCalls + ? format.getToolCalls(previousMessage) + : [] + + return pendingInputs.map((pendingInput) => ({ + ...pendingInput, + summary: summaries.find( + (summary) => summary.toolCallId === pendingInput.input.toolCallId + ), + })) + } + + function toPendingToolCall( + call: ReturnType[number] + ) { + const approved = call.approval + ? call.summary?.approval?.approved + : undefined + + return { + name: call.input.name, + toolCallId: call.input.toolCallId, + input: call.summary?.input ?? call.input.input, + output: call.approval + ? approved === true + ? call.approval.output + : undefined + : call.summary?.state === "output-available" + ? call.summary.output + : undefined, + ...(call.approval + ? { + approved: approved === true, + denied: approved === false, + } + : {}), + } as PendingToolCall + } + + function materializeDeferredTurn( + turnIndex: number, + messages: MESSAGE[] + ): ResolvedTurn { + const turn = turns[turnIndex] + const resolve = turn.resolve + + if (!resolve) { + throw new Error("Only continuation turns materialize from a transcript.") + } + + const events = [...turn.events] + + // The client merges a continuation into the prior assistant message as a + // new step. The step boundary keeps the resolved tool call out of the + // latest step so automatic sending does not retrigger on it. + events.push({ kind: "step-start" }) + + const pending = resolvePendingToolCalls(turnIndex, messages) + + if (!pending.length) { + devWarn( + "A continuation turn resolved without a pending tool call. Its toolCall context is empty." + ) + } + + for (const call of pending) { + if (!call.approval || call.summary?.state !== "approval-responded") { + continue + } + + if (call.summary.approval?.approved) { + if (call.approval.errorText !== undefined) { + events.push({ + kind: "tool-error", + toolCallId: call.input.toolCallId, + errorText: call.approval.errorText, + providerExecuted: call.input.providerExecuted, + toolMetadata: call.input.toolMetadata, + dynamic: call.input.dynamic, + }) + } else { + events.push({ + kind: "tool-output", + toolCallId: call.input.toolCallId, + output: call.approval.output, + providerExecuted: call.input.providerExecuted, + toolMetadata: call.input.toolMetadata, + dynamic: call.input.dynamic, + }) + } + } else { + events.push({ + kind: "tool-denied", + toolCallId: call.input.toolCallId, + }) + } + } + + const toolCalls = pending.map(toPendingToolCall) + // Same cast point as materializeAssistantInput: the full writer passes + // through the adapter's narrower callback type. + const writer = createEventWriter(events, { + ids, + payloads, + }) as unknown as WRITER + + resolve({ + writer, + messages, + toolCall: toolCalls[toolCalls.length - 1], + toolCalls, + }) + reserveEventIds(events) + turn.lastEvents = events + + const parts = format.materializeParts(events) + const turnMetadata = + turn.metadata === undefined ? metadata() : cloneValue(turn.metadata) + + return { + role: "assistant", + message: format.createMessage({ + id: turn.messageId, + role: "assistant", + metadata: turnMetadata, + parts, + }), + events, + metadata: turnMetadata, + continuation: true, + } + } + const api: Chat = { user(text = DEFAULT_TEXT, userOptions: ChatUserOptions = {}) { const events = takePendingEvents() @@ -334,8 +610,9 @@ export function createChatRuntime< userOptions.metadata === undefined ? metadata() : cloneValue(userOptions.metadata) + const messageId = resolveMessageId(userOptions.id) const userMessage = format.createMessage({ - id: resolveMessageId(userOptions.id), + id: messageId, role: "user", metadata: turnMetadata, parts, @@ -344,6 +621,7 @@ export function createChatRuntime< return pushTurn({ role: "user", message: userMessage, + messageId, events, metadata: turnMetadata, }) @@ -353,17 +631,40 @@ export function createChatRuntime< input: | string | PART[] - | ((context: { writer: WRITER }) => void) = DEFAULT_TEXT, + | (( + context: AssistantTurnContext + ) => void) = DEFAULT_TEXT, assistantOptions: ChatAssistantOptions = {} ) { const events = takePendingEvents() + const continuation = isPreviousTurnPausedAssistant() + + if (typeof input === "function" && continuation) { + return pushTurn({ + role: "assistant", + messageId: resolveMessageId(assistantOptions.id), + events, + metadata: + assistantOptions.metadata === undefined + ? undefined + : cloneValue(assistantOptions.metadata), + resolve: input, + continuation, + }) + } + + if (continuation) { + events.push({ kind: "step-start" }) + } + const messageParts = materializeAssistantInput(input, events) const turnMetadata = assistantOptions.metadata === undefined ? metadata() : cloneValue(assistantOptions.metadata) + const messageId = resolveMessageId(assistantOptions.id) const assistantMessage = format.createMessage({ - id: resolveMessageId(assistantOptions.id), + id: messageId, role: "assistant", metadata: turnMetadata, parts: messageParts, @@ -372,8 +673,10 @@ export function createChatRuntime< return pushTurn({ role: "assistant", message: assistantMessage, + messageId, events, metadata: turnMetadata, + continuation: continuation || undefined, }) }, @@ -386,15 +689,17 @@ export function createChatRuntime< }) const turnMetadata = metadata() + const messageId = resolveMessageId() return pushTurn({ role: "assistant", message: format.createMessage({ - id: resolveMessageId(), + id: messageId, role: "assistant", metadata: turnMetadata, parts: [], }), + messageId, events, metadata: turnMetadata, }) @@ -410,18 +715,35 @@ export function createChatRuntime< return api }, - get(count = turns.length) { + get(count) { + const deferredIndex = turns.findIndex( + (turn) => turn.resolve !== undefined + ) + const limit = deferredIndex === -1 ? turns.length : deferredIndex + + if (count === undefined) { + count = limit + } + if (!Number.isInteger(count) || count < 0) { throw new RangeError("count must be a non-negative integer.") } - return turns.slice(0, count).map((turn) => cloneValue(turn.message)) + if (deferredIndex !== -1 && count > deferredIndex) { + throw new Error( + "get() cannot materialize a continuation turn without a live transcript." + ) + } + + return turns + .slice(0, count) + .map((turn) => cloneValue(turn.message as MESSAGE)) }, next(messages) { const turn = findNextUserTurn(messages) - return turn ? cloneValue(turn.message) : null + return turn?.message ? cloneValue(turn.message) : null }, transport( @@ -433,13 +755,35 @@ export function createChatRuntime< WRITER > = {} ) { + const lastAssistantIndex = findPreviousAssistantIndex(turns.length) + const lastAssistant = turns[lastAssistantIndex] + + if ( + lastAssistant && + !lastAssistant.resolve && + getPendingToolInputs(lastAssistant.events).some( + (pendingInput) => pendingInput.approval + ) + ) { + devWarn( + "The last scripted assistant turn requests approval but no continuation turn follows. The decision will have no response." + ) + } + return format.createTransport( { resolveTurn(messages, messageId) { - const turn = findNextAssistantTurn(messages, messageId) + const turnIndex = findNextAssistantTurnIndex(messages, messageId) + const turn = turnIndex === -1 ? undefined : turns[turnIndex] + + if (turn) { + return turn.resolve + ? materializeDeferredTurn(turnIndex, messages) + : (turn as ChatTurn) + } - if (turn || transportOptions.fallback === undefined) { - return turn + if (transportOptions.fallback === undefined) { + return undefined } return createFallbackTurn(transportOptions.fallback, messages) @@ -449,8 +793,13 @@ export function createChatRuntime< const steps = lowerEvents(turn.events, { delayMs: DEFAULT_STREAM_DELAY_MS, ...streamOptions, - messageId: format.getMessageId(turn.message), - messageMetadata: (turn as InternalTurn).metadata, + // A continuation stream carries no message id: the client is + // already continuing the paused assistant message, and a new + // id would fork it into a duplicate instead of updating it. + messageId: (turn as ResolvedTurn).continuation + ? undefined + : format.getMessageId(turn.message), + messageMetadata: (turn as ResolvedTurn).metadata, }) return createTurnStream(steps, encodeChunk, abortSignal) diff --git a/packages/helpers/src/core/ids.test.ts b/packages/helpers/src/core/ids.test.ts index 60996f4fd86..50efe86afe2 100644 --- a/packages/helpers/src/core/ids.test.ts +++ b/packages/helpers/src/core/ids.test.ts @@ -10,6 +10,7 @@ describe("createChatIds", () => { expect(ids.nextMessageId()).toBe("msg-2") expect(ids.nextToolCallId()).toBe("call-1") expect(ids.nextSourceId()).toBe("source-1") + expect(ids.nextApprovalId()).toBe("approval-1") }) it("supports custom prefixes", () => { @@ -30,9 +31,11 @@ describe("createChatIds", () => { ids.reserveMessageId("msg-2") ids.reserveToolCallId("call-4") ids.reserveSourceId("source-3") + ids.reserveApprovalId("approval-2") expect(ids.nextMessageId()).toBe("msg-3") expect(ids.nextToolCallId()).toBe("call-5") expect(ids.nextSourceId()).toBe("source-4") + expect(ids.nextApprovalId()).toBe("approval-3") }) }) diff --git a/packages/helpers/src/core/ids.ts b/packages/helpers/src/core/ids.ts index 74d93063f54..21abfaf05af 100644 --- a/packages/helpers/src/core/ids.ts +++ b/packages/helpers/src/core/ids.ts @@ -9,6 +9,7 @@ export function createChatIds(options: ChatIdsOptions = {}): ChatIds { const messageIdPrefix = options.messageIdPrefix ?? "msg" const toolCallIdPrefix = options.toolCallIdPrefix ?? "call" const sourceIdPrefix = options.sourceIdPrefix ?? "source" + const approvalIdPrefix = options.approvalIdPrefix ?? "approval" function createSequence(prefix: string) { const reserved = new Set() @@ -55,6 +56,7 @@ export function createChatIds(options: ChatIdsOptions = {}): ChatIds { const messageIds = createSequence(messageIdPrefix) const toolCallIds = createSequence(toolCallIdPrefix) const sourceIds = createSequence(sourceIdPrefix) + const approvalIds = createSequence(approvalIdPrefix) return { nextMessageId() { @@ -69,6 +71,10 @@ export function createChatIds(options: ChatIdsOptions = {}): ChatIds { return sourceIds.next() }, + nextApprovalId() { + return approvalIds.next() + }, + reserveMessageId(id) { messageIds.reserve(id) }, @@ -80,5 +86,9 @@ export function createChatIds(options: ChatIdsOptions = {}): ChatIds { reserveSourceId(id) { sourceIds.reserve(id) }, + + reserveApprovalId(id) { + approvalIds.reserve(id) + }, } } diff --git a/packages/helpers/src/core/lower.ts b/packages/helpers/src/core/lower.ts index d30619e8919..683b77da30c 100644 --- a/packages/helpers/src/core/lower.ts +++ b/packages/helpers/src/core/lower.ts @@ -154,6 +154,18 @@ export function lowerEvents< }) break } + case "tool-approval-request": { + // The gated output stays script-side; only the request goes on the wire. + steps.push({ + kind: "chunk", + chunk: { + type: "tool-approval-request", + approvalId: event.approvalId, + toolCallId: event.toolCallId, + }, + }) + break + } case "tool-output": { steps.push({ kind: "chunk", diff --git a/packages/helpers/src/core/test-utils.ts b/packages/helpers/src/core/test-utils.ts index 3cea5194f87..e92f273f034 100644 --- a/packages/helpers/src/core/test-utils.ts +++ b/packages/helpers/src/core/test-utils.ts @@ -6,6 +6,7 @@ import type { MessageRole, NeutralChunk, StreamStep, + ToolCallSummary, ToolSet, TransportContext, TurnStreamOptions, @@ -97,6 +98,20 @@ export function createTestFormat< }) } + if (event.kind === "tool-approval-request") { + const index = parts.findIndex( + (part) => part.toolCallId === event.toolCallId + ) + + if (index !== -1) { + parts[index] = { + ...parts[index], + state: "approval-requested", + approval: { id: event.approvalId }, + } + } + } + if (event.kind === "tool-output") { const index = parts.findIndex( (part) => part.toolCallId === event.toolCallId @@ -161,6 +176,19 @@ export function createTestFormat< return message.parts }, + getToolCalls(message) { + return message.parts + .filter((part) => part.type.startsWith("tool-")) + .map((part) => ({ + toolCallId: part.toolCallId as string, + name: part.type.replace("tool-", ""), + input: part.input, + output: part.state === "output-available" ? part.output : undefined, + state: part.state as ToolCallSummary["state"], + approval: part.approval as ToolCallSummary["approval"], + })) + }, + createTransport(context, options) { return { context, options } }, diff --git a/packages/helpers/src/core/types.ts b/packages/helpers/src/core/types.ts index 6dde2371f06..f46dfd583a0 100644 --- a/packages/helpers/src/core/types.ts +++ b/packages/helpers/src/core/types.ts @@ -30,6 +30,52 @@ export type ToolWriterOptions< errorText?: string /** Script the call as a `dynamic-tool` part instead of a typed `tool-` part. */ dynamic?: boolean + /** + * Pause the turn behind a user approval. `output` (or `errorText`) then + * means "stream this after approval" instead of "resolve immediately"; + * denial streams `tool-output-denied` automatically. + */ + needsApproval?: boolean + approvalId?: string +} + +/** + * A human-in-the-loop tool call as a continuation turn sees it: the scripted + * input, the user-submitted output (elicitation) or gated output (approval), + * and the approval decision when one was requested. + */ +export type PendingToolCall = { + [NAME in keyof TOOLS & string]: { + name: NAME + toolCallId: string + input: TOOLS[NAME]["input"] + output: TOOLS[NAME]["output"] | undefined + approved?: boolean + denied?: boolean + } +}[keyof TOOLS & string] + +/** + * A framework-neutral summary of one tool call read back from a transcript + * message. Adapters produce these through `ChatFormat.getToolCalls`. + */ +export type ToolCallSummary = { + toolCallId: string + name: string + dynamic?: boolean + input: unknown + output?: unknown + errorText?: string + state: + | "input-streaming" + | "input-available" + | "approval-requested" + | "approval-responded" + | "output-available" + | "output-error" + | "output-denied" + | "undefined-input" + approval?: { id: string; approved?: boolean; reason?: string } } /** @@ -125,6 +171,13 @@ export type ChatEvent< dynamic?: boolean input: unknown } + | { + kind: "tool-approval-request" + toolCallId: string + approvalId: string + output?: unknown + errorText?: string + } | { kind: "tool-output" toolCallId: string @@ -188,6 +241,11 @@ export type NeutralChunk< dynamic?: boolean title?: string } + | { + type: "tool-approval-request" + approvalId: string + toolCallId: string + } | { type: "tool-output-available" toolCallId: string @@ -237,9 +295,11 @@ export type ChatIds = { nextMessageId(): string nextToolCallId(): string nextSourceId(): string + nextApprovalId(): string reserveMessageId(id: string): void reserveToolCallId(id: string): void reserveSourceId(id: string): void + reserveApprovalId(id: string): void } /** Prefixes used by the deterministic chat id generators. */ @@ -247,6 +307,7 @@ export type ChatIdsOptions = { messageIdPrefix?: string toolCallIdPrefix?: string sourceIdPrefix?: string + approvalIdPrefix?: string } /** Chat-wide options: id prefixes plus the fixed clock used for default metadata. */ @@ -321,6 +382,8 @@ export type ChatFormat< getMessageParts(message: MESSAGE): PART[] /** Optional: extract a message's metadata when hydrating chats from existing messages. */ getMessageMetadata?(message: MESSAGE): METADATA | undefined + /** Optional: read a message's tool calls so continuation turns can resolve human-in-the-loop state. */ + getToolCalls?(message: MESSAGE): ToolCallSummary[] createTransport( context: TransportContext, options?: TurnStreamOptions diff --git a/packages/helpers/src/core/utils.ts b/packages/helpers/src/core/utils.ts index 4377d68fa61..8450bc2e037 100644 --- a/packages/helpers/src/core/utils.ts +++ b/packages/helpers/src/core/utils.ts @@ -46,3 +46,10 @@ export function splitTextDeltas(text: string) { export function getDataPartName(type: `data-${string}`) { return type.slice("data-".length) } + +/** Logs a development-only warning. */ +export function devWarn(message: string) { + if (process.env.NODE_ENV !== "production") { + console.warn(`[helpers] ${message}`) + } +} diff --git a/packages/helpers/src/core/writer.ts b/packages/helpers/src/core/writer.ts index 051c9032f99..b2bd47258e6 100644 --- a/packages/helpers/src/core/writer.ts +++ b/packages/helpers/src/core/writer.ts @@ -157,6 +157,14 @@ export function createEventWriter< input: clonedOptions.input ?? {}, }) + function assertResolvable(method: string) { + if (clonedOptions.needsApproval) { + throw new Error( + `A needsApproval tool call resolves from the user's decision; remove the ${method}() call.` + ) + } + } + const handle: ToolHandle = { sleep(delayMs: number) { events.push({ @@ -169,6 +177,7 @@ export function createEventWriter< }, output(output: TOOLS[NAME]["output"]) { + assertResolvable("output") events.push({ kind: "tool-output", toolCallId, @@ -182,6 +191,7 @@ export function createEventWriter< }, error(errorText = "Tool call failed.") { + assertResolvable("error") events.push({ kind: "tool-error", toolCallId, @@ -195,6 +205,7 @@ export function createEventWriter< }, denied() { + assertResolvable("denied") events.push({ kind: "tool-denied", toolCallId, @@ -204,6 +215,25 @@ export function createEventWriter< }, } + if (clonedOptions.needsApproval) { + const approvalId = + clonedOptions.approvalId ?? context.ids.nextApprovalId() + + if (clonedOptions.approvalId !== undefined) { + context.ids.reserveApprovalId(clonedOptions.approvalId) + } + + events.push({ + kind: "tool-approval-request", + toolCallId, + approvalId, + output: clonedOptions.output, + errorText: clonedOptions.errorText, + }) + + return handle + } + if (clonedOptions.output !== undefined) { handle.output(clonedOptions.output) } diff --git a/packages/helpers/src/tanstack-ai/format.ts b/packages/helpers/src/tanstack-ai/format.ts index 7474563c194..752d9d48be4 100644 --- a/packages/helpers/src/tanstack-ai/format.ts +++ b/packages/helpers/src/tanstack-ai/format.ts @@ -162,6 +162,7 @@ export function createRunEncoder< value: chunk.part, }) case "abort": + case "tool-approval-request": case "tool-output-denied": case "start-step": case "file": diff --git a/packages/helpers/src/tanstack-ai/parts.ts b/packages/helpers/src/tanstack-ai/parts.ts index ecee53f91d3..54d791500e3 100644 --- a/packages/helpers/src/tanstack-ai/parts.ts +++ b/packages/helpers/src/tanstack-ai/parts.ts @@ -224,6 +224,7 @@ export function materializeParts< case "reasoning-file": case "custom": case "step-start": + case "tool-approval-request": // These events have no TanStack message part. break default: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78f4ac07a26..654333a92ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,8 +425,8 @@ importers: specifier: 0.20.0 version: 0.20.0(@opentelemetry/api@1.9.0) ai: - specifier: 7.0.22 - version: 7.0.22(zod@4.4.3) + specifier: 7.0.0-canary.159 + version: 7.0.0-canary.159(zod@3.25.76) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -439,6 +439,9 @@ importers: vitest: specifier: ^3.2.6 version: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.10)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(tsx@4.20.3)(yaml@2.8.1) + zod: + specifier: 3.25.76 + version: 3.25.76 packages/react: dependencies: @@ -664,12 +667,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@4.0.16': - resolution: {integrity: sha512-9iYxdOLquoOFk5e+02P9qfBIA+EJU0FNJvyjGxi4iXJabt2+GlbaCg7TX0sTtxOsBVkdabkatqWM6+kvp53tBg==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/mcp@2.0.0-canary.56': resolution: {integrity: sha512-4YteXhAIJNmhpG+Tab+iyDyw53oXceLH/Xa88FutBGFPJanqgQyB+Dp2kgRbq8xxcQLp6ufuVwxYXYuFq3exUw==} engines: {node: '>=22'} @@ -694,12 +691,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@5.0.7': - resolution: {integrity: sha512-OSm5/5kdrHa11WIOo5LYgDKnxYWp5aB/wx5EXRHi0jpUGduMDeB6oht9U6p+UNNWIP3F/EqPpV8d7vdP/iRnqg==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@4.0.0-canary.17': resolution: {integrity: sha512-m/rtalImeIt7deuQGkEHehqlIPOM8Sjb0cF1b1SJ3B6Re+WYgbUkOK9gY2SSro1YO8jYBRgTMPz2qYzPtIzAxQ==} engines: {node: '>=22'} @@ -708,10 +699,6 @@ packages: resolution: {integrity: sha512-+XBwXEPkN+l/90bb7TaSK15jAq9ScsGrauJ/NLSGip0KX5Mf1pfEfqBWDuJMIYVRYHNdV1dDBy7JCoKUCQe/tA==} engines: {node: '>=22'} - '@ai-sdk/provider@4.0.3': - resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} - engines: {node: '>=22'} - '@ai-sdk/react@4.0.0-canary.162': resolution: {integrity: sha512-OSiEDRsgJVviCajzxbRgxLGxDj+jAsAPlp5Uo4QOA0UrvZ3Aecg+78XZK5YJIazX4J9wNam6Uj481uEdIXtR/w==} engines: {node: '>=22'} @@ -4711,12 +4698,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@7.0.22: - resolution: {integrity: sha512-iAXYwQtR18qN7GXwNmGVAQM4uSJOh2+nZ5RP9NtDXfH5d4tlYyYzAM133O3U+eVcyWrvNRzecN9yW58xpCdfMg==} - engines: {node: '>=22'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -9802,13 +9783,6 @@ snapshots: '@vercel/oidc': 3.2.0 zod: 3.25.76 - '@ai-sdk/gateway@4.0.16(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.7(zod@4.4.3) - '@vercel/oidc': 3.2.0 - zod: 4.4.3 - '@ai-sdk/mcp@2.0.0-canary.56(zod@3.25.76)': dependencies: '@ai-sdk/provider': 4.0.0-canary.17 @@ -9838,14 +9812,6 @@ snapshots: eventsource-parser: 3.1.0 zod: 3.25.76 - '@ai-sdk/provider-utils@5.0.7(zod@4.4.3)': - dependencies: - '@ai-sdk/provider': 4.0.3 - '@standard-schema/spec': 1.1.0 - '@workflow/serde': 4.1.0 - eventsource-parser: 3.1.0 - zod: 4.4.3 - '@ai-sdk/provider@4.0.0-canary.17': dependencies: json-schema: 0.4.0 @@ -9854,10 +9820,6 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@4.0.3': - dependencies: - json-schema: 0.4.0 - '@ai-sdk/react@4.0.0-canary.162(react@19.2.3)(zod@3.25.76)': dependencies: '@ai-sdk/mcp': 2.0.0-canary.56(zod@3.25.76) @@ -11185,7 +11147,7 @@ snapshots: cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 7.5.1(express@5.2.1) jose: 6.1.3 @@ -13773,13 +13735,6 @@ snapshots: '@ai-sdk/provider-utils': 5.0.0-canary.44(zod@3.25.76) zod: 3.25.76 - ai@7.0.22(zod@4.4.3): - dependencies: - '@ai-sdk/gateway': 4.0.16(zod@4.4.3) - '@ai-sdk/provider': 4.0.3 - '@ai-sdk/provider-utils': 5.0.7(zod@4.4.3) - zod: 4.4.3 - ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 From efac5987074af84ece57c367c6dd83387b967022 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:38:09 +0400 Subject: [PATCH 4/4] chore(release): version packages (#11462) Co-authored-by: shadcn --- .changeset/helpers-human-in-the-loop.md | 5 ----- .changeset/helpers-message-type-generic.md | 5 ----- .changeset/socks-proxy-support.md | 7 ------- apps/v4/package.json | 2 +- packages/helpers/CHANGELOG.md | 8 ++++++++ packages/helpers/package.json | 2 +- packages/shadcn/CHANGELOG.md | 8 ++++++++ packages/shadcn/package.json | 2 +- pnpm-lock.yaml | 2 +- 9 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 .changeset/helpers-human-in-the-loop.md delete mode 100644 .changeset/helpers-message-type-generic.md delete mode 100644 .changeset/socks-proxy-support.md diff --git a/.changeset/helpers-human-in-the-loop.md b/.changeset/helpers-human-in-the-loop.md deleted file mode 100644 index 50f76893a87..00000000000 --- a/.changeset/helpers-human-in-the-loop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shadcn/helpers": minor ---- - -Add human-in-the-loop mocking for the AI SDK. diff --git a/.changeset/helpers-message-type-generic.md b/.changeset/helpers-message-type-generic.md deleted file mode 100644 index 3cf80c3674a..00000000000 --- a/.changeset/helpers-message-type-generic.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shadcn/helpers": minor ---- - -Make `createChat` generic over the UI message type, like `useChat`. diff --git a/.changeset/socks-proxy-support.md b/.changeset/socks-proxy-support.md deleted file mode 100644 index d2eb8dfa369..00000000000 --- a/.changeset/socks-proxy-support.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"shadcn": minor ---- - -Add SOCKS4/SOCKS5 proxy support to the registry HTTP stack via `ALL_PROXY=socks5://...` (the curl convention), backed by the `socks` package. - -Proxy selection now goes through a `createProxyDispatcher(env)` factory that checks `ALL_PROXY` / `all_proxy` for a `socks*://` URL before falling back to the existing HTTP/HTTPS handling. `ALL_PROXY` with a non-SOCKS scheme is ignored here — `HTTP_PROXY` / `HTTPS_PROXY` remain the way to configure those. Existing `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` handling via `undici.EnvHttpProxyAgent` is unchanged. diff --git a/apps/v4/package.json b/apps/v4/package.json index 4ac611b5fa4..7487daf03f3 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.16.2", + "shadcn": "4.17.0", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/packages/helpers/CHANGELOG.md b/packages/helpers/CHANGELOG.md index cea09a87a68..2b37cf5f235 100644 --- a/packages/helpers/CHANGELOG.md +++ b/packages/helpers/CHANGELOG.md @@ -1,5 +1,13 @@ # @shadcn/helpers +## 0.2.0 + +### Minor Changes + +- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Add human-in-the-loop mocking for the AI SDK. + +- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Make `createChat` generic over the UI message type, like `useChat`. + ## 0.1.0 ### Minor Changes diff --git a/packages/helpers/package.json b/packages/helpers/package.json index bc7d0c5d60c..7a7b27b749e 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@shadcn/helpers", - "version": "0.1.0", + "version": "0.2.0", "description": "Small helpers for developing apps.", "publishConfig": { "access": "public" diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index e0d2a63675f..e3bc05e8287 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,13 @@ # shadcn +## 4.17.0 + +### Minor Changes + +- [#10453](https://github.com/shadcn-ui/ui/pull/10453) [`deda4df80fb350230b2fce2b575e769a90cae076`](https://github.com/shadcn-ui/ui/commit/deda4df80fb350230b2fce2b575e769a90cae076) Thanks [@nbouvrette](https://github.com/nbouvrette)! - Add SOCKS4/SOCKS5 proxy support to the registry HTTP stack via `ALL_PROXY=socks5://...` (the curl convention), backed by the `socks` package. + + Proxy selection now goes through a `createProxyDispatcher(env)` factory that checks `ALL_PROXY` / `all_proxy` for a `socks*://` URL before falling back to the existing HTTP/HTTPS handling. `ALL_PROXY` with a non-SOCKS scheme is ignored here — `HTTP_PROXY` / `HTTPS_PROXY` remain the way to configure those. Existing `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` handling via `undici.EnvHttpProxyAgent` is unchanged. + ## 4.16.2 ### Patch Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 3edf029350e..9c5c179e17b 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.16.2", + "version": "4.17.0", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 654333a92ab..43a12846e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.16.2 + specifier: 4.17.0 version: link:../../packages/shadcn shiki: specifier: ^3.23.0