@assistant-ui/react 0.14.12 → 0.14.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  2. package/dist/client/InMemoryThreadList.js +13 -3
  3. package/dist/client/InMemoryThreadList.js.map +1 -1
  4. package/dist/client/SingleThreadList.d.ts.map +1 -1
  5. package/dist/client/SingleThreadList.js +5 -2
  6. package/dist/client/SingleThreadList.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts +14 -0
  10. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.d.ts.map +1 -0
  11. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +101 -0
  12. package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js.map +1 -0
  13. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +12 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  16. package/dist/unstable/useMentionAdapter.d.ts +2 -2
  17. package/dist/unstable/useMentionAdapter.js +1 -1
  18. package/dist/unstable/useMentionAdapter.js.map +1 -1
  19. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  20. package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
  21. package/package.json +5 -5
  22. package/src/client/InMemoryThreadList.ts +23 -2
  23. package/src/client/SingleThreadList.ts +5 -1
  24. package/src/index.ts +12 -2
  25. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.test.ts +426 -0
  26. package/src/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.ts +146 -0
  27. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +16 -1
  28. package/src/unstable/useMentionAdapter.ts +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"useMentionAdapter.js","names":[],"sources":["../../src/unstable/useMentionAdapter.ts"],"sourcesContent":["\"use client\";\n\nimport { useMemo, type FC } from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport type {\n Unstable_DirectiveFormatter,\n Unstable_TriggerAdapter,\n Unstable_TriggerCategory,\n Unstable_TriggerItem,\n} from \"@assistant-ui/core\";\nimport { unstable_defaultDirectiveFormatter } from \"@assistant-ui/core\";\nimport type { ReadonlyJSONObject } from \"assistant-stream/utils\";\n\n/** Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`. */\nexport type Unstable_IconComponent = FC<{ className?: string }>;\n\nexport type Unstable_Mention = {\n readonly id: string;\n readonly type: string;\n readonly label: string;\n readonly description?: string | undefined;\n /** Shortcut for `metadata.icon`; merged with `metadata` if both are given. */\n readonly icon?: string | undefined;\n readonly metadata?: ReadonlyJSONObject | undefined;\n};\n\nexport type Unstable_MentionCategory = {\n readonly id: string;\n readonly label: string;\n readonly items: readonly Unstable_Mention[];\n};\n\nexport type Unstable_ModelContextToolsOptions = {\n /** Wrap tools in a dedicated category (drill-down mode). */\n readonly category?: { readonly id: string; readonly label: string };\n /** Format tool name for display. */\n readonly formatLabel?: (toolName: string) => string;\n /** Default icon key for each tool. */\n readonly icon?: string;\n};\n\nexport type Unstable_UseMentionAdapterOptions = {\n /** Flat mention list. Ignored when `categories` is set. */\n readonly items?: readonly Unstable_Mention[];\n /** Categorized mentions for drill-down navigation. */\n readonly categories?: readonly Unstable_MentionCategory[];\n /**\n * How tools registered via `useAssistantTool` integrate.\n * - `false`: exclude.\n * - `true`: include (default when no `items`/`categories`; as a category\n * if `categories` is set, flat otherwise).\n * - object: explicit config.\n *\n * Omitted → defaults to `true` iff neither `items` nor `categories`.\n */\n readonly includeModelContextTools?:\n | boolean\n | Unstable_ModelContextToolsOptions;\n /** Directive formatter. @default unstable_defaultDirectiveFormatter */\n readonly formatter?: Unstable_DirectiveFormatter;\n /** Fires after an item is inserted into the composer. */\n readonly onInserted?: (item: Unstable_TriggerItem) => void;\n /** Maps `metadata.icon` / `category.id` string keys to React components. */\n readonly iconMap?: Record<string, Unstable_IconComponent>;\n /** Fallback icon when no entry in `iconMap` matches. */\n readonly fallbackIcon?: Unstable_IconComponent;\n};\n\nexport type Unstable_MentionDirective = {\n readonly formatter: Unstable_DirectiveFormatter;\n readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;\n};\n\n/**\n * @deprecated Under active development and might change without notice.\n *\n * Creates a spreadable `{ adapter, directive }` bundle for `@` mentions.\n * Supports tools registered via `useAssistantTool`, explicit items, or both —\n * flat or categorized.\n *\n * @example\n * ```tsx\n * const mention = unstable_useMentionAdapter();\n * <ComposerTriggerPopover char=\"@\" {...mention} />\n * ```\n */\nexport function unstable_useMentionAdapter(\n options?: Unstable_UseMentionAdapterOptions,\n): {\n adapter: Unstable_TriggerAdapter;\n directive: Unstable_MentionDirective;\n iconMap?: Record<string, Unstable_IconComponent>;\n fallbackIcon?: Unstable_IconComponent;\n} {\n const aui = useAui();\n\n const items = options?.items;\n const categories = options?.categories;\n const includeTools =\n options?.includeModelContextTools ?? (!items && !categories);\n const toolsConfig =\n typeof includeTools === \"object\" ? includeTools : undefined;\n const wantsTools = includeTools !== false;\n const formatter = options?.formatter;\n const onInserted = options?.onInserted;\n\n const adapter = useMemo<Unstable_TriggerAdapter>(() => {\n const getModelContextTools = (): Unstable_TriggerItem[] => {\n if (!wantsTools) return [];\n const ctx = aui.thread().getModelContext();\n const tools = ctx.tools;\n if (!tools) return [];\n const formatLabel = toolsConfig?.formatLabel;\n const defaultIcon = toolsConfig?.icon;\n return Object.entries(tools).map(([name, tool]) =>\n toTriggerItem({\n id: name,\n type: \"tool\",\n label: formatLabel ? formatLabel(name) : name,\n description: tool.description ?? undefined,\n icon: defaultIcon,\n }),\n );\n };\n\n // Categorized: drill-down mode\n if (categories && categories.length > 0) {\n const groups = categories.map((cat) => ({\n id: cat.id,\n label: cat.label,\n items: cat.items.map(toTriggerItem),\n }));\n\n let toolCategory: {\n id: string;\n label: string;\n items: Unstable_TriggerItem[];\n } | null = null;\n if (wantsTools) {\n const toolItems = getModelContextTools();\n if (toolItems.length > 0) {\n toolCategory = {\n id: toolsConfig?.category?.id ?? \"tools\",\n label: toolsConfig?.category?.label ?? \"Tools\",\n items: toolItems,\n };\n }\n }\n const allGroups = toolCategory ? [...groups, toolCategory] : groups;\n\n return {\n categories: () => allGroups.map(({ id, label }) => ({ id, label })),\n categoryItems: (id) => allGroups.find((g) => g.id === id)?.items ?? [],\n search: (query) => {\n const lower = query.toLowerCase();\n return allGroups\n .flatMap((g) => g.items)\n .filter((item) => matchesQuery(item, lower));\n },\n };\n }\n\n // Flat: items + (optionally) tools, all in one search pool\n const flatItems = (items ?? []).map(toTriggerItem);\n const getFlatPool = (): Unstable_TriggerItem[] => {\n if (!wantsTools) return flatItems;\n const toolItems = getModelContextTools();\n // Dedupe by id — explicit items win.\n const seen = new Set(flatItems.map((i) => i.id));\n return [...flatItems, ...toolItems.filter((t) => !seen.has(t.id))];\n };\n\n return {\n categories: (): readonly Unstable_TriggerCategory[] => [],\n categoryItems: () => [],\n search: (query) => {\n const lower = query.toLowerCase();\n return getFlatPool().filter((item) => matchesQuery(item, lower));\n },\n };\n }, [aui, items, categories, wantsTools, toolsConfig]);\n\n const directive = useMemo<Unstable_MentionDirective>(\n () => ({\n formatter: formatter ?? unstable_defaultDirectiveFormatter,\n ...(onInserted ? { onInserted } : {}),\n }),\n [formatter, onInserted],\n );\n\n return {\n adapter,\n directive,\n ...(options?.iconMap ? { iconMap: options.iconMap } : {}),\n ...(options?.fallbackIcon ? { fallbackIcon: options.fallbackIcon } : {}),\n };\n}\n\nfunction toTriggerItem(m: Unstable_Mention): Unstable_TriggerItem {\n const metadata =\n m.icon !== undefined ? { ...(m.metadata ?? {}), icon: m.icon } : m.metadata;\n return {\n id: m.id,\n type: m.type,\n label: m.label,\n ...(m.description !== undefined ? { description: m.description } : {}),\n ...(metadata !== undefined ? { metadata } : {}),\n };\n}\n\nfunction matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {\n if (!lower) return true;\n if (item.id.toLowerCase().includes(lower)) return true;\n if (item.label.toLowerCase().includes(lower)) return true;\n if (item.description?.toLowerCase().includes(lower)) return true;\n return false;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useMentionAdapter.js","names":[],"sources":["../../src/unstable/useMentionAdapter.ts"],"sourcesContent":["\"use client\";\n\nimport { useMemo, type FC } from \"react\";\nimport { useAui } from \"@assistant-ui/store\";\nimport type {\n Unstable_DirectiveFormatter,\n Unstable_TriggerAdapter,\n Unstable_TriggerCategory,\n Unstable_TriggerItem,\n} from \"@assistant-ui/core\";\nimport { unstable_defaultDirectiveFormatter } from \"@assistant-ui/core\";\nimport type { ReadonlyJSONObject } from \"assistant-stream/utils\";\n\n/** Icon component shape consumed by `ComposerTriggerPopover`'s `iconMap`. */\nexport type Unstable_IconComponent = FC<{ className?: string }>;\n\nexport type Unstable_Mention = {\n readonly id: string;\n readonly type: string;\n readonly label: string;\n readonly description?: string | undefined;\n /** Shortcut for `metadata.icon`; merged with `metadata` if both are given. */\n readonly icon?: string | undefined;\n readonly metadata?: ReadonlyJSONObject | undefined;\n};\n\nexport type Unstable_MentionCategory = {\n readonly id: string;\n readonly label: string;\n readonly items: readonly Unstable_Mention[];\n};\n\nexport type Unstable_ModelContextToolsOptions = {\n /** Wrap tools in a dedicated category (drill-down mode). */\n readonly category?: { readonly id: string; readonly label: string };\n /** Format tool name for display. */\n readonly formatLabel?: (toolName: string) => string;\n /** Default icon key for each tool. */\n readonly icon?: string;\n};\n\nexport type Unstable_UseMentionAdapterOptions = {\n /** Flat mention list. Ignored when `categories` is set. */\n readonly items?: readonly Unstable_Mention[];\n /** Categorized mentions for drill-down navigation. */\n readonly categories?: readonly Unstable_MentionCategory[];\n /**\n * How tools registered in model context integrate.\n * - `false`: exclude.\n * - `true`: include (default when no `items`/`categories`; as a category\n * if `categories` is set, flat otherwise).\n * - object: explicit config.\n *\n * Omitted → defaults to `true` iff neither `items` nor `categories`.\n */\n readonly includeModelContextTools?:\n | boolean\n | Unstable_ModelContextToolsOptions;\n /** Directive formatter. @default unstable_defaultDirectiveFormatter */\n readonly formatter?: Unstable_DirectiveFormatter;\n /** Fires after an item is inserted into the composer. */\n readonly onInserted?: (item: Unstable_TriggerItem) => void;\n /** Maps `metadata.icon` / `category.id` string keys to React components. */\n readonly iconMap?: Record<string, Unstable_IconComponent>;\n /** Fallback icon when no entry in `iconMap` matches. */\n readonly fallbackIcon?: Unstable_IconComponent;\n};\n\nexport type Unstable_MentionDirective = {\n readonly formatter: Unstable_DirectiveFormatter;\n readonly onInserted?: ((item: Unstable_TriggerItem) => void) | undefined;\n};\n\n/**\n * @deprecated Under active development and might change without notice.\n *\n * Creates a spreadable `{ adapter, directive }` bundle for `@` mentions.\n * Supports tools registered in model context, explicit items, or both —\n * flat or categorized.\n *\n * @example\n * ```tsx\n * const mention = unstable_useMentionAdapter();\n * <ComposerTriggerPopover char=\"@\" {...mention} />\n * ```\n */\nexport function unstable_useMentionAdapter(\n options?: Unstable_UseMentionAdapterOptions,\n): {\n adapter: Unstable_TriggerAdapter;\n directive: Unstable_MentionDirective;\n iconMap?: Record<string, Unstable_IconComponent>;\n fallbackIcon?: Unstable_IconComponent;\n} {\n const aui = useAui();\n\n const items = options?.items;\n const categories = options?.categories;\n const includeTools =\n options?.includeModelContextTools ?? (!items && !categories);\n const toolsConfig =\n typeof includeTools === \"object\" ? includeTools : undefined;\n const wantsTools = includeTools !== false;\n const formatter = options?.formatter;\n const onInserted = options?.onInserted;\n\n const adapter = useMemo<Unstable_TriggerAdapter>(() => {\n const getModelContextTools = (): Unstable_TriggerItem[] => {\n if (!wantsTools) return [];\n const ctx = aui.thread().getModelContext();\n const tools = ctx.tools;\n if (!tools) return [];\n const formatLabel = toolsConfig?.formatLabel;\n const defaultIcon = toolsConfig?.icon;\n return Object.entries(tools).map(([name, tool]) =>\n toTriggerItem({\n id: name,\n type: \"tool\",\n label: formatLabel ? formatLabel(name) : name,\n description: tool.description ?? undefined,\n icon: defaultIcon,\n }),\n );\n };\n\n // Categorized: drill-down mode\n if (categories && categories.length > 0) {\n const groups = categories.map((cat) => ({\n id: cat.id,\n label: cat.label,\n items: cat.items.map(toTriggerItem),\n }));\n\n let toolCategory: {\n id: string;\n label: string;\n items: Unstable_TriggerItem[];\n } | null = null;\n if (wantsTools) {\n const toolItems = getModelContextTools();\n if (toolItems.length > 0) {\n toolCategory = {\n id: toolsConfig?.category?.id ?? \"tools\",\n label: toolsConfig?.category?.label ?? \"Tools\",\n items: toolItems,\n };\n }\n }\n const allGroups = toolCategory ? [...groups, toolCategory] : groups;\n\n return {\n categories: () => allGroups.map(({ id, label }) => ({ id, label })),\n categoryItems: (id) => allGroups.find((g) => g.id === id)?.items ?? [],\n search: (query) => {\n const lower = query.toLowerCase();\n return allGroups\n .flatMap((g) => g.items)\n .filter((item) => matchesQuery(item, lower));\n },\n };\n }\n\n // Flat: items + (optionally) tools, all in one search pool\n const flatItems = (items ?? []).map(toTriggerItem);\n const getFlatPool = (): Unstable_TriggerItem[] => {\n if (!wantsTools) return flatItems;\n const toolItems = getModelContextTools();\n // Dedupe by id — explicit items win.\n const seen = new Set(flatItems.map((i) => i.id));\n return [...flatItems, ...toolItems.filter((t) => !seen.has(t.id))];\n };\n\n return {\n categories: (): readonly Unstable_TriggerCategory[] => [],\n categoryItems: () => [],\n search: (query) => {\n const lower = query.toLowerCase();\n return getFlatPool().filter((item) => matchesQuery(item, lower));\n },\n };\n }, [aui, items, categories, wantsTools, toolsConfig]);\n\n const directive = useMemo<Unstable_MentionDirective>(\n () => ({\n formatter: formatter ?? unstable_defaultDirectiveFormatter,\n ...(onInserted ? { onInserted } : {}),\n }),\n [formatter, onInserted],\n );\n\n return {\n adapter,\n directive,\n ...(options?.iconMap ? { iconMap: options.iconMap } : {}),\n ...(options?.fallbackIcon ? { fallbackIcon: options.fallbackIcon } : {}),\n };\n}\n\nfunction toTriggerItem(m: Unstable_Mention): Unstable_TriggerItem {\n const metadata =\n m.icon !== undefined ? { ...(m.metadata ?? {}), icon: m.icon } : m.metadata;\n return {\n id: m.id,\n type: m.type,\n label: m.label,\n ...(m.description !== undefined ? { description: m.description } : {}),\n ...(metadata !== undefined ? { metadata } : {}),\n };\n}\n\nfunction matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {\n if (!lower) return true;\n if (item.id.toLowerCase().includes(lower)) return true;\n if (item.label.toLowerCase().includes(lower)) return true;\n if (item.description?.toLowerCase().includes(lower)) return true;\n return false;\n}\n"],"mappings":"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"}
