basalt-ui 1.10.0 → 1.12.0

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 (273) hide show
  1. package/AGENTS.md +21 -21
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +36 -27
  4. package/agent/rules/basalt-content.md +16 -3
  5. package/dist/agent/adapter.d.ts +146 -0
  6. package/dist/agent/adapter.d.ts.map +1 -0
  7. package/dist/agent/adapter.js +473 -0
  8. package/dist/agent/adapter.test.d.ts +2 -0
  9. package/dist/agent/adapter.test.d.ts.map +1 -0
  10. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  11. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  12. package/dist/agent/ai-sdk-transport.js +108 -24
  13. package/dist/agent/ai-sdk-transport.test.d.ts +2 -0
  14. package/dist/agent/ai-sdk-transport.test.d.ts.map +1 -0
  15. package/dist/agent/coalesce.d.ts +42 -0
  16. package/dist/agent/coalesce.d.ts.map +1 -0
  17. package/dist/agent/coalesce.js +137 -0
  18. package/dist/agent/coalesce.test.d.ts +2 -0
  19. package/dist/agent/coalesce.test.d.ts.map +1 -0
  20. package/dist/agent/foreign.d.ts +97 -0
  21. package/dist/agent/foreign.d.ts.map +1 -0
  22. package/dist/agent/foreign.js +18 -0
  23. package/dist/agent/foreign.test.d.ts +2 -0
  24. package/dist/agent/foreign.test.d.ts.map +1 -0
  25. package/dist/agent/history.d.ts +2 -0
  26. package/dist/agent/history.d.ts.map +1 -1
  27. package/dist/agent/id.d.ts +65 -0
  28. package/dist/agent/id.d.ts.map +1 -0
  29. package/dist/agent/id.js +30 -0
  30. package/dist/agent/id.test.d.ts +2 -0
  31. package/dist/agent/id.test.d.ts.map +1 -0
  32. package/dist/agent/index.d.ts +11 -4
  33. package/dist/agent/index.d.ts.map +1 -1
  34. package/dist/agent/index.js +18 -3
  35. package/dist/agent/merge.d.ts +8 -0
  36. package/dist/agent/merge.d.ts.map +1 -0
  37. package/dist/agent/merge.js +35 -0
  38. package/dist/agent/merge.test.d.ts +2 -0
  39. package/dist/agent/merge.test.d.ts.map +1 -0
  40. package/dist/agent/part-list.d.ts +49 -34
  41. package/dist/agent/part-list.d.ts.map +1 -1
  42. package/dist/agent/part-list.js +53 -7
  43. package/dist/agent/part-list.test.d.ts +2 -0
  44. package/dist/agent/part-list.test.d.ts.map +1 -0
  45. package/dist/agent/parts.d.ts +135 -20
  46. package/dist/agent/parts.d.ts.map +1 -1
  47. package/dist/agent/parts.js +138 -11
  48. package/dist/agent/parts.test.d.ts +2 -0
  49. package/dist/agent/parts.test.d.ts.map +1 -0
  50. package/dist/agent/thread.d.ts +14 -0
  51. package/dist/agent/thread.d.ts.map +1 -1
  52. package/dist/agent/thread.js +8 -2
  53. package/dist/agent/thread.test.d.ts +2 -0
  54. package/dist/agent/thread.test.d.ts.map +1 -0
  55. package/dist/agent/transport.d.ts +39 -3
  56. package/dist/agent/transport.d.ts.map +1 -1
  57. package/dist/agent/transport.js +20 -17
  58. package/dist/agent/transport.test.d.ts +2 -0
  59. package/dist/agent/transport.test.d.ts.map +1 -0
  60. package/dist/agent/use-agent-stream.d.ts +2 -1
  61. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  62. package/dist/agent/use-agent-stream.js +5 -2
  63. package/dist/agent/use-agent-stream.test.d.ts +2 -0
  64. package/dist/agent/use-agent-stream.test.d.ts.map +1 -0
  65. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  66. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  67. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +2 -0
  68. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +1 -0
  69. package/dist/agent/use-agent-thread-runs.js +75 -23
  70. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +2 -0
  71. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +1 -0
  72. package/dist/agent/use-agent-thread-runs.test.d.ts +2 -0
  73. package/dist/agent/use-agent-thread-runs.test.d.ts.map +1 -0
  74. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +2 -0
  75. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +1 -0
  76. package/dist/agent-chat/composer.d.ts +130 -8
  77. package/dist/agent-chat/composer.d.ts.map +1 -1
  78. package/dist/agent-chat/composer.js +189 -23
  79. package/dist/agent-chat/composer.test.d.ts +2 -0
  80. package/dist/agent-chat/composer.test.d.ts.map +1 -0
  81. package/dist/agent-chat/index.d.ts +3 -1
  82. package/dist/agent-chat/index.d.ts.map +1 -1
  83. package/dist/agent-chat/index.js +2 -0
  84. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  85. package/dist/agent-chat/thread-detail-panel.js +1 -1
  86. package/dist/agent-chat/thread-message.d.ts +24 -7
  87. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  88. package/dist/agent-chat/thread-message.js +115 -45
  89. package/dist/agent-chat/thread-message.test.d.ts +2 -0
  90. package/dist/agent-chat/thread-message.test.d.ts.map +1 -0
  91. package/dist/agent-chat/thread-workspace.js +1 -1
  92. package/dist/agent-chat/tool-chip.d.ts +21 -0
  93. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  94. package/dist/agent-chat/tool-chip.js +120 -0
  95. package/dist/agent-chat/tool-chip.test.d.ts +2 -0
  96. package/dist/agent-chat/tool-chip.test.d.ts.map +1 -0
  97. package/dist/charts/primitives/ChartFrame.test.d.ts +2 -0
  98. package/dist/charts/primitives/ChartFrame.test.d.ts.map +1 -0
  99. package/dist/charts/utils/domain.test.d.ts +2 -0
  100. package/dist/charts/utils/domain.test.d.ts.map +1 -0
  101. package/dist/cli/check-theme.test.d.ts +2 -0
  102. package/dist/cli/check-theme.test.d.ts.map +1 -0
  103. package/dist/cli/help.test.d.ts +2 -0
  104. package/dist/cli/help.test.d.ts.map +1 -0
  105. package/dist/cli/index.d.ts.map +1 -1
  106. package/dist/cli/init-sync.test.d.ts +2 -0
  107. package/dist/cli/init-sync.test.d.ts.map +1 -0
  108. package/dist/cli/placement-engine.test.d.ts +2 -0
  109. package/dist/cli/placement-engine.test.d.ts.map +1 -0
  110. package/dist/cli/tarball-parity.test.d.ts +2 -0
  111. package/dist/cli/tarball-parity.test.d.ts.map +1 -0
  112. package/dist/cli/template-format.test.d.ts +2 -0
  113. package/dist/cli/template-format.test.d.ts.map +1 -0
  114. package/dist/cli/template-ledger.test.d.ts +2 -0
  115. package/dist/cli/template-ledger.test.d.ts.map +1 -0
  116. package/dist/cli/tokens-css.test.d.ts +2 -0
  117. package/dist/cli/tokens-css.test.d.ts.map +1 -0
  118. package/dist/content/article-actions.test.d.ts +2 -0
  119. package/dist/content/article-actions.test.d.ts.map +1 -0
  120. package/dist/content/article-model.test.d.ts +2 -0
  121. package/dist/content/article-model.test.d.ts.map +1 -0
  122. package/dist/content/block-split.test.d.ts +2 -0
  123. package/dist/content/block-split.test.d.ts.map +1 -0
  124. package/dist/content/fence-block.d.ts +37 -10
  125. package/dist/content/fence-block.d.ts.map +1 -1
  126. package/dist/content/fence-block.js +80 -14
  127. package/dist/content/heading-components.d.ts.map +1 -1
  128. package/dist/content/heading-components.js +7 -4
  129. package/dist/content/heading-components.test.d.ts +2 -0
  130. package/dist/content/heading-components.test.d.ts.map +1 -0
  131. package/dist/content/index.d.ts +6 -3
  132. package/dist/content/index.d.ts.map +1 -1
  133. package/dist/content/index.js +5 -1
  134. package/dist/content/markdown.d.ts +101 -14
  135. package/dist/content/markdown.d.ts.map +1 -1
  136. package/dist/content/markdown.js +141 -28
  137. package/dist/content/markdown.test.d.ts +2 -0
  138. package/dist/content/markdown.test.d.ts.map +1 -0
  139. package/dist/content/prose.module.css.test.d.ts +2 -0
  140. package/dist/content/prose.module.css.test.d.ts.map +1 -0
  141. package/dist/content/sanitize.d.ts +170 -0
  142. package/dist/content/sanitize.d.ts.map +1 -0
  143. package/dist/content/sanitize.js +178 -0
  144. package/dist/content/sanitize.test.d.ts +2 -0
  145. package/dist/content/sanitize.test.d.ts.map +1 -0
  146. package/dist/content/slug.test.d.ts +2 -0
  147. package/dist/content/slug.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.test.d.ts +2 -0
  149. package/dist/dashboard/stat-card.test.d.ts.map +1 -0
  150. package/dist/guard/check-source.test.d.ts +2 -0
  151. package/dist/guard/check-source.test.d.ts.map +1 -0
  152. package/dist/guard/guard-hook.test.d.ts +2 -0
  153. package/dist/guard/guard-hook.test.d.ts.map +1 -0
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +1 -3
  156. package/dist/index.d.ts +4 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +12 -2
  159. package/dist/provider/index.test.d.ts +2 -0
  160. package/dist/provider/index.test.d.ts.map +1 -0
  161. package/dist/provider/lab-theme.test.d.ts +2 -0
  162. package/dist/provider/lab-theme.test.d.ts.map +1 -0
  163. package/dist/router-tanstack/multi-search-param-store.test.d.ts +2 -0
  164. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +1 -0
  165. package/dist/styles.floor.test.d.ts +2 -0
  166. package/dist/styles.floor.test.d.ts.map +1 -0
  167. package/dist/surfaces.d.ts +5 -5
  168. package/dist/surfaces.d.ts.map +1 -1
  169. package/dist/surfaces.js +13 -4
  170. package/dist/theme/border-coverage.test.d.ts +18 -0
  171. package/dist/theme/border-coverage.test.d.ts.map +1 -0
  172. package/dist/theme/contrast.test.d.ts +2 -0
  173. package/dist/theme/contrast.test.d.ts.map +1 -0
  174. package/dist/theme/density-option.test.d.ts +2 -0
  175. package/dist/theme/density-option.test.d.ts.map +1 -0
  176. package/dist/theme/density-relations.test.d.ts +2 -0
  177. package/dist/theme/density-relations.test.d.ts.map +1 -0
  178. package/dist/theme/derive-theme.test.d.ts +2 -0
  179. package/dist/theme/derive-theme.test.d.ts.map +1 -0
  180. package/dist/theme/fonts-option.test.d.ts +2 -0
  181. package/dist/theme/fonts-option.test.d.ts.map +1 -0
  182. package/dist/theme/radius-option.test.d.ts +2 -0
  183. package/dist/theme/radius-option.test.d.ts.map +1 -0
  184. package/dist/theme/radius.test.d.ts +2 -0
  185. package/dist/theme/radius.test.d.ts.map +1 -0
  186. package/dist/theme/shadow-surfaces.test.d.ts +26 -0
  187. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -0
  188. package/dist/theme/spacing.test.d.ts +2 -0
  189. package/dist/theme/spacing.test.d.ts.map +1 -0
  190. package/dist/theme/use-basalt-spacing.test.d.ts +2 -0
  191. package/dist/theme/use-basalt-spacing.test.d.ts.map +1 -0
  192. package/dist/theme-lab/derive-controls.test.d.ts +2 -0
  193. package/dist/theme-lab/derive-controls.test.d.ts.map +1 -0
  194. package/dist/theme-lab/derive-state.test.d.ts +2 -0
  195. package/dist/theme-lab/derive-state.test.d.ts.map +1 -0
  196. package/dist/theme-lab/theme-lab.test.d.ts +2 -0
  197. package/dist/theme-lab/theme-lab.test.d.ts.map +1 -0
  198. package/dist/tokens/build-fonts-css.test.d.ts +2 -0
  199. package/dist/tokens/build-fonts-css.test.d.ts.map +1 -0
  200. package/dist/tokens/build-radius-css.test.d.ts +2 -0
  201. package/dist/tokens/build-radius-css.test.d.ts.map +1 -0
  202. package/dist/tokens/density.test.d.ts +2 -0
  203. package/dist/tokens/density.test.d.ts.map +1 -0
  204. package/dist/tokens/derive-radius.test.d.ts +2 -0
  205. package/dist/tokens/derive-radius.test.d.ts.map +1 -0
  206. package/dist/tokens/derive.test.d.ts +2 -0
  207. package/dist/tokens/derive.test.d.ts.map +1 -0
  208. package/dist/tokens/palette.test.d.ts +2 -0
  209. package/dist/tokens/palette.test.d.ts.map +1 -0
  210. package/dist/vite-color-mix.test.d.ts +2 -0
  211. package/dist/vite-color-mix.test.d.ts.map +1 -0
  212. package/dist/vite.test.d.ts +2 -0
  213. package/dist/vite.test.d.ts.map +1 -0
  214. package/llms.txt +7 -7
  215. package/package.json +6 -1
  216. package/src/agent/adapter.test.ts +1403 -0
  217. package/src/agent/adapter.ts +887 -0
  218. package/src/agent/ai-sdk-transport.test.ts +380 -55
  219. package/src/agent/ai-sdk-transport.ts +206 -53
  220. package/src/agent/coalesce.test.ts +387 -0
  221. package/src/agent/coalesce.ts +229 -0
  222. package/src/agent/foreign.test.ts +51 -0
  223. package/src/agent/foreign.ts +140 -0
  224. package/src/agent/history.ts +2 -0
  225. package/src/agent/id.test.ts +75 -0
  226. package/src/agent/id.ts +91 -0
  227. package/src/agent/index.ts +34 -3
  228. package/src/agent/merge.test.ts +206 -0
  229. package/src/agent/merge.ts +114 -0
  230. package/src/agent/part-list.test.tsx +112 -0
  231. package/src/agent/part-list.tsx +108 -35
  232. package/src/agent/parts.test.ts +516 -57
  233. package/src/agent/parts.ts +323 -32
  234. package/src/agent/thread.test.ts +75 -2
  235. package/src/agent/thread.ts +21 -1
  236. package/src/agent/transport.test.ts +70 -0
  237. package/src/agent/transport.ts +72 -20
  238. package/src/agent/use-agent-stream.test.tsx +53 -19
  239. package/src/agent/use-agent-stream.ts +13 -3
  240. package/src/agent/use-agent-thread-runs.foreign.test.tsx +138 -0
  241. package/src/agent/use-agent-thread-runs.resume.test.tsx +44 -11
  242. package/src/agent/use-agent-thread-runs.test.tsx +418 -22
  243. package/src/agent/use-agent-thread-runs.ts +170 -26
  244. package/src/agent/use-agent-thread-runs.wedge.test.tsx +15 -7
  245. package/src/agent-chat/composer.test.tsx +924 -0
  246. package/src/agent-chat/composer.tsx +521 -52
  247. package/src/agent-chat/index.ts +5 -1
  248. package/src/agent-chat/thread-detail-panel.tsx +3 -1
  249. package/src/agent-chat/thread-message.test.tsx +351 -0
  250. package/src/agent-chat/thread-message.tsx +262 -64
  251. package/src/agent-chat/thread-workspace.tsx +1 -1
  252. package/src/agent-chat/tool-chip.test.tsx +139 -0
  253. package/src/agent-chat/tool-chip.tsx +219 -0
  254. package/src/cli/index.ts +0 -1
  255. package/src/cli/template-format.test.ts +2 -2
  256. package/src/content/fence-block.tsx +242 -15
  257. package/src/content/heading-components.test.tsx +65 -0
  258. package/src/content/heading-components.tsx +26 -5
  259. package/src/content/index.ts +19 -3
  260. package/src/content/markdown.test.tsx +603 -0
  261. package/src/content/markdown.tsx +355 -35
  262. package/src/content/sanitize.test.ts +659 -0
  263. package/src/content/sanitize.ts +510 -0
  264. package/src/dashboard/stat-card.test.tsx +6 -1
  265. package/src/guard/check-source.test.ts +8 -4
  266. package/src/guard/index.ts +1 -3
  267. package/src/index.ts +15 -1
  268. package/src/surfaces.ts +13 -4
  269. package/src/theme/contrast.test.ts +1 -4
  270. package/src/theme/density-relations.test.ts +4 -2
  271. package/src/theme/fonts-option.test.ts +7 -1
  272. package/src/theme/shadow-surfaces.test.ts +12 -6
  273. package/src/theme/use-basalt-spacing.test.tsx +2 -1
