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
@@ -1,15 +1,18 @@
1
1
  /**
2
- * parseAgentPart / isStartPart — the runtime narrowing guard over the AgentPart wire format.
2
+ * parseAgentPart / isStartPart / isToolCallSettled — the runtime narrowing guard over the
3
+ * AgentPart wire format, post-1.11.0 part identity + the seven-state ToolCallPart union.
3
4
  *
4
- * Scope: the parser AS IT EXISTS TODAY (B1) — no required `id`/`toolCallId`, no `AgentPartDraft`,
5
- * no seven-state ToolCallPart union; those are 1.10.0 part-identity work landing on top of this
6
- * currently-untested parser (see the sibling `ai-sdk-transport.test.ts`'s scope note for the same
7
- * cutoff). Every AgentPart variant below is validated against parts.ts:87's `parseAgentPart` and
8
- * parts.ts:134's `isStartPart` as they read right now.
5
+ * Every AgentPart variant below is validated against parts.ts's `parseAgentPart`, `isStartPart`,
6
+ * and `isToolCallSettled` as they read right now: `id` is required on every variant, `toolCallId`
7
+ * is required on the three in-flight tool states and optional on the four settled ones (see the
8
+ * per-state identity block below), and `tool` parts are validated against the seven-state
9
+ * discriminated union mirroring AI SDK v7's
10
+ * `UIToolInvocation` (see parts.ts's ToolCallPart doc for the ground-truth corrections against the
11
+ * design spec).
9
12
  */
10
13
  import { describe, expect, test } from 'bun:test'
11
- import { isStartPart, parseAgentPart } from './parts'
12
- import type { AgentPart } from './parts'
14
+ import { isStartPart, isToolCallSettled, parseAgentPart } from './parts'
15
+ import type { AgentPart, ToolCallPart, ToolCallState } from './parts'
13
16
 
14
17
  /** JSON round-trip a value through JSON.stringify → JSON.parse, mirroring wire transport. */