@@ -1,5 +1,7 @@
1
1
  //#region src/utils/useToolArgsFieldStatus.d.ts
2
2
  declare const useToolArgsFieldStatus: (fieldPath: (string | number)[]) => {
3
+ type: string;
4
+ } | {
3
5
  readonly type: "running";
4
6
  } | {
5
7
  readonly type: "complete";
@@ -7,8 +9,6 @@ declare const useToolArgsFieldStatus: (fieldPath: (string | number)[]) => {
7
9
  readonly type: "incomplete";
8
10
  readonly reason: "cancelled" | "length" | "content-filter" | "other" | "error";
9
11
  readonly error?: unknown;
10
- } | {
11
- type: string;
12
12
  };
13
13
  //#endregion
14
14
  export { useToolArgsFieldStatus };
@@ -1 +1 @@
1
- {"version":3,"file":"useToolArgsFieldStatus.d.ts","names":[],"sources":["../../src/utils/useToolArgsFieldStatus.ts"],"mappings":";cAKa,sBAAA,GAA0B,SAAA;EAAA"}
1
+ {"version":3,"file":"useToolArgsFieldStatus.d.ts","names":[],"sources":["../../src/utils/useToolArgsFieldStatus.ts"],"mappings":";cAKa,sBAAA,GAA0B,SAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.14.12",
3
+ "version": "0.14.14",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "radix-ui",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "sideEffects": false,
50
50
  "dependencies": {
51
- "@assistant-ui/core": "^0.2.8",
51
+ "@assistant-ui/core": "^0.2.10",
52
52
  "@assistant-ui/store": "^0.2.13",
53
53
  "@assistant-ui/tap": "^0.5.14",
54
54
  "@radix-ui/primitive": "^1.1.3",
@@ -57,8 +57,8 @@
57
57
  "@radix-ui/react-primitive": "^2.1.4",
58
58
  "@radix-ui/react-use-callback-ref": "^1.1.1",
59
59
  "@radix-ui/react-use-escape-keydown": "^1.1.1",
60
- "assistant-cloud": "^0.1.30",
61
- "assistant-stream": "^0.3.18",
60
+ "assistant-cloud": "^0.1.31",
61
+ "assistant-stream": "^0.3.20",
62
62
  "nanoid": "^5.1.11",
63
63
  "radix-ui": "^1.4.3",
64
64
  "react-textarea-autosize": "^8.5.9",
@@ -90,7 +90,7 @@
90
90
  "react": "^19.2.6",
91
91
  "react-dom": "^19.2.6",
92
92
  "vitest": "^4.1.7",
93
- "@assistant-ui/x-buildutils": "0.0.10"
93
+ "@assistant-ui/x-buildutils": "0.0.11"
94
94
  },
95
95
  "publishConfig": {
96
96
  "access": "public",
@@ -24,6 +24,7 @@ type ThreadData = {
24
24
  id: string;
25
25
  title?: string;
26
26
  status: "regular" | "archived";
27
+ custom?: Record<string, unknown> | undefined;
27
28
  };
28
29
 
29
30
  // ThreadListItem Client
@@ -31,11 +32,19 @@ const ThreadListItemClient = resource(
31
32
  (props: {
32
33
  data: ThreadData;
33
34
  onSwitchTo: () => void;
35
+ onUpdateCustom: (custom: Record<string, unknown> | undefined) => void;
34
36
  onArchive: () => void;
35
37
  onUnarchive: () => void;
36
38
  onDelete: () => void;
37
39
  }): ClientOutput<"threadListItem"> => {
38
- const { data, onSwitchTo, onArchive, onUnarchive, onDelete } = props;
40
+ const {
41
+ data,
42
+ onSwitchTo,
43
+ onUpdateCustom,
44
+ onArchive,
45
+ onUnarchive,
46
+ onDelete,
47
+ } = props;
39
48
  const state = tapMemo(
40
49
  () => ({
41
50
  id: data.id,
@@ -43,14 +52,16 @@ const ThreadListItemClient = resource(
43
52
  externalId: undefined,
44
53
  title: data.title,
45
54
  status: data.status,
55
+ custom: data.custom,
46
56
  }),
47
- [data.id, data.title, data.status],
57
+ [data.id, data.title, data.status, data.custom],
48
58
  );
49
59
 
50
60
  return {
51
61
  getState: () => state,
52
62
  switchTo: onSwitchTo,
53
63
  rename: () => {},
64
+ updateCustom: onUpdateCustom,
54
65
  archive: onArchive,
55
66
  unarchive: onUnarchive,
56
67
  delete: onDelete,
@@ -96,6 +107,15 @@ export const InMemoryThreadList = resource(
96
107
  );
97
108
  };
98
109
 
110
+ const handleUpdateCustom = (
111
+ threadId: string,
112
+ custom: Record<string, unknown> | undefined,
113
+ ) => {
114
+ setThreads((prev) =>
115
+ prev.map((t) => (t.id === threadId ? { ...t, custom } : t)),
116
+ );
117
+ };
118
+
99
119
  const handleDelete = (threadId: string) => {
100
120
  setThreads((prev) => prev.filter((t) => t.id !== threadId));
101
121
  if (mainThreadId === threadId) {
@@ -122,6 +142,7 @@ export const InMemoryThreadList = resource(
122
142
  ThreadListItemClient({
123
143
  data: t,
124
144
  onSwitchTo: () => handleSwitchToThread(t.id),
145
+ onUpdateCustom: (custom) => handleUpdateCustom(t.id, custom),
125
146
  onArchive: () => handleArchive(t.id),
126
147
  onUnarchive: () => handleUnarchive(t.id),
127
148
  onDelete: () => handleDelete(t.id),
@@ -1,4 +1,4 @@
1
- import { resource, tapMemo } from "@assistant-ui/tap";
1
+ import { resource, tapMemo, tapState } from "@assistant-ui/tap";
2
2
  import {
3
3
  type ClientElement,
4
4
  type ClientOutput,
@@ -9,6 +9,8 @@ const RESOLVED_PROMISE = Promise.resolve();
9
9
  const THREAD_ID = "default";
10
10
 
11
11
  const SingleThreadListItem = resource((): ClientOutput<"threadListItem"> => {
12
+ const [custom, setCustom] = tapState<Record<string, unknown> | undefined>();
13
+
12
14
  return {
13
15
  getState: () => ({
14
16
  id: THREAD_ID,
@@ -16,9 +18,11 @@ const SingleThreadListItem = resource((): ClientOutput<"threadListItem"> => {
16
18
  externalId: undefined,
17
19
  title: undefined,
18
20
  status: "regular",
21
+ custom,
19
22
  }),
20
23
  switchTo: () => {},
21
24
  rename: () => {},
25
+ updateCustom: setCustom,
22
26
  archive: () => {},
23
27
  unarchive: () => {},
24
28
  delete: () => {},
package/src/index.ts CHANGED
@@ -187,8 +187,18 @@ export {
187
187
  useInlineRender,
188
188
  type Toolkit,
189
189
  type ToolDefinition,
190
- type ToolkitDeclaration,
191
- type ToolkitDeclarationDefinition,
190
+ type ToolCallText,
191
+ type ToolkitDefinition,
192
+ type ToolkitDefinitionEntry,
193
+ defineToolkit,
194
+ stubTool,
195
+ useAuiToolOverrides,
196
+ hitl,
197
+ hitlTool,
198
+ providerTool,
199
+ type ProviderToolConfig,
200
+ defineMcpToolkit,
201
+ type McpToolkitDefinition,
192
202
  Tools,
193
203
  DataRenderers,
194
204
  Interactables,
@@ -0,0 +1,426 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ AssistantMessageAccumulator,
5
+ DataStreamDecoder,
6
+ } from "assistant-stream";
7
+ import { act, renderHook } from "@testing-library/react";
8
+ import { describe, expect, it, vi } from "vitest";
9
+ import {
10
+ createReplayBoundaryStream,
11
+ REPLAY_CONTENT_LENGTH_HEADER,
12
+ useReplayRenderWait,
13
+ } from "./replayBoundaryStream";
14
+
15
+ const encoder = new TextEncoder();
16
+ const decoder = new TextDecoder();
17
+
18
+ const createBody = (chunks: readonly string[]) =>
19
+ new ReadableStream<Uint8Array>({
20
+ start(controller) {
21
+ for (const chunk of chunks) {
22
+ controller.enqueue(encoder.encode(chunk));
23
+ }
24
+ controller.close();
25
+ },
26
+ });
27
+
28
+ const createResponse = (
29
+ chunks: readonly string[],
30
+ replayContentLength?: number | string,
31
+ ) =>
32
+ new Response(createBody(chunks), {
33
+ headers:
34
+ replayContentLength === undefined
35
+ ? undefined
36
+ : { [REPLAY_CONTENT_LENGTH_HEADER]: String(replayContentLength) },
37
+ });
38
+
39
+ const createRenderWait = () => {
40
+ const pending: Array<() => void> = [];
41
+ const waitForRender = vi.fn(
42
+ () =>
43
+ new Promise<void>((resolve) => {
44
+ pending.push(resolve);
45
+ }),
46
+ );
47
+
48
+ const releaseNext = async () => {
49
+ for (let i = 0; pending.length === 0 && i < 10; i++) {
50
+ await Promise.resolve();
51
+ }
52
+
53
+ const resolve = pending.shift();
54
+ expect(resolve).toBeDefined();
55
+ resolve!();
56
+ await Promise.resolve();
57
+ };
58
+
59
+ return { waitForRender, releaseNext };
60
+ };
61
+
62
+ const readText = async (stream: ReadableStream<Uint8Array>) => {
63
+ const reader = stream.getReader();
64
+ const chunks: string[] = [];
65
+
66
+ while (true) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ chunks.push(decoder.decode(value, { stream: true }));
70
+ }
71
+ chunks.push(decoder.decode());
72
+
73
+ return chunks.join("");
74
+ };
75
+
76
+ describe("useReplayRenderWait", () => {
77
+ it("resolves after its own render ticket commits", async () => {
78
+ vi.useFakeTimers();
79
+
80
+ try {
81
+ const { result } = renderHook(() => useReplayRenderWait());
82
+
83
+ let resolved = false;
84
+ const wait = result.current().then(() => {
85
+ resolved = true;
86
+ });
87
+
88
+ await Promise.resolve();
89
+ expect(resolved).toBe(false);
90
+
91
+ await act(async () => {
92
+ vi.runOnlyPendingTimers();
93
+ });
94
+ await wait;
95
+
96
+ expect(resolved).toBe(true);
97
+ } finally {
98
+ vi.useRealTimers();
99
+ }
100
+ });
101
+ });
102
+
103
+ describe("createReplayBoundaryStream", () => {
104
+ it("short-circuits responses without a valid replay content length", async () => {
105
+ const setReplaying = vi.fn();
106
+ const waitForRender = vi.fn();
107
+
108
+ for (const replayContentLength of [undefined, "abc", "3.5", "-1"]) {
109
+ setReplaying.mockClear();
110
+ waitForRender.mockClear();
111
+
112
+ const body = await createReplayBoundaryStream(
113
+ createResponse(["live"], replayContentLength),
114
+ {
115
+ setReplaying,
116
+ waitForRender,
117
+ },
118
+ );
119
+
120
+ expect(await readText(body)).toBe("live");
121
+ expect(setReplaying).not.toHaveBeenCalled();
122
+ expect(waitForRender).not.toHaveBeenCalled();
123
+ }
124
+ });
125
+
126
+ it("pauses at the replay boundary before releasing live bytes", async () => {
127
+ const { waitForRender, releaseNext } = createRenderWait();
128
+ const setReplaying = vi.fn();
129
+ const replayPrefix = '0:"hi"\nb:{"toolCallId":"call-1","toolName":"te';
130
+ const liveSuffix = 'st"}\n';
131
+ const replayContentLength = encoder.encode(replayPrefix).byteLength;
132
+
133
+ const streamPromise = createReplayBoundaryStream(
134
+ createResponse([replayPrefix + liveSuffix], replayContentLength),
135
+ { setReplaying, waitForRender },
136
+ );
137
+
138
+ expect(setReplaying).toHaveBeenCalledWith(true);
139
+ expect(waitForRender).toHaveBeenCalledTimes(1);
140
+
141
+ await releaseNext();
142
+ const stream = await streamPromise;
143
+ const reader = stream.getReader();
144
+
145
+ await expect(reader.read()).resolves.toMatchObject({
146
+ done: false,
147
+ value: encoder.encode(replayPrefix),
148
+ });
149
+ expect(waitForRender).toHaveBeenCalledTimes(2);
150
+ expect(setReplaying).toHaveBeenCalledTimes(1);
151
+
152
+ let liveReadResolved = false;
153
+ const liveRead = reader.read().then((read) => {
154
+ liveReadResolved = true;
155
+ return read;
156
+ });
157
+ await Promise.resolve();
158
+ expect(liveReadResolved).toBe(false);
159
+
160
+ await releaseNext();
161
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
162
+ expect(waitForRender).toHaveBeenCalledTimes(3);
163
+ await Promise.resolve();
164
+ expect(liveReadResolved).toBe(false);
165
+
166
+ await releaseNext();
167
+ await expect(liveRead).resolves.toMatchObject({
168
+ done: false,
169
+ value: encoder.encode(liveSuffix),
170
+ });
171
+ });
172
+
173
+ it("pauses when a chunk ends exactly at the replay boundary", async () => {
174
+ const { waitForRender, releaseNext } = createRenderWait();
175
+ const setReplaying = vi.fn();
176
+ const replayPrefix = "replay";
177
+ const liveSuffix = "live";
178
+ const replayContentLength = encoder.encode(replayPrefix).byteLength;
179
+
180
+ const streamPromise = createReplayBoundaryStream(
181
+ createResponse([replayPrefix, liveSuffix], replayContentLength),
182
+ { setReplaying, waitForRender },
183
+ );
184
+
185
+ await releaseNext();
186
+ const stream = await streamPromise;
187
+ const reader = stream.getReader();
188
+
189
+ await expect(reader.read()).resolves.toMatchObject({
190
+ done: false,
191
+ value: encoder.encode(replayPrefix),
192
+ });
193
+ expect(setReplaying).toHaveBeenCalledTimes(1);
194
+
195
+ let liveReadResolved = false;
196
+ const liveRead = reader.read().then((read) => {
197
+ liveReadResolved = true;
198
+ return read;
199
+ });
200
+ await Promise.resolve();
201
+ expect(liveReadResolved).toBe(false);
202
+
203
+ await releaseNext();
204
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
205
+ await releaseNext();
206
+
207
+ await expect(liveRead).resolves.toMatchObject({
208
+ done: false,
209
+ value: encoder.encode(liveSuffix),
210
+ });
211
+ });
212
+
213
+ it("accumulates replay bytes across chunks before splitting live bytes", async () => {
214
+ const { waitForRender, releaseNext } = createRenderWait();
215
+ const setReplaying = vi.fn();
216
+ const firstReplayChunk = "part1";
217
+ const secondReplayChunk = "part2";
218
+ const firstLiveChunk = "live1";
219
+ const secondLiveChunk = "live2";
220
+ const replayContentLength = encoder.encode(
221
+ firstReplayChunk + secondReplayChunk,
222
+ ).byteLength;
223
+
224
+ const streamPromise = createReplayBoundaryStream(
225
+ createResponse(
226
+ [firstReplayChunk, secondReplayChunk + firstLiveChunk, secondLiveChunk],
227
+ replayContentLength,
228
+ ),
229
+ { setReplaying, waitForRender },
230
+ );
231
+
232
+ await releaseNext();
233
+ const stream = await streamPromise;
234
+ const reader = stream.getReader();
235
+
236
+ await expect(reader.read()).resolves.toMatchObject({
237
+ done: false,
238
+ value: encoder.encode(firstReplayChunk),
239
+ });
240
+ await expect(reader.read()).resolves.toMatchObject({
241
+ done: false,
242
+ value: encoder.encode(secondReplayChunk),
243
+ });
244
+ expect(setReplaying).toHaveBeenCalledTimes(1);
245
+
246
+ let liveReadResolved = false;
247
+ const liveRead = reader.read().then((read) => {
248
+ liveReadResolved = true;
249
+ return read;
250
+ });
251
+ await Promise.resolve();
252
+ expect(liveReadResolved).toBe(false);
253
+
254
+ await releaseNext();
255
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
256
+ await releaseNext();
257
+
258
+ await expect(liveRead).resolves.toMatchObject({
259
+ done: false,
260
+ value: encoder.encode(firstLiveChunk),
261
+ });
262
+ await expect(reader.read()).resolves.toMatchObject({
263
+ done: false,
264
+ value: encoder.encode(secondLiveChunk),
265
+ });
266
+ });
267
+
268
+ it("clears replaying when the stream ends before the boundary", async () => {
269
+ const { waitForRender, releaseNext } = createRenderWait();
270
+ const setReplaying = vi.fn();
271
+ const streamPromise = createReplayBoundaryStream(
272
+ createResponse(["hi"], 10),
273
+ {
274
+ setReplaying,
275
+ waitForRender,
276
+ },
277
+ );
278
+
279
+ await releaseNext();
280
+ const stream = await streamPromise;
281
+ const text = readText(stream);
282
+ await releaseNext();
283
+ await releaseNext();
284
+
285
+ await expect(text).resolves.toBe("hi");
286
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
287
+ });
288
+
289
+ it("clears replaying when the gated stream is cancelled", async () => {
290
+ const { waitForRender, releaseNext } = createRenderWait();
291
+ const setReplaying = vi.fn();
292
+ let cancelled = false;
293
+ const body = new ReadableStream<Uint8Array>({
294
+ start(controller) {
295
+ controller.enqueue(encoder.encode("hi"));
296
+ },
297
+ cancel() {
298
+ cancelled = true;
299
+ },
300
+ });
301
+ const streamPromise = createReplayBoundaryStream(
302
+ new Response(body, { headers: { [REPLAY_CONTENT_LENGTH_HEADER]: "10" } }),
303
+ { setReplaying, waitForRender },
304
+ );
305
+
306
+ await releaseNext();
307
+ const stream = await streamPromise;
308
+ await stream.cancel("done");
309
+
310
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
311
+ expect(cancelled).toBe(true);
312
+ });
313
+
314
+ it("does not wait for replay completion after cancellation unblocks a read", async () => {
315
+ const { waitForRender, releaseNext } = createRenderWait();
316
+ const setReplaying = vi.fn();
317
+ let cancelled = false;
318
+ const body = new ReadableStream<Uint8Array>({
319
+ cancel() {
320
+ cancelled = true;
321
+ },
322
+ });
323
+ const streamPromise = createReplayBoundaryStream(
324
+ new Response(body, { headers: { [REPLAY_CONTENT_LENGTH_HEADER]: "10" } }),
325
+ { setReplaying, waitForRender },
326
+ );
327
+
328
+ await releaseNext();
329
+ const stream = await streamPromise;
330
+ const reader = stream.getReader();
331
+ const read = reader.read();
332
+
333
+ await Promise.resolve();
334
+ expect(waitForRender).toHaveBeenCalledTimes(1);
335
+
336
+ await reader.cancel("done");
337
+ await expect(read).resolves.toMatchObject({ done: true });
338
+
339
+ expect(waitForRender).toHaveBeenCalledTimes(1);
340
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
341
+ expect(cancelled).toBe(true);
342
+ });
343
+
344
+ it("does not clear replaying twice when cancelled during replay completion", async () => {
345
+ const { waitForRender, releaseNext } = createRenderWait();
346
+ const setReplaying = vi.fn();
347
+ const replayStr = "replay";
348
+ const replayContentLength = encoder.encode(replayStr).byteLength;
349
+
350
+ const streamPromise = createReplayBoundaryStream(
351
+ createResponse([replayStr], replayContentLength),
352
+ { setReplaying, waitForRender },
353
+ );
354
+
355
+ await releaseNext();
356
+ const stream = await streamPromise;
357
+ const reader = stream.getReader();
358
+
359
+ await expect(reader.read()).resolves.toMatchObject({
360
+ done: false,
361
+ value: encoder.encode(replayStr),
362
+ });
363
+ expect(waitForRender).toHaveBeenCalledTimes(2);
364
+
365
+ const cancel = reader.cancel("done");
366
+ await Promise.resolve();
367
+ expect(setReplaying).toHaveBeenCalledTimes(1);
368
+
369
+ await releaseNext();
370
+ expect(setReplaying).toHaveBeenCalledTimes(2);
371
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
372
+
373
+ await releaseNext();
374
+ await cancel;
375
+ expect(setReplaying).toHaveBeenCalledTimes(2);
376
+ });
377
+
378
+ it("lets data-stream parsing commit replayed text before live tool calls", async () => {
379
+ const { waitForRender, releaseNext } = createRenderWait();
380
+ const setReplaying = vi.fn();
381
+ const replayPrefix = '0:"hi"\nb:{"toolCallId":"call-1","toolName":"te';
382
+ const liveSuffix = 'st"}\n';
383
+ const replayContentLength = encoder.encode(replayPrefix).byteLength;
384
+
385
+ const streamPromise = createReplayBoundaryStream(
386
+ createResponse([replayPrefix + liveSuffix], replayContentLength),
387
+ { setReplaying, waitForRender },
388
+ );
389
+
390
+ await releaseNext();
391
+ const messages = (await streamPromise)
392
+ .pipeThrough(new DataStreamDecoder())
393
+ .pipeThrough(new AssistantMessageAccumulator({ throttle: true }));
394
+ const reader = messages.getReader();
395
+
396
+ let sawReplayedText = false;
397
+ while (!sawReplayedText) {
398
+ const replayedMessage = await reader.read();
399
+ expect(replayedMessage.done).toBe(false);
400
+ expect(
401
+ replayedMessage.value?.parts.some((part) => part.type === "tool-call"),
402
+ ).toBe(false);
403
+ sawReplayedText =
404
+ replayedMessage.value?.parts.some(
405
+ (part) => part.type === "text" && part.text === "hi",
406
+ ) ?? false;
407
+ }
408
+ expect(setReplaying).toHaveBeenCalledTimes(1);
409
+
410
+ const liveMessagePromise = reader.read();
411
+ await releaseNext();
412
+ expect(setReplaying).toHaveBeenLastCalledWith(false);
413
+ await releaseNext();
414
+
415
+ let liveMessage = await liveMessagePromise;
416
+ while (
417
+ !liveMessage.done &&
418
+ !liveMessage.value?.parts.some(
419
+ (part) => part.type === "tool-call" && part.toolName === "test",
420
+ )
421
+ ) {
422
+ liveMessage = await reader.read();
423
+ }
424
+ expect(liveMessage.done).toBe(false);
425
+ });
426
+ });