@@ -47,13 +47,22 @@
47
47
  * resolveOutcome: heuristicOutcome,
48
48
  * })
49
49
  */
50
- import type { AgentPart, SourcePart, ToolCallPart } from './parts'
51
- import type { AgentTransport } from './transport'
50
+ import { assertNever } from '../register'
51
+ import { TERMINAL_TOOL_STATES } from './parts'
52
+ import type { AgentPart, AgentPartDraft } from './parts'
53
+ import type { ResumableAgentTransport } from './transport'
52
54
 
53
55
  // ── ai (optional peer) — TYPE-ONLY imports, erased at runtime ────────────────
54
56
  // verbatimModuleSyntax-safe: these never trigger a runtime resolution of 'ai'. The actual runtime
55
57
  // access goes through the memoized `import('ai')` inside resolveAiSdk() below.
56
- import type { DefaultChatTransport, UIMessage, UIMessageChunk, readUIMessageStream } from 'ai'
58
+ import type {
59
+ DefaultChatTransport,
60
+ DynamicToolUIPart,
61
+ ToolUIPart,
62
+ UIMessage,
63
+ UIMessageChunk,
64
+ readUIMessageStream,
65
+ } from 'ai'
57
66
 
58
67
  /** The element type of an AI SDK UIMessage's `parts` array (defaults applied). */