15
18
  function roundTrip(value: unknown): unknown {
@@ -19,13 +22,14 @@ function roundTrip(value: unknown): unknown {
19
22
  describe('parseAgentPart', () => {
20
23
  describe('start', () => {
21
24
  test('parses a minimal start part (no resumeToken)', () => {
22
- const raw = { type: 'start', runId: 'run-1' }
23
- expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
25
+ const raw = { id: 'p1', type: 'start', runId: 'run-1' }
26
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
24
27
  })
25
28
 
26
29
  test('parses a start part with resumeToken', () => {
27
- const raw = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
30
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
28
31
  expect(parseAgentPart(raw)).toEqual({
32
+ id: 'p1',
29
33
  type: 'start',
30
34
  runId: 'run-1',
31
35
  resumeToken: 'tok-1',
@@ -33,120 +37,512 @@ describe('parseAgentPart', () => {
33
37
  })
34
38
 
35
39
  test('drops a non-string resumeToken rather than passing it through', () => {
36
- const raw = { type: 'start', runId: 'run-1', resumeToken: 42 }
37
- expect(parseAgentPart(raw)).toEqual({ type: 'start', runId: 'run-1' })
40
+ const raw = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 42 }
41
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'start', runId: 'run-1' })
38
42
  })
39
43
 
40
44
  test('rejects a missing runId', () => {
41
- expect(parseAgentPart({ type: 'start' })).toBeNull()
45
+ expect(parseAgentPart({ id: 'p1', type: 'start' })).toBeNull()
42
46
  })
43
47
 
44
48
  test('rejects a non-string runId', () => {
45
- expect(parseAgentPart({ type: 'start', runId: 42 })).toBeNull()
49
+ expect(parseAgentPart({ id: 'p1', type: 'start', runId: 42 })).toBeNull()
50
+ })
51
+
52
+ test('rejects a missing id', () => {
53
+ expect(parseAgentPart({ type: 'start', runId: 'run-1' })).toBeNull()
46
54
  })
47
55
 
48
56
  test('round-trips through JSON', () => {
49
- const part: AgentPart = { type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
57
+ const part: AgentPart = { id: 'p1', type: 'start', runId: 'run-1', resumeToken: 'tok-1' }
50
58
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
51
59
  })
52
60
  })
53
61
 
54
62
  describe('text', () => {
55
63
  test('parses a text part', () => {
56
- expect(parseAgentPart({ type: 'text', text: 'hello' })).toEqual({
64
+ expect(parseAgentPart({ id: 'p1', type: 'text', text: 'hello' })).toEqual({
65
+ id: 'p1',
57
66
  type: 'text',
58
67
  text: 'hello',
59
68
  })
60
69
  })
61
70
 
71
+ test('parses a text part with offset', () => {
72
+ const raw = { id: 'p1', type: 'text', text: 'llo', offset: 2 }
73
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'text', text: 'llo', offset: 2 })
74
+ })
75
+
62
76
  test('rejects a missing text field', () => {
63
- expect(parseAgentPart({ type: 'text' })).toBeNull()
77
+ expect(parseAgentPart({ id: 'p1', type: 'text' })).toBeNull()
64
78
  })
65
79
 
66
80
  test('rejects a non-string text field', () => {
67
- expect(parseAgentPart({ type: 'text', text: 1 })).toBeNull()
81
+ expect(parseAgentPart({ id: 'p1', type: 'text', text: 1 })).toBeNull()
82
+ })
83
+
84
+ test('rejects a missing id', () => {
85
+ expect(parseAgentPart({ type: 'text', text: 'hello' })).toBeNull()
68
86
  })
69
87
 
70
88
  test('round-trips through JSON', () => {
71
- const part: AgentPart = { type: 'text', text: 'hello' }
89
+ const part: AgentPart = { id: 'p1', type: 'text', text: 'hello', offset: 0 }
72
90
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
73
91
  })
74
92
  })
75
93
 
76
94
  describe('reasoning', () => {
77
95
  test('parses a reasoning part', () => {
78
- expect(parseAgentPart({ type: 'reasoning', text: 'thinking...' })).toEqual({
96
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning', text: 'thinking...' })).toEqual({
97
+ id: 'p1',
79
98
  type: 'reasoning',
80
99
  text: 'thinking...',
81
100
  })
82
101
  })
83
102
 
84
103
  test('rejects a missing text field', () => {
85
- expect(parseAgentPart({ type: 'reasoning' })).toBeNull()
104
+ expect(parseAgentPart({ id: 'p1', type: 'reasoning' })).toBeNull()
86
105
  })
87
106
 
88
107
  test('round-trips through JSON', () => {
89
- const part: AgentPart = { type: 'reasoning', text: 'thinking...' }
108
+ const part: AgentPart = { id: 'p1', type: 'reasoning', text: 'thinking...' }
90
109
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
91
110
  })
92
111
  })
93
112
 
94
113
  describe('tool', () => {
95
- test('parses a minimal tool part (input only, no output/toolCallId)', () => {
96
- const raw = { type: 'tool', toolName: 'search', input: { q: 'x' } }
97
- expect(parseAgentPart(raw)).toEqual({
114
+ test('rejects a non-string toolCallId', () => {
115
+ const raw = {
116
+ id: 'p1',
98
117
  type: 'tool',
118
+ toolCallId: 42,
119
+ toolName: 'search',
120
+ state: 'input-available',
121
+ input: {},
122
+ }
123
+ expect(parseAgentPart(raw)).toBeNull()
124
+ })
125
+
126
+ // ── toolCallId identity, decided per state ──────────────────────────────
127
+ // An empty string is not a value any state accepts: '' normalizes to absent right here, so the
128
+ // pre-1.12.0 sentinel can never reach the type or a consumer of it. What an ABSENT id then
129
+ // means is a per-state decision (see Identified / MaybeIdentified in parts.ts):
130
+ // in-flight (input-streaming, input-available, approval-requested) → REJECTED. The part
131
+ // exists only to be closed by a later state, which re-supplies its toolName and input;
132
+ // unidentified it could never be closed and would render as a forever-pending chip.
133
+ // settled (approval-responded + the three terminal states) → KEPT, unidentified.
134
+ // It carries the OUTCOME; dropping it would erase how the call ended (silent data loss),
135
+ // while keeping it costs only the fold — coalesceParts renders it as its own block.
136
+ //
137
+ // The `satisfies Record<ToolCallState, …>` below is an authoring-time exhaustiveness gate: add
138
+ // a state to the union and this fixture stops typechecking until its identity behaviour is
139
+ // decided here too. It is an EDITOR/tsc guard only — the package tsconfig excludes `*.test.ts`
140
+ // from `bun run typecheck`, and `bun test` type-strips rather than typechecks — so the
141
+ // "covers every state exactly once" test below backs it with a runtime check of the same claim.
142
+
143
+ const UNIDENTIFIED_RAW = {
144
+ 'input-streaming': { toolName: 'search', state: 'input-streaming' },
145
+ 'input-available': { toolName: 'search', state: 'input-available', input: { q: 'x' } },
146
+ 'approval-requested': {
147
+ toolName: 'search',
148
+ state: 'approval-requested',
149
+ input: { q: 'x' },
150
+ approval: { id: 'appr-1' },
151
+ },
152
+ 'approval-responded': {
153
+ toolName: 'search',
154
+ state: 'approval-responded',
155
+ input: { q: 'x' },
156
+ approval: { id: 'appr-1', approved: true },
157
+ },
158
+ 'output-available': {
99
159
  toolName: 'search',
160
+ state: 'output-available',
100
161
  input: { q: 'x' },
162
+ output: { hits: 3 },
163
+ },
164
+ 'output-error': { toolName: 'search', state: 'output-error', errorText: 'boom' },
165
+ 'output-denied': {
166
+ toolName: 'search',
167
+ state: 'output-denied',
168
+ input: { q: 'x' },
169
+ approval: { id: 'appr-1', approved: false },
170
+ },
171
+ } satisfies Record<ToolCallState, Record<string, unknown>>
172
+
173
+ const IN_FLIGHT_STATES = [
174
+ 'input-streaming',
175
+ 'input-available',
176
+ 'approval-requested',
177
+ ] as const satisfies readonly ToolCallState[]
178
+
179
+ const SETTLED_STATES = [
180
+ 'approval-responded',
181
+ 'output-available',
182
+ 'output-error',
183
+ 'output-denied',
184
+ ] as const satisfies readonly ToolCallState[]
185
+
186
+ test('the in-flight/settled split covers every tool state exactly once', () => {
187
+ // Object.keys() is always typed string[] regardless of the object's key type — cast back to
188
+ // ToolCallState[], which every key of UNIDENTIFIED_RAW genuinely is (used as
189
+ // UNIDENTIFIED_RAW[state] elsewhere in this file).
190
+ expect([...IN_FLIGHT_STATES, ...SETTLED_STATES].toSorted()).toEqual(
191
+ (Object.keys(UNIDENTIFIED_RAW) as ToolCallState[]).toSorted(),
192
+ )
193
+ })
194
+
195
+ for (const state of IN_FLIGHT_STATES) {
196
+ test(`${state}: rejects a missing toolCallId — an in-flight part that could never be closed`, () => {
197
+ expect(parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })).toBeNull()
101
198
  })
199
+
200
+ test(`${state}: rejects an empty-string toolCallId identically (it means absent)`, () => {
201
+ const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
202
+ expect(parseAgentPart(raw)).toBeNull()
203
+ })
204
+ }
205
+
206
+ for (const state of SETTLED_STATES) {
207
+ test(`${state}: a missing toolCallId keeps the part UNIDENTIFIED — it is never dropped`, () => {
208
+ const parsed = parseAgentPart({ id: 'p1', type: 'tool', ...UNIDENTIFIED_RAW[state] })
209
+ expect(parsed).not.toBeNull()
210
+ expect((parsed as ToolCallPart).state).toBe(state)
211
+ expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
212
+ })
213
+
214
+ test(`${state}: an empty-string toolCallId normalizes to absent, never to ""`, () => {
215
+ const raw = { id: 'p1', type: 'tool', toolCallId: '', ...UNIDENTIFIED_RAW[state] }
216
+ const parsed = parseAgentPart(raw)
217
+ expect(parsed).not.toBeNull()
218
+ expect(Object.hasOwn(parsed as object, 'toolCallId')).toBe(false)
219
+ })
220
+
221
+ test(`${state}: a real toolCallId still carries through`, () => {
222
+ const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', ...UNIDENTIFIED_RAW[state] }
223
+ expect((parseAgentPart(raw) as ToolCallPart | null)?.toolCallId).toBe('call-1')
224
+ })
225
+ }
226
+
227
+ test('approval-responded: a real toolCallId round-trips through JSON', () => {
228
+ const part: ToolCallPart = {
229
+ id: 'p1',
230
+ type: 'tool',
231
+ toolCallId: 'call-1',
232
+ toolName: 'delete_file',
233
+ state: 'approval-responded',
234
+ input: { path: '/tmp/x' },
235
+ approval: { id: 'appr-1', approved: true },
236
+ }
237
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
238
+ })
239
+
240
+ test('rejects a missing toolName', () => {
241
+ const raw = {
242
+ id: 'p1',
243
+ type: 'tool',
244
+ toolCallId: 'call-1',
245
+ state: 'input-available',
246
+ input: {},
247
+ }
248
+ expect(parseAgentPart(raw)).toBeNull()
249
+ })
250
+
251
+ test('rejects a missing state', () => {
252
+ const raw = { id: 'p1', type: 'tool', toolCallId: 'call-1', toolName: 'search', input: {} }
253
+ expect(parseAgentPart(raw)).toBeNull()
102
254
  })
103
255
 
104
- test('parses a tool part with output and toolCallId', () => {
256
+ test('rejects an unknown state', () => {
105
257
  const raw = {
258
+ id: 'p1',
106
259
  type: 'tool',
260
+ toolCallId: 'call-1',
107
261
  toolName: 'search',
262
+ state: 'running',
263
+ input: {},
264
+ }
265
+ expect(parseAgentPart(raw)).toBeNull()
266
+ })
267
+
268
+ test('rejects the pre-1.11.0 flat shape (no `state` field at all)', () => {
269
+ const raw = {
270
+ id: 'p1',
271
+ type: 'tool',
272
+ toolName: 'search',
273
+ toolCallId: 'call-1',
108
274
  input: { q: 'x' },
109
275
  output: { hits: 3 },
276
+ }
277
+ expect(parseAgentPart(raw)).toBeNull()
278
+ })
279
+
280
+ // ── the seven states ────────────────────────────────────────────────────
281
+
282
+ test('input-streaming: parses with no input', () => {
283
+ const raw = {
284
+ id: 'p1',
285
+ type: 'tool',
110
286
  toolCallId: 'call-1',
287
+ toolName: 'search',
288
+ state: 'input-streaming',
111
289
  }
112
290
  expect(parseAgentPart(raw)).toEqual({
291
+ id: 'p1',
292
+ type: 'tool',
293
+ toolCallId: 'call-1',
294
+ toolName: 'search',
295
+ state: 'input-streaming',
296
+ })
297
+ })
298
+
299
+ test('input-streaming: parses with a partial input', () => {
300
+ const raw = {
301
+ id: 'p1',
113
302
  type: 'tool',
303
+ toolCallId: 'call-1',
114
304
  toolName: 'search',
305
+ state: 'input-streaming',
115
306
  input: { q: 'x' },
116
- output: { hits: 3 },
307
+ }
308
+ expect(parseAgentPart(raw)).toEqual({
309
+ id: 'p1',
310
+ type: 'tool',
117
311
  toolCallId: 'call-1',
312
+ toolName: 'search',
313
+ state: 'input-streaming',
314
+ input: { q: 'x' },
118
315
  })
119
316
  })
120
317
 
121
- test('rejects a missing toolName', () => {
122
- expect(parseAgentPart({ type: 'tool', input: {} })).toBeNull()
318
+ test('input-available: requires input', () => {
319
+ const raw = {
320
+ id: 'p1',
321
+ type: 'tool',
322
+ toolCallId: 'call-1',
323
+ toolName: 'search',
324
+ state: 'input-available',
325
+ }
326
+ expect(parseAgentPart(raw)).toBeNull()
327
+ })
328
+
329
+ test('input-available: round-trips', () => {
330
+ const part: ToolCallPart = {
331
+ id: 'p1',
332
+ type: 'tool',
333
+ toolCallId: 'call-1',
334
+ toolName: 'search',
335
+ state: 'input-available',
336
+ input: { q: 'x' },
337
+ }
338
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
123
339
  })
124
340
 
125
- test('rejects a non-string toolName', () => {
126
- expect(parseAgentPart({ type: 'tool', toolName: 1, input: {} })).toBeNull()
341
+ test('approval-requested: round-trips', () => {
342
+ const part: ToolCallPart = {
343
+ id: 'p1',
344
+ type: 'tool',
345
+ toolCallId: 'call-1',
346
+ toolName: 'search',
347
+ state: 'approval-requested',
348
+ input: { q: 'x' },
349
+ approval: { id: 'appr-1' },
350
+ }
351
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
127
352
  })
128
353
 
129
- test('rejects a missing input key entirely (undefined input is a valid value, absent key is not)', () => {
130
- expect(parseAgentPart({ type: 'tool', toolName: 'search' })).toBeNull()
354
+ test('approval-requested: rejects a missing approval', () => {
355
+ const raw = {
356
+ id: 'p1',
357
+ type: 'tool',
358
+ toolCallId: 'call-1',
359
+ toolName: 'search',
360
+ state: 'approval-requested',
361
+ input: { q: 'x' },
362
+ }
363
+ expect(parseAgentPart(raw)).toBeNull()
131
364
  })
132
365
 
133
- test('accepts an explicit `input: undefined` (the key is present)', () => {
134
- const raw = { type: 'tool', toolName: 'search', input: undefined }
135
- expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: undefined })
366
+ test('approval-requested: rejects an approval that already carries approved (a verdict, not a pending request)', () => {
367
+ const raw = {
368
+ id: 'p1',
369
+ type: 'tool',
370
+ toolCallId: 'call-1',
371
+ toolName: 'search',
372
+ state: 'approval-requested',
373
+ input: { q: 'x' },
374
+ approval: { id: 'appr-1', approved: true },
375
+ }
376
+ expect(parseAgentPart(raw)).toBeNull()
136
377
  })
137
378
 
138
- test('drops a non-string toolCallId rather than passing it through', () => {
139
- const raw = { type: 'tool', toolName: 'search', input: {}, toolCallId: 7 }
140
- expect(parseAgentPart(raw)).toEqual({ type: 'tool', toolName: 'search', input: {} })
379
+ test('approval-requested: rejects an approval that already carries a reason', () => {
380
+ const raw = {
381
+ id: 'p1',
382
+ type: 'tool',
383
+ toolCallId: 'call-1',
384
+ toolName: 'search',
385
+ state: 'approval-requested',
386
+ input: { q: 'x' },
387
+ approval: { id: 'appr-1', reason: 'too risky' },
388
+ }
389
+ expect(parseAgentPart(raw)).toBeNull()
141
390
  })
142
391
 
143
- test('round-trips through JSON', () => {
144
- const part: AgentPart = {
392
+ test('approval-responded: round-trips with approved + reason', () => {
393
+ const part: ToolCallPart = {
394
+ id: 'p1',
395
+ type: 'tool',
396
+ toolCallId: 'call-1',
397
+ toolName: 'search',
398
+ state: 'approval-responded',
399
+ input: { q: 'x' },
400
+ approval: { id: 'appr-1', approved: true, reason: 'looks safe' },
401
+ }
402
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
403
+ })
404
+
405
+ test('approval-responded: rejects a non-boolean approved', () => {
406
+ const raw = {
407
+ id: 'p1',
408
+ type: 'tool',
409
+ toolCallId: 'call-1',
410
+ toolName: 'search',
411
+ state: 'approval-responded',
412
+ input: { q: 'x' },
413
+ approval: { id: 'appr-1' },
414
+ }
415
+ expect(parseAgentPart(raw)).toBeNull()
416
+ })
417
+
418
+ test('output-denied: round-trips (approved: false)', () => {
419
+ const part: ToolCallPart = {
420
+ id: 'p1',
421
+ type: 'tool',
422
+ toolCallId: 'call-1',
423
+ toolName: 'search',
424
+ state: 'output-denied',
425
+ input: { q: 'x' },
426
+ approval: { id: 'appr-1', approved: false, reason: 'too risky' },
427
+ }
428
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
429
+ })
430
+
431
+ test('output-denied: rejects approved !== false', () => {
432
+ const raw = {
433
+ id: 'p1',
434
+ type: 'tool',
435
+ toolCallId: 'call-1',
436
+ toolName: 'search',
437
+ state: 'output-denied',
438
+ input: { q: 'x' },
439
+ approval: { id: 'appr-1', approved: true },
440
+ }
441
+ expect(parseAgentPart(raw)).toBeNull()
442
+ })
443
+
444
+ test('output-available: round-trips with output + preliminary', () => {
445
+ const part: ToolCallPart = {
446
+ id: 'p1',
447
+ type: 'tool',
448
+ toolCallId: 'call-1',
449
+ toolName: 'search',
450
+ state: 'output-available',
451
+ input: { q: 'x' },
452
+ output: { hits: 3 },
453
+ preliminary: true,
454
+ }
455
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
456
+ })
457
+
458
+ test('output-available: an optional approval carries through', () => {
459
+ const part: ToolCallPart = {
460
+ id: 'p1',
461
+ type: 'tool',
462
+ toolCallId: 'call-1',
463
+ toolName: 'search',
464
+ state: 'output-available',
465
+ input: { q: 'x' },
466
+ output: { hits: 3 },
467
+ approval: { id: 'appr-1', approved: true },
468
+ }
469
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
470
+ })
471
+
472
+ test('output-available: rejects a missing output', () => {
473
+ const raw = {
474
+ id: 'p1',
475
+ type: 'tool',
476
+ toolCallId: 'call-1',
477
+ toolName: 'search',
478
+ state: 'output-available',
479
+ input: { q: 'x' },
480
+ }
481
+ expect(parseAgentPart(raw)).toBeNull()
482
+ })
483
+
484
+ test('output-available: rejects approval.approved === false (a denial masquerading as output)', () => {
485
+ const raw = {
486
+ id: 'p1',
145
487
  type: 'tool',
488
+ toolCallId: 'call-1',
146
489
  toolName: 'search',
490
+ state: 'output-available',
147
491
  input: { q: 'x' },
148
492
  output: { hits: 3 },
493
+ approval: { id: 'appr-1', approved: false },
494
+ }
495
+ expect(parseAgentPart(raw)).toBeNull()
496
+ })
497
+
498
+ test('output-error: uses errorText, not error, and round-trips with rawInput', () => {
499
+ const part: ToolCallPart = {
500
+ id: 'p1',
501
+ type: 'tool',
502
+ toolCallId: 'call-1',
503
+ toolName: 'search',
504
+ state: 'output-error',
505
+ errorText: 'boom',
506
+ rawInput: '{"q": invalid json',
507
+ }
508
+ expect(parseAgentPart(roundTrip(part))).toEqual(part)
509
+ })
510
+
511
+ test('output-error: rejects a missing errorText', () => {
512
+ const raw = {
513
+ id: 'p1',
514
+ type: 'tool',
515
+ toolCallId: 'call-1',
516
+ toolName: 'search',
517
+ state: 'output-error',
518
+ }
519
+ expect(parseAgentPart(raw)).toBeNull()
520
+ })
521
+
522
+ test('output-error: rejects approval.approved === false (a denial masquerading as an error)', () => {
523
+ const raw = {
524
+ id: 'p1',
525
+ type: 'tool',
526
+ toolCallId: 'call-1',
527
+ toolName: 'search',
528
+ state: 'output-error',
529
+ errorText: 'boom',
530
+ approval: { id: 'appr-1', approved: false },
531
+ }
532
+ expect(parseAgentPart(raw)).toBeNull()
533
+ })
534
+
535
+ test('durationMs and providerExecuted carry through when present', () => {
536
+ const part: ToolCallPart = {
537
+ id: 'p1',
538
+ type: 'tool',
149
539
  toolCallId: 'call-1',
540
+ toolName: 'search',
541
+ state: 'output-available',
542
+ input: { q: 'x' },
543
+ output: { hits: 3 },
544
+ durationMs: 42,
545
+ providerExecuted: true,
150
546
  }
151
547
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
152
548
  })
@@ -154,15 +550,17 @@ describe('parseAgentPart', () => {
154
550
 
155
551
  describe('source', () => {
156
552
  test('parses a minimal source part (no title)', () => {
157
- expect(parseAgentPart({ type: 'source', url: 'https://example.com' })).toEqual({
553
+ expect(parseAgentPart({ id: 'p1', type: 'source', url: 'https://example.com' })).toEqual({
554
+ id: 'p1',
158
555
  type: 'source',
159
556
  url: 'https://example.com',
160
557
  })
161
558
  })
162
559
 
163
560
  test('parses a source part with title', () => {
164
- const raw = { type: 'source', url: 'https://example.com', title: 'Example' }
561
+ const raw = { id: 'p1', type: 'source', url: 'https://example.com', title: 'Example' }
165
562
  expect(parseAgentPart(raw)).toEqual({
563
+ id: 'p1',
166
564
  type: 'source',
167
565
  url: 'https://example.com',
168
566
  title: 'Example',
@@ -170,38 +568,44 @@ describe('parseAgentPart', () => {
170
568
  })
171
569
 
172
570
  test('rejects a missing url', () => {
173
- expect(parseAgentPart({ type: 'source' })).toBeNull()
571
+ expect(parseAgentPart({ id: 'p1', type: 'source' })).toBeNull()
174
572
  })
175
573
 
176
574
  test('drops a non-string title rather than passing it through', () => {
177
- const raw = { type: 'source', url: 'https://example.com', title: 7 }
178
- expect(parseAgentPart(raw)).toEqual({ type: 'source', url: 'https://example.com' })
575
+ const raw = { id: 'p1', type: 'source', url: 'https://example.com', title: 7 }
576
+ expect(parseAgentPart(raw)).toEqual({ id: 'p1', type: 'source', url: 'https://example.com' })
179
577
  })
180
578
 
181
579
  test('round-trips through JSON', () => {
182
- const part: AgentPart = { type: 'source', url: 'https://example.com', title: 'Example' }
580
+ const part: AgentPart = {
581
+ id: 'p1',
582
+ type: 'source',
583
+ url: 'https://example.com',
584
+ title: 'Example',
585
+ }
183
586
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
184
587
  })
185
588
  })
186
589
 
187
590
  describe('error', () => {
188
591
  test('parses an error part', () => {
189
- expect(parseAgentPart({ type: 'error', message: 'boom' })).toEqual({
592
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 'boom' })).toEqual({
593
+ id: 'p1',
190
594
  type: 'error',
191
595
  message: 'boom',
192
596
  })
193
597
  })
194
598
 
195
599
  test('rejects a missing message', () => {
196
- expect(parseAgentPart({ type: 'error' })).toBeNull()
600
+ expect(parseAgentPart({ id: 'p1', type: 'error' })).toBeNull()
197
601
  })
198
602
 
199
603
  test('rejects a non-string message', () => {
200
- expect(parseAgentPart({ type: 'error', message: 500 })).toBeNull()
604
+ expect(parseAgentPart({ id: 'p1', type: 'error', message: 500 })).toBeNull()
201
605
  })
202
606
 
203
607
  test('round-trips through JSON', () => {
204
- const part: AgentPart = { type: 'error', message: 'boom' }
608
+ const part: AgentPart = { id: 'p1', type: 'error', message: 'boom' }
205
609
  expect(parseAgentPart(roundTrip(part))).toEqual(part)
206
610
  })
207
611
  })
@@ -225,26 +629,26 @@ describe('parseAgentPart', () => {
225
629
  })
226
630
 
227
631
  test('rejects an object with no type field', () => {
228
- expect(parseAgentPart({ text: 'hello' })).toBeNull()
632
+ expect(parseAgentPart({ id: 'p1', text: 'hello' })).toBeNull()
229
633
  })
230
634
 
231
635
  test('rejects an object with a non-string type field', () => {
232
- expect(parseAgentPart({ type: 1 })).toBeNull()
636
+ expect(parseAgentPart({ id: 'p1', type: 1 })).toBeNull()
233
637
  })
234
638
 
235
639
  test('rejects an unknown type discriminant', () => {
236
- expect(parseAgentPart({ type: 'unknown-variant', text: 'hi' })).toBeNull()
640
+ expect(parseAgentPart({ id: 'p1', type: 'unknown-variant', text: 'hi' })).toBeNull()
237
641
  })
238
642
  })
239
643
  })
240
644
 
241
645
  describe('isStartPart', () => {
242
646
  test('returns true for a StartPart', () => {
243
- expect(isStartPart({ type: 'start', runId: 'run-1' })).toBe(true)
647
+ expect(isStartPart({ id: 'p1', type: 'start', runId: 'run-1' })).toBe(true)
244
648
  })
245
649
 
246
650
  test('returns false for a non-start AgentPart', () => {
247
- expect(isStartPart({ type: 'text', text: 'hi' })).toBe(false)
651
+ expect(isStartPart({ id: 'p1', type: 'text', text: 'hi' })).toBe(false)
248
652
  })
249
653
 
250
654
  test('returns false for null', () => {
@@ -259,3 +663,58 @@ describe('isStartPart', () => {
259
663
  expect(isStartPart({ runId: 'run-1' })).toBe(false)
260
664
  })
261
665
  })
666
+
667
+ describe('isToolCallSettled', () => {
668
+ const base = { id: 'p1', type: 'tool', toolCallId: 'call-1', toolName: 'search' } as const
669
+
670
+ test('false for input-streaming', () => {
671
+ expect(isToolCallSettled({ ...base, state: 'input-streaming' })).toBe(false)
672
+ })
673
+
674
+ test('false for input-available', () => {
675
+ expect(isToolCallSettled({ ...base, state: 'input-available', input: {} })).toBe(false)
676
+ })
677
+
678
+ test('false for approval-requested', () => {
679
+ expect(
680
+ isToolCallSettled({
681
+ ...base,
682
+ state: 'approval-requested',
683
+ input: {},
684
+ approval: { id: 'appr-1' },
685
+ }),
686
+ ).toBe(false)
687
+ })
688
+
689
+ test('false for approval-responded', () => {
690
+ expect(
691
+ isToolCallSettled({
692
+ ...base,
693
+ state: 'approval-responded',
694
+ input: {},
695
+ approval: { id: 'appr-1', approved: true },
696
+ }),
697
+ ).toBe(false)
698
+ })
699
+
700
+ test('true for output-available', () => {
701
+ expect(isToolCallSettled({ ...base, state: 'output-available', input: {}, output: {} })).toBe(
702
+ true,
703
+ )
704
+ })
705
+
706
+ test('true for output-error', () => {
707
+ expect(isToolCallSettled({ ...base, state: 'output-error', errorText: 'boom' })).toBe(true)
708
+ })
709
+
710
+ test('true for output-denied', () => {
711
+ expect(
712
+ isToolCallSettled({
713
+ ...base,
714
+ state: 'output-denied',
715
+ input: {},
716
+ approval: { id: 'appr-1', approved: false },
717
+ }),
718
+ ).toBe(true)
719
+ })
720
+ })