59
68
  type AiPart = UIMessage['parts'][number]
@@ -76,13 +85,13 @@ export type AiSdkTransportOptions = {
76
85
 
77
86
  // ── AiSdkTransport ─────────────────────────────────────────────────────────────
78
87
 
79
- export type AiSdkTransport<TPart = AgentPart> = AgentTransport<TPart, string> & {
88
+ export type AiSdkTransport<TPart = AgentPart> = ResumableAgentTransport<TPart, string> & {
80
89
  /**
81
90
  * Returns an AgentTransport bound to a stable chat id (e.g. a basalt-ui thread id) instead of
82
91
  * this transport's own fixed one. Use this for useAgentThreadRuns's per-thread transport-factory
83
92
  * form so each thread's turns share one continuous AI SDK chat session.
84
93
  */
85
- readonly forThread: (chatId: string) => AgentTransport<TPart, string>
94
+ readonly forThread: (chatId: string) => ResumableAgentTransport<TPart, string>
86
95
  }
87
96
 
88
97
  // ── Lazy, memoized 'ai' resolution ────────────────────────────────────────────
@@ -120,80 +129,209 @@ function createAiSdkResolver(options: AiSdkTransportOptions): () => Promise<Reso
120
129
 
121
130
  /**
122
131
  * Diffs one AI SDK part (by array index) against its previous snapshot at that same index,
123
- * returning zero or more new AgentPart deltas.
132
+ * returning zero or more new AgentPart drafts, each carrying a deterministic id.
124
133
  *
125
- * source-document, file, reasoning-file, data-*, step-start, custom, and dynamic-tool parts have
126
- * no AgentPart equivalent yet — deliberate v1 gap, skipped rather than inventing new variants.
134
+ * Index-addressed parts (text, reasoning, source) get `${chatId}#${index}` — stable across
135
+ * replays of the same snapshot sequence, which is what lets `mergePart` rewrite instead of
136
+ * duplicate on a resumed/replayed stream. text/reasoning also carry an authoritative `offset`
137
+ * (the length of the previously-seen text for that part) so a full-length resend at offset 0
138
+ * splices over the existing content instead of appending — the literal mechanism
139
+ * `idempotentReplay: true` asserts.
140
+ *
141
+ * source-document, file, reasoning-file, data-*, step-start, and custom parts have no AgentPart
142
+ * equivalent yet — deliberate v1 gap, skipped rather than inventing new variants.
127
143
  */
128
- function diffPart(prevPart: AiPart | undefined, currPart: AiPart): AgentPart[] {
144
+ function diffPart(
145
+ chatId: string,
146
+ index: number,
147
+ prevPart: AiPart | undefined,
148
+ currPart: AiPart,
149
+ toolStartTimes: Map<string, number>,
150
+ ): AgentPartDraft[] {
129
151
  switch (currPart.type) {
130
152
  case 'text': {
131
153
  const prevText = prevPart?.type === 'text' ? prevPart.text : ''
132
154
  const delta = currPart.text.slice(prevText.length)
133
- return delta.length === 0 ? [] : [{ type: 'text', text: delta }]
155
+ return delta.length === 0
156
+ ? []
157
+ : [{ id: `${chatId}#${index}`, type: 'text', text: delta, offset: prevText.length }]
134
158
  }
135
159
  case 'reasoning': {
136
160
  const prevText = prevPart?.type === 'reasoning' ? prevPart.text : ''
137
161
  const delta = currPart.text.slice(prevText.length)
138
- return delta.length === 0 ? [] : [{ type: 'reasoning', text: delta }]
162
+ return delta.length === 0
163
+ ? []
164
+ : [{ id: `${chatId}#${index}`, type: 'reasoning', text: delta, offset: prevText.length }]
139
165
  }
140
166
  case 'source-url': {
141
167
  // Emit once, the first time this part appears at this index.
142
168
  if (prevPart !== undefined) return []
143
- const source: SourcePart = { type: 'source', url: currPart.url }
169
+ const source: AgentPartDraft = { id: `${chatId}#${index}`, type: 'source', url: currPart.url }
144
170
  return [currPart.title !== undefined ? { ...source, title: currPart.title } : source]
145
171
  }
146
172
  default:
147
- return currPart.type.startsWith('tool-') ? diffToolPart(prevPart, currPart) : []
173
+ return isToolLikePart(currPart) ? diffToolPart(prevPart, currPart, toolStartTimes) : []
148
174
  }
149
175
  }
150
176
 
177
+ /** True for a `tool-${name}` static part OR a `dynamic-tool` part — the two AI SDK shapes basalt
178
+ * models as one `ToolCallPart`. `'dynamic-tool'` does NOT start with `'tool-'`, so this must be
179
+ * its own check, not a `.startsWith('tool-')` alone (that silently drops every dynamic tool call). */
180
+ function isToolLikePart(part: AiPart): part is ToolUIPart | DynamicToolUIPart {
181
+ return part.type === 'dynamic-tool' || part.type.startsWith('tool-')
182
+ }
183
+
184
+ /** Derives the tool's display name — mirrors AI SDK's own `getToolName`/`getStaticToolName`
185
+ * runtime (reimplemented locally rather than value-imported, so this module never resolves 'ai'
186
+ * at runtime; see the module docblock). Static tools carry their name only in the `tool-${name}`
187
+ * discriminator; dynamic tools carry it in `toolName`. */
188
+ function deriveToolName(part: ToolUIPart | DynamicToolUIPart): string {
189
+ return part.type === 'dynamic-tool' ? part.toolName : part.type.split('-').slice(1).join('-')
190
+ }
191
+
192
+ /** The `{ output, preliminary }` snapshot of an `output-available` tool part, or undefined for
193
+ * any other state. */
194
+ function outputSnapshot(
195
+ part: ToolUIPart | DynamicToolUIPart,
196
+ ): { readonly output: unknown; readonly preliminary: boolean | undefined } | undefined {
197
+ return part.state === 'output-available'
198
+ ? { output: part.output, preliminary: part.preliminary }
199
+ : undefined
200
+ }
201
+
151
202
  /**
152
- * Minimal structural shape read off a `tool-${name}` UIMessagePart. Cast to this (rather than
153
- * AI SDK's own generic ToolUIPart<TOOLS>) because we diff by raw `type`/`state` string comparison,
154
- * not by a caller-supplied tool registry.
203
+ * True when `curr` reports nothing new relative to `prev` — same state AND (for output-available)
204
+ * the same output/preliminary content. A `preliminary: true` result streams several
205
+ * output-available snapshots with the SAME `state` but a MUTATING `output`; naive state-equality
206
+ * would freeze on the first snapshot and drop every refinement. Content (not reference) equality:
207
+ * each snapshot from `readUIMessageStream` is a fresh `structuredClone`, so `===` on nested output
208
+ * objects would never match even when genuinely unchanged.
155
209
  */
156
- type ToolLike = {
157
- readonly type: string
158
- readonly toolCallId: string
159
- readonly state: string
160
- readonly input?: unknown
161
- readonly output?: unknown
162
- readonly errorText?: string
210
+ function isSameToolState(
211
+ prev: ToolUIPart | DynamicToolUIPart,
212
+ curr: ToolUIPart | DynamicToolUIPart,
213
+ ): boolean {
214
+ if (prev.state !== curr.state) return false
215
+ const prevOutput = outputSnapshot(prev)
216
+ const currOutput = outputSnapshot(curr)
217
+ if (prevOutput === undefined || currOutput === undefined) return true
218
+ return (
219
+ JSON.stringify(prevOutput.output) === JSON.stringify(currOutput.output) &&
220
+ prevOutput.preliminary === currOutput.preliminary
221
+ )
163
222
  }
164
223
 
165
- function diffToolPart(prevPart: AiPart | undefined, currPart: AiPart): AgentPart[] {
166
- const curr = currPart as unknown as ToolLike
167
- // Deliberate v1 simplification: never emit on 'input-streaming' (partial/DeepPartial input) —
168
- // wait for at least 'input-available' so the consumer isn't flooded with incomplete fragments.
169
- if (curr.state === 'input-streaming') return []
224
+ /**
225
+ * Diffs one tool part across the seven-state `UIToolInvocation` union into a `ToolCallPart` draft,
226
+ * addressed by `tool#${toolCallId}` — stable across every state transition of one call, so
227
+ * `mergePart` rewrites the same part in place rather than stacking one entry per state.
228
+ *
229
+ * `durationMs` is basalt's own field (the SDK doesn't provide one): wall-clock from this
230
+ * toolCallId's first sighting (recorded in `toolStartTimes`, scoped to ONE stream() / resume()
231
+ * call — never module-global, so two concurrent threads never share timings) to a terminal state.
232
+ */
233
+ function diffToolPart(
234
+ prevPart: AiPart | undefined,
235
+ currPart: ToolUIPart | DynamicToolUIPart,
236
+ toolStartTimes: Map<string, number>,
237
+ ): AgentPartDraft[] {
238
+ if (!toolStartTimes.has(currPart.toolCallId)) {
239
+ toolStartTimes.set(currPart.toolCallId, Date.now())
240
+ }
241
+
242
+ const prev = prevPart !== undefined && isToolLikePart(prevPart) ? prevPart : undefined
243
+ if (prev !== undefined && isSameToolState(prev, currPart)) return []
170
244
 
171
- const prev =
172
- prevPart !== undefined && prevPart.type === currPart.type
173
- ? (prevPart as unknown as ToolLike)
174
- : undefined
175
- // Already emitted this exact state for this tool call index — nothing new to report.
176
- if (prev?.state === curr.state) return []
245
+ const settled = (TERMINAL_TOOL_STATES as readonly string[]).includes(currPart.state)
246
+ const durationMs = settled
247
+ ? Date.now() - (toolStartTimes.get(currPart.toolCallId) ?? Date.now())
248
+ : undefined
177
249
 
178
- const toolName = curr.type.slice('tool-'.length)
179
- const base: ToolCallPart = {
180
- type: 'tool',
181
- toolName,
182
- toolCallId: curr.toolCallId,
183
- input: curr.input,
250
+ const base = {
251
+ id: `tool#${currPart.toolCallId}`,
252
+ type: 'tool' as const,
253
+ toolCallId: currPart.toolCallId,
254
+ toolName: deriveToolName(currPart),
255
+ ...(currPart.providerExecuted !== undefined
256
+ ? { providerExecuted: currPart.providerExecuted }
257
+ : {}),
258
+ ...(durationMs !== undefined ? { durationMs } : {}),
259
+ }
260
+
261
+ switch (currPart.state) {
262
+ case 'input-streaming':
263
+ return [
264
+ {
265
+ ...base,
266
+ state: 'input-streaming',
267
+ ...(currPart.input !== undefined ? { input: currPart.input } : {}),
268
+ },
269
+ ]
270
+ case 'input-available':
271
+ return [{ ...base, state: 'input-available', input: currPart.input }]
272
+ case 'approval-requested':
273
+ return [
274
+ {
275
+ ...base,
276
+ state: 'approval-requested',
277
+ input: currPart.input,
278
+ approval: currPart.approval,
279
+ },
280
+ ]
281
+ case 'approval-responded':
282
+ return [
283
+ {
284
+ ...base,
285
+ state: 'approval-responded',
286
+ input: currPart.input,
287
+ approval: currPart.approval,
288
+ },
289
+ ]
290
+ case 'output-available':
291
+ return [
292
+ {
293
+ ...base,
294
+ state: 'output-available',
295
+ input: currPart.input,
296
+ output: currPart.output,
297
+ ...(currPart.preliminary !== undefined ? { preliminary: currPart.preliminary } : {}),
298
+ ...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
299
+ },
300
+ ]
301
+ case 'output-error': {
302
+ // rawInput only exists on the static ToolUIPart variant — a DynamicToolUIPart's output-error
303
+ // has no such field at all, so this must be read defensively behind the type discriminant.
304
+ const rawInput = currPart.type === 'dynamic-tool' ? undefined : currPart.rawInput
305
+ return [
306
+ {
307
+ ...base,
308
+ state: 'output-error',
309
+ ...(currPart.input !== undefined ? { input: currPart.input } : {}),
310
+ errorText: currPart.errorText,
311
+ ...(rawInput !== undefined ? { rawInput } : {}),
312
+ ...(currPart.approval !== undefined ? { approval: currPart.approval } : {}),
313
+ },
314
+ ]
315
+ }
316
+ case 'output-denied':
317
+ return [
318
+ { ...base, state: 'output-denied', input: currPart.input, approval: currPart.approval },
319
+ ]
320
+ default:
321
+ return assertNever(currPart)
184
322
  }
185
- if (curr.state === 'output-available') return [{ ...base, output: curr.output }]
186
- // output-error: still surface it as a tool part rather than silently dropping the failure — the
187
- // error text becomes the output payload since ToolCallPart has no dedicated error field.
188
- if (curr.state === 'output-error') return [{ ...base, output: { error: curr.errorText } }]
189
- return [base]
190
323
  }
191
324
 
192
325
  /** Diffs every index of one UIMessage snapshot against the previous snapshot. */
193
- function diffMessage(prev: UIMessage | undefined, curr: UIMessage): AgentPart[] {
194
- const deltas: AgentPart[] = []
326
+ function diffMessage(
327
+ chatId: string,
328
+ prev: UIMessage | undefined,
329
+ curr: UIMessage,
330
+ toolStartTimes: Map<string, number>,
331
+ ): AgentPartDraft[] {
332
+ const deltas: AgentPartDraft[] = []
195
333
  curr.parts.forEach((part, i) => {
196
- deltas.push(...diffPart(prev?.parts[i], part))
334
+ deltas.push(...diffPart(chatId, i, prev?.parts[i], part, toolStartTimes))
197
335
  })
198
336
  return deltas
199
337
  }
@@ -204,19 +342,28 @@ function diffMessage(prev: UIMessage | undefined, curr: UIMessage): AgentPart[]
204
342
  * built-in AgentPart variants, so TPart must be — or be assignable from — AgentPart for a
205
343
  * meaningful result; same documented-cast convention as useAgentThreadRuns' defaultToUserParts).
206
344
  *
345
+ * `chatId` is the id-namespace for this diff pass — the caller's chat id on stream(), the
346
+ * resumeToken (which IS the chat id, see the module docblock's CHAT ID BINDING note) on resume() —
347
+ * so index-addressed ids line up with the original run's ids across a reconnect.
348
+ *
349
+ * A fresh `toolStartTimes` map is scoped to ONE diffChunkStream call (one stream()/resume()
350
+ * invocation), never shared across calls or threads.
351
+ *
207
352
  * `resume()`'s underlying reconnectToStream call has no AbortSignal parameter in the current AI
208
353
  * SDK API (unlike sendMessages), so we can't cancel the in-flight fetch directly — checking
209
354
  * `signal.aborted` here at least stops yielding further deltas to an aborted caller promptly.
210
355
  */
211
356
  async function* diffChunkStream<TPart>(
357
+ chatId: string,
212
358
  chunkStream: ReadableStream<UIMessageChunk>,
213
359
  readStream: typeof readUIMessageStream,
214
360
  signal?: AbortSignal,
215
361
  ): AsyncGenerator<TPart> {
216
362
  let prev: UIMessage | undefined
363
+ const toolStartTimes = new Map<string, number>()
217
364
  for await (const curr of readStream<UIMessage>({ stream: chunkStream })) {
218
365
  if (signal?.aborted) return
219
- for (const part of diffMessage(prev, curr)) {
366
+ for (const part of diffMessage(chatId, prev, curr, toolStartTimes)) {
220
367
  yield part as TPart
221
368
  }
222
369
  prev = curr
@@ -241,12 +388,16 @@ export function aiSdkTransport<TPart = AgentPart>(
241
388
  ): AiSdkTransport<TPart> {
242
389
  const resolveAiSdk = createAiSdkResolver(options)
243
390
 
244
- function makeTransport(chatId: string): AgentTransport<TPart, string> {
391
+ function makeTransport(chatId: string): ResumableAgentTransport<TPart, string> {
245
392
  return {
393
+ // Every text/reasoning delta this transport emits carries an authoritative `offset` (see
394
+ // diffPart), so a replayed run rewrites in place instead of duplicating — the literal
395
+ // condition this assertion stands for.
396
+ idempotentReplay: true,
246
397
  async *stream(input: string, signal?: AbortSignal): AsyncGenerator<TPart> {
247
398
  // Synthesized locally, first — we already know the chat id client-side, so there is no
248
399
  // need to wait on the server to hand us a run id (unlike a river-style protocol).
249
- yield { type: 'start', runId: chatId, resumeToken: chatId } as TPart
400
+ yield { id: `${chatId}#start`, type: 'start', runId: chatId, resumeToken: chatId } as TPart
250
401
 
251
402
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
252
403
  const userMessage: UIMessage = {
@@ -261,13 +412,15 @@ export function aiSdkTransport<TPart = AgentPart>(
261
412
  messageId: undefined,
262
413
  abortSignal: signal,
263
414
  })
264
- yield* diffChunkStream<TPart>(chunkStream, readStream, signal)
415
+ yield* diffChunkStream<TPart>(chatId, chunkStream, readStream, signal)
265
416
  },
266
417
  async *resume(resumeToken: string, signal?: AbortSignal): AsyncGenerator<TPart> {
267
418
  const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
268
419
  const chunkStream = await httpTransport.reconnectToStream({ chatId: resumeToken })
269
420
  if (chunkStream === null) return
270
- yield* diffChunkStream<TPart>(chunkStream, readStream, signal)
421
+ // resumeToken IS the chat id (see CHAT ID BINDING above) — reusing it as the id-namespace
422
+ // here keeps replayed index-addressed ids identical to the ones the original run minted.
423
+ yield* diffChunkStream<TPart>(resumeToken, chunkStream, readStream, signal)
271
424
  },
272
425
  }
273
426
  }