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
@@ -3,12 +3,20 @@
3
3
  *
4
4
  * Exhaustiveness is enforced at every switch via assertNever (imported from '../register').
5
5
  * Adding a new variant without a matching case is a tsc error — see the fixture in
6
- * apps/playground/src/demo/agent-part.type-guard.ts.
6
+ * apps/playground/src/demo/agent-part.type-guard.ts. One caveat for code that folds WIRE data on
7
+ * the render path (`coalesceParts`): keep the compile-time guarantee — bind the value at `never` —
8
+ * but do NOT let the runtime throw, or one malformed chunk kills the whole transcript. See
9
+ * `mergeToolPart`'s default branch for that shape.
7
10
  *
8
11
  * Eden #231 doctrine: the server-side Elysia route MUST declare `: AsyncGenerator<AgentPart>`
9
12
  * explicitly and carry NO t.Object/t.Union response schema. Validate at yield-time. See
10
13
  * agent/rules/basalt-agent.md for the full doctrine.
11
14
  *
15
+ * Part identity: every variant carries a stable `id` (see `PartBase`). Transports are permitted to
16
+ * yield a DRAFT — `id` omitted — via `AgentPartDraft`; the consuming hooks (or `withPartIds`)
17
+ * normalize a draft into a fully-identified part before it is ever accumulated. `parseAgentPart`
18
+ * validates the FULL (identified) shape — a draft is a separate, narrower concern.
19
+ *
12
20
  * @example
13
21
  * import type { AgentPart } from 'basalt-ui/agent'
14
22
  * function describe(p: AgentPart): string {
@@ -24,6 +32,11 @@
24
32
  * }
25
33
  */
26
34
 
35
+ // ── Part identity ─────────────────────────────────────────────────────────────
36
+
37
+ /** Every AgentPart variant carries a stable id — the addressing key for `mergePart`/`coalesceParts`. */
38
+ type PartBase = { readonly id: string }
39
+
27
40
  // ── AgentPart discriminated union ────────────────────────────────────────────
28
41
 
29
42
  /**
@@ -31,53 +44,179 @@
31
44
  * resume this run after a disconnect. Not renderable content — UI code should treat it as a
32
45
  * no-op signal, not conversation content.
33
46
  */
34
- export type StartPart = {
47
+ export type StartPart = PartBase & {
35
48
  readonly type: 'start'
36
49
  readonly runId: string
37
50
  readonly resumeToken?: string
38
51
  }
39
52
 
40
- /** A streamed text fragment from the assistant. */
41
- export type TextPart = { readonly type: 'text'; readonly text: string }
53
+ /**
54
+ * A streamed text fragment from the assistant. `offset` is the character position of this delta
55
+ * inside its part (undefined = append at the tail) — the mechanism `mergePart` uses to splice a
56
+ * replayed delta in place rather than duplicating it.
57
+ */
58
+ export type TextPart = PartBase & {
59
+ readonly type: 'text'
60
+ readonly text: string
61
+ readonly offset?: number
62
+ }
63
+
64
+ /** An internal reasoning/thinking fragment (may be hidden in the UI). Same `offset` mechanism as TextPart. */
65
+ export type ReasoningPart = PartBase & {
66
+ readonly type: 'reasoning'
67
+ readonly text: string
68
+ readonly offset?: number
69
+ }
42
70
 
43
- /** An internal reasoning/thinking fragment (may be hidden in the UI). */
44
- export type ReasoningPart = { readonly type: 'reasoning'; readonly text: string }
71
+ // ── ToolCallPart — mirrors AI SDK v7's UIToolInvocation ─────────────────────
72
+ //
73
+ // Verified against installed ai@7.0.16 / 7.0.18 (byte-identical .d.ts). Seven states, no
74
+ // 'running' ('input-available' IS the running state — input complete, no output yet).
75
+ //
76
+ // The nested `approval` shape mirrors the SDK exactly (`{ id, approved?, reason?, isAutomatic?,
77
+ // signature? }`) rather than flattening to `approvalId`/`approved`/`reason` — flattening would
78
+ // silently drop `isAutomatic`/`signature`.
79
+ //
80
+ // `toolName` is NOT a field on the SDK's UIToolInvocation — for static tools it lives in the part
81
+ // discriminator `tool-${NAME}`. It IS a required field here (ours, not the SDK's) because the chip
82
+ // needs it; a transport mapping from the SDK must DERIVE it from the discriminator, not read it off
83
+ // the invocation.
84
+ //
85
+ // `toolCallId` is required on the three IN-FLIGHT states and OPTIONAL on the four SETTLED ones —
86
+ // see `Identified` / `MaybeIdentified` below. The absence is modelled in the type, never as a
87
+ // sentinel value.
88
+
89
+ /** The nested approval envelope — mirrors the SDK's shape verbatim, never flattened. */
90
+ type ToolApproval = {
91
+ readonly id: string
92
+ readonly approved?: boolean
93
+ readonly reason?: string
94
+ readonly isAutomatic?: boolean
95
+ readonly signature?: string
96
+ }
97
+
98
+ type ToolCallBase = PartBase & {
99
+ readonly type: 'tool'
100
+ readonly toolName: string
101
+ /** Wall-clock time from first sighting of this toolCallId to its terminal state. */
102
+ readonly durationMs?: number
103
+ readonly providerExecuted?: boolean
104
+ }
45
105
 
46
106
  /**
47
- * A tool invocation — input is the parameters sent, output (optional) is the result once
48
- * the tool has completed. Streams arrive with output undefined; it may be appended later.
107
+ * The three IN-FLIGHT states (`input-streaming`, `input-available`, `approval-requested`) carry an
108
+ * authoritative `toolCallId` — the merge key, and for them it is structural. An in-flight part
109
+ * exists only to be superseded by the settled part for the same call; with no merge key it can
110
+ * never be closed, so it would render as a forever-pending chip standing beside the real result.
111
+ * Nothing is lost by rejecting an unidentified one either: the `toolName` and `input` it carries
112
+ * are re-supplied on the settled part. `parseToolCallPart` therefore rejects these when the id is
113
+ * missing or empty.
114
+ */
115
+ type Identified = ToolCallBase & { readonly toolCallId: string }
116
+
117
+ /**
118
+ * The four SETTLED states (`approval-responded` plus the three terminal ones) may legitimately
119
+ * arrive without a `toolCallId`, for two different reasons.
120
+ *
121
+ * `approval-responded` is where the WIRE omits it: the SDK's `tool-approval-response` chunk
122
+ * carries none, and it is resolved by reverse lookup on `approval.id` (coalesce.ts keeps the
123
+ * approvalId -> toolCallId side index that does it).
124
+ *
125
+ * The three terminal states admit the absence for a different reason — tolerance, not wire shape.
126
+ * They are self-contained: each carries the OUTCOME of the call, so a missing id costs only the
127
+ * fold (`coalesceParts` keeps an unresolvable part as its own block and never discards it).
128
+ * Rejecting one instead would delete the only record of how the call ended — silent data loss in a
129
+ * transcript, strictly worse than an unfolded block.
49
130
  *
50
- * `toolCallId` (optional) identifies the specific invocation when a transport's wire format
51
- * carries one (e.g. AI SDK's `ToolUIPart`) — useful for consumer-side coalescing of a tool call's
52
- * input/output pair by id rather than by array position.
131
+ * The absence is modelled here rather than smuggled through an empty-string sentinel: the union
132
+ * previously declared an always-meaningful `string` that the `approval-responded` wire shape
133
+ * cannot supply, which forced a `''` sentinel into the type and an `as unknown as` cast into every
134
+ * test that built one. A `''` toolCallId is not a value any state accepts; `parseToolCallPart`
135
+ * normalizes one to absent at the wire boundary, so it never reaches the type or a consumer.
53
136
  */
54
- export type ToolCallPart = {
55
- readonly type: 'tool'
56
- readonly toolName: string
57
- readonly input: unknown
58
- readonly output?: unknown
59
- readonly toolCallId?: string
137
+ type MaybeIdentified = ToolCallBase & { readonly toolCallId?: string }
138
+
139
+ export type ToolCallPart =
140
+ | (Identified & { readonly state: 'input-streaming'; readonly input?: unknown })
141
+ | (Identified & { readonly state: 'input-available'; readonly input: unknown })
142
+ | (Identified & {
143
+ readonly state: 'approval-requested'
144
+ readonly input: unknown
145
+ readonly approval: ToolApproval & { readonly approved?: never; readonly reason?: never }
146
+ })
147
+ | (MaybeIdentified & {
148
+ readonly state: 'approval-responded'
149
+ readonly input: unknown
150
+ readonly approval: ToolApproval & { readonly approved: boolean }
151
+ })
152
+ | (MaybeIdentified & {
153
+ readonly state: 'output-available'
154
+ readonly input: unknown
155
+ readonly output: unknown
156
+ readonly preliminary?: boolean
157
+ readonly approval?: ToolApproval & { readonly approved?: true }
158
+ })
159
+ | (MaybeIdentified & {
160
+ readonly state: 'output-error'
161
+ readonly input?: unknown
162
+ /** The SDK's field name — there is no field named `error` anywhere in the union. */
163
+ readonly errorText: string
164
+ /** The only place the raw, un-parsed input survives — what lets a UI render a call whose input never validated. */
165
+ readonly rawInput?: unknown
166
+ readonly approval?: ToolApproval & { readonly approved?: true }
167
+ })
168
+ | (MaybeIdentified & {
169
+ readonly state: 'output-denied'
170
+ readonly input: unknown
171
+ readonly approval: ToolApproval & { readonly approved: false }
172
+ })
173
+
174
+ export type ToolCallState = ToolCallPart['state']
175
+
176
+ /** The three states a tool call can never leave — see `isToolCallSettled`. */
177
+ export const TERMINAL_TOOL_STATES = ['output-available', 'output-error', 'output-denied'] as const
178
+
179
+ /** True once a tool call has reached one of `TERMINAL_TOOL_STATES` — no further state transition. */
180
+ export function isToolCallSettled(part: ToolCallPart): boolean {
181
+ return (TERMINAL_TOOL_STATES as readonly ToolCallState[]).includes(part.state)
60
182
  }
61
183
 
62
184
  /** A cited source URL referenced by the assistant. */
63
- export type SourcePart = { readonly type: 'source'; readonly url: string; readonly title?: string }
185
+ export type SourcePart = PartBase & {
186
+ readonly type: 'source'
187
+ readonly url: string
188
+ readonly title?: string
189
+ }
64
190
 
65
191
  /** A fatal or recoverable error emitted in-stream by the agent. */
66
- export type ErrorPart = { readonly type: 'error'; readonly message: string }
192
+ export type ErrorPart = PartBase & { readonly type: 'error'; readonly message: string }
67
193
 
68
194
  /**
69
195
  * The complete discriminated union of all structured agent stream parts.
70
196
  *
71
197
  * @example
72
- * const part: AgentPart = { type: 'text', text: 'Hello' }
198
+ * const part: AgentPart = { id: 'p1', type: 'text', text: 'Hello' }
73
199
  */
74
200
  export type AgentPart = StartPart | TextPart | ReasoningPart | ToolCallPart | SourcePart | ErrorPart
75
201
 
202
+ // ── AgentPartDraft — what a transport is allowed to yield ────────────────────
203
+
204
+ /**
205
+ * An AgentPart with `id` optional — the shape a transport is allowed to yield. The naked type
206
+ * parameter (not `AgentPart extends unknown ? ... : never` written some other way) is deliberate:
207
+ * it makes the conditional type distribute over the AgentPart union member-by-member, so
208
+ * `Drafted<AgentPart>` is `Drafted<StartPart> | Drafted<TextPart> | ...` rather than one flattened
209
+ * (and wrong) shape.
210
+ */
211
+ export type Drafted<T> = T extends unknown ? Omit<T, 'id'> & { readonly id?: string } : never
212
+ export type AgentPartDraft = Drafted<AgentPart>
213
+
76
214
  // ── parseAgentPart ───────────────────────────────────────────────────────────
77
215
 
78
216
  /**
79
217
  * Runtime type-narrowing guard: validates an unknown value against the AgentPart discriminated
80
- * union and returns the narrowed type or null when validation fails.
218
+ * union and returns the narrowed type or null when validation fails. Validates the FULL
219
+ * (identified) shape — an `id`-less draft is a separate concern handled by `withPartIds`.
81
220
  *
82
221
  * @example
83
222
  * const raw = JSON.parse(line)
@@ -88,46 +227,198 @@ export function parseAgentPart(raw: unknown): AgentPart | null {
88
227
  if (raw === null || typeof raw !== 'object') return null
89
228
  const obj = raw as Record<string, unknown>
90
229
  if (typeof obj['type'] !== 'string') return null
230
+ if (typeof obj['id'] !== 'string') return null
231
+ const id = obj['id']
91
232
 
92
233
  switch (obj['type']) {
93
234
  case 'start':
94
235
  if (typeof obj['runId'] !== 'string') return null
95
236
  return {
237
+ id,
96
238
  type: 'start',
97
239
  runId: obj['runId'],
98
240
  ...(typeof obj['resumeToken'] === 'string' ? { resumeToken: obj['resumeToken'] } : {}),
99
241
  }
100
242
  case 'text':
101
243
  if (typeof obj['text'] !== 'string') return null
102
- return { type: 'text', text: obj['text'] }
244
+ return {
245
+ id,
246
+ type: 'text',
247
+ text: obj['text'],
248
+ ...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
249
+ }
103
250
  case 'reasoning':
104
251
  if (typeof obj['text'] !== 'string') return null
105
- return { type: 'reasoning', text: obj['text'] }
106
- case 'tool':
107
- if (typeof obj['toolName'] !== 'string') return null
108
- if (!('input' in obj)) return null
109
252
  return {
110
- type: 'tool',
111
- toolName: obj['toolName'],
112
- input: obj['input'],
113
- ...(obj['output'] !== undefined ? { output: obj['output'] } : {}),
114
- ...(typeof obj['toolCallId'] === 'string' ? { toolCallId: obj['toolCallId'] } : {}),
253
+ id,
254
+ type: 'reasoning',
255
+ text: obj['text'],
256
+ ...(typeof obj['offset'] === 'number' ? { offset: obj['offset'] } : {}),
115
257
  }
258
+ case 'tool':
259
+ return parseToolCallPart(id, obj)
116
260
  case 'source':
117
261
  if (typeof obj['url'] !== 'string') return null
118
262
  return {
263
+ id,
119
264
  type: 'source',
120
265
  url: obj['url'],
121
266
  ...(typeof obj['title'] === 'string' ? { title: obj['title'] } : {}),
122
267
  }
123
268
  case 'error':
124
269
  if (typeof obj['message'] !== 'string') return null
125
- return { type: 'error', message: obj['message'] }
270
+ return { id, type: 'error', message: obj['message'] }
126
271
  default:
127
272
  return null
128
273
  }
129
274
  }
130
275
 
276
+ /** Validates the state-discriminated `tool` part shape. Rejects the pre-1.11.0 flat shape
277
+ * (`{type:'tool', output}` with no `state`) and any unrecognized `state` value.
278
+ *
279
+ * An empty-string `toolCallId` is normalized to absent here — the one place the wire's degenerate
280
+ * shape is decoded — so `''` never reaches the type or any consumer of it. What an ABSENT id then
281
+ * means is decided per state, exactly as the union declares it (`Identified` /
282
+ * `MaybeIdentified`): fatal on the three in-flight states, which exist only to be closed by a
283
+ * later one and are re-supplied by it; survivable on the four settled states, which carry the
284
+ * outcome and are kept UNIDENTIFIED rather than dropped. Dropping a settled part would be silent
285
+ * data loss — the transcript would simply forget how the call ended. */
286
+ function parseToolCallPart(id: string, obj: Record<string, unknown>): ToolCallPart | null {
287
+ if (typeof obj['toolName'] !== 'string') return null
288
+ if (typeof obj['state'] !== 'string') return null
289
+
290
+ const rawToolCallId = obj['toolCallId']
291
+ if (rawToolCallId !== undefined && typeof rawToolCallId !== 'string') return null
292
+ const toolCallId =
293
+ typeof rawToolCallId === 'string' && rawToolCallId.length > 0 ? rawToolCallId : undefined
294
+
295
+ const toolName = obj['toolName']
296
+ const common = {
297
+ id,
298
+ type: 'tool' as const,
299
+ toolName,
300
+ ...(typeof obj['durationMs'] === 'number' ? { durationMs: obj['durationMs'] } : {}),
301
+ ...(typeof obj['providerExecuted'] === 'boolean'
302
+ ? { providerExecuted: obj['providerExecuted'] }
303
+ : {}),
304
+ }
305
+ // The three in-flight states below require an identified call; the four settled ones spread
306
+ // `identity` instead, which is simply empty when the wire supplied no usable id.
307
+ const identified = toolCallId !== undefined ? { ...common, toolCallId } : null
308
+ const identity = toolCallId !== undefined ? { toolCallId } : {}
309
+
310
+ switch (obj['state']) {
311
+ case 'input-streaming':
312
+ if (identified === null) return null
313
+ return {
314
+ ...identified,
315
+ state: 'input-streaming',
316
+ ...('input' in obj ? { input: obj['input'] } : {}),
317
+ }
318
+ case 'input-available':
319
+ if (identified === null) return null
320
+ if (!('input' in obj)) return null
321
+ return { ...identified, state: 'input-available', input: obj['input'] }
322
+ case 'approval-requested': {
323
+ if (identified === null) return null
324
+ if (!('input' in obj)) return null
325
+ const approval = parseApproval(obj['approval'])
326
+ // The type declares this state's approval as `{ approved?: never; reason?: never }` — a
327
+ // request that already carries a verdict or a reason is a contradiction, not a pending
328
+ // request, so reject it rather than silently casting past the invariant.
329
+ if (approval === null || approval.approved !== undefined || approval.reason !== undefined) {
330
+ return null
331
+ }
332
+ return {
333
+ ...identified,
334
+ state: 'approval-requested',
335
+ input: obj['input'],
336
+ approval: approval as ToolApproval & { approved?: never; reason?: never },
337
+ }
338
+ }
339
+ case 'approval-responded': {
340
+ if (!('input' in obj)) return null
341
+ const approval = parseApproval(obj['approval'])
342
+ if (approval === null || typeof approval.approved !== 'boolean') return null
343
+ // The state the WIRE ships unidentified — `approval.id` is then its only addressing key.
344
+ return {
345
+ ...common,
346
+ ...identity,
347
+ state: 'approval-responded',
348
+ input: obj['input'],
349
+ approval: approval as ToolApproval & { approved: boolean },
350
+ }
351
+ }
352
+ case 'output-denied': {
353
+ if (!('input' in obj)) return null
354
+ const approval = parseApproval(obj['approval'])
355
+ if (approval === null || approval.approved !== false) return null
356
+ return {
357
+ ...common,
358
+ ...identity,
359
+ state: 'output-denied',
360
+ input: obj['input'],
361
+ approval: approval as ToolApproval & { approved: false },
362
+ }
363
+ }
364
+ case 'output-available': {
365
+ if (!('input' in obj)) return null
366
+ if (!('output' in obj)) return null
367
+ const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
368
+ // The type declares this state's approval as `{ approved?: true }` — output present
369
+ // alongside `approved: false` is a denial masquerading as a result, not a valid "approved"
370
+ // carry-through. A present-but-malformed approval is rejected the same way (not silently
371
+ // dropped), matching output-denied/approval-responded's precedent above.
372
+ if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
373
+ return null
374
+ }
375
+ return {
376
+ ...common,
377
+ ...identity,
378
+ state: 'output-available',
379
+ input: obj['input'],
380
+ output: obj['output'],
381
+ ...(typeof obj['preliminary'] === 'boolean' ? { preliminary: obj['preliminary'] } : {}),
382
+ ...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
383
+ }
384
+ }
385
+ case 'output-error': {
386
+ if (typeof obj['errorText'] !== 'string') return null
387
+ const approval = obj['approval'] === undefined ? null : parseApproval(obj['approval'])
388
+ // Same invariant as output-available above: an error carrying `approved: false` is a
389
+ // denial, not an error whose approval "happens to carry through".
390
+ if (obj['approval'] !== undefined && (approval === null || approval.approved === false)) {
391
+ return null
392
+ }
393
+ return {
394
+ ...common,
395
+ ...identity,
396
+ state: 'output-error',
397
+ errorText: obj['errorText'],
398
+ ...('input' in obj ? { input: obj['input'] } : {}),
399
+ ...('rawInput' in obj ? { rawInput: obj['rawInput'] } : {}),
400
+ ...(approval !== null ? { approval: approval as ToolApproval & { approved?: true } } : {}),
401
+ }
402
+ }
403
+ default:
404
+ return null
405
+ }
406
+ }
407
+
408
+ /** Validates the nested `approval` envelope. Returns null when absent/malformed. */
409
+ function parseApproval(raw: unknown): ToolApproval | null {
410
+ if (raw === null || typeof raw !== 'object') return null
411
+ const obj = raw as Record<string, unknown>
412
+ if (typeof obj['id'] !== 'string') return null
413
+ return {
414
+ id: obj['id'],
415
+ ...(typeof obj['approved'] === 'boolean' ? { approved: obj['approved'] } : {}),
416
+ ...(typeof obj['reason'] === 'string' ? { reason: obj['reason'] } : {}),
417
+ ...(typeof obj['isAutomatic'] === 'boolean' ? { isAutomatic: obj['isAutomatic'] } : {}),
418
+ ...(typeof obj['signature'] === 'string' ? { signature: obj['signature'] } : {}),
419
+ }
420
+ }
421
+
131
422
  // ── isStartPart ──────────────────────────────────────────────────────────────
132
423
 
133
424
  /** Type guard: narrows an unknown value to StartPart. */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * createThreadsStore — ring-buffer caps (maxThreads/maxMessagesPerThread), newest-first ordering,
3
- * setResumeToken's delete-the-key-on-undefined semantics, and same-tick action accumulation.
3
+ * setResumeToken's delete-the-key-on-undefined semantics, same-tick action accumulation, and the
4
+ * constant hydrated/error pair the async sibling store (./adapter) actually varies.
4
5
  *
5
6
  * Uses the REAL localStorage-backed store (happy-dom provides `window.localStorage`), so every
6
7
  * test uses its own unique `key` — the same convention as
@@ -13,7 +14,12 @@ import { createThreadsStore } from './thread'
13
14
  import type { ChatMessage } from './history'
14
15
 
15
16
  function makeMessage(id: string): ChatMessage {
16
- return { id, role: 'user', parts: [{ type: 'text', text: id }], createdAt: Date.now() }
17
+ return {
18
+ id,
19
+ role: 'user',
20
+ parts: [{ id: `${id}-p0`, type: 'text', text: id }],
21
+ createdAt: Date.now(),
22
+ }
17
23
  }
18
24
 
19
25
  describe('createThreadsStore', () => {
@@ -144,4 +150,71 @@ describe('createThreadsStore', () => {
144
150
  expect(thread?.status).toBe('streaming')
145
151
  expect(thread?.resumeToken).toBe('tok-accum')
146
152
  })
153
+
154
+ test('hydrated is always true and error always undefined — localStorage resolves synchronously', () => {
155
+ const useThreads = createThreadsStore({ key: 'thread-hydrated-a', version: 1 })
156
+ const { result } = renderHook(() => useThreads())
157
+
158
+ // Pinned because these two exist for the async adapter store (./adapter); the localStorage
159
+ // store must fill them with the "nothing to wait for, nothing to report" constants on the
160
+ // very first render, before any action has run.
161
+ expect(result.current.hydrated).toBe(true)
162
+ expect(result.current.error).toBeUndefined()
163
+
164
+ act(() => {
165
+ result.current.create()
166
+ })
167
+
168
+ expect(result.current.hydrated).toBe(true)
169
+ expect(result.current.error).toBeUndefined()
170
+ })
171
+
172
+ test('create() mints distinct ids via crypto.getRandomValues when randomUUID is unavailable', () => {
173
+ const originalCrypto = globalThis.crypto
174
+ Object.defineProperty(globalThis, 'crypto', {
175
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
176
+ configurable: true,
177
+ })
178
+ try {
179
+ const useThreads = createThreadsStore({ key: 'thread-crypto-a', version: 1 })
180
+ const { result } = renderHook(() => useThreads())
181
+
182
+ let first = ''
183
+ let second = ''
184
+ act(() => {
185
+ first = result.current.create()
186
+ second = result.current.create()
187
+ })
188
+
189
+ expect(first).not.toBe('')
190
+ expect(second).not.toBe('')
191
+ expect(first).not.toBe(second)
192
+ } finally {
193
+ // Restore the real crypto so no later test in the process is affected.
194
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
195
+ }
196
+ })
197
+
198
+ test('create() still mints distinct ids with no usable crypto at all (randomUUID and getRandomValues both absent)', () => {
199
+ const originalCrypto = globalThis.crypto
200
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
201
+ try {
202
+ const useThreads = createThreadsStore({ key: 'thread-crypto-b', version: 1 })
203
+ const { result } = renderHook(() => useThreads())
204
+
205
+ let first = ''
206
+ let second = ''
207
+ act(() => {
208
+ first = result.current.create()
209
+ second = result.current.create()
210
+ })
211
+
212
+ expect(first).not.toBe('')
213
+ expect(second).not.toBe('')
214
+ expect(first).not.toBe(second)
215
+ } finally {
216
+ // Restore the real crypto so no later test in the process is affected.
217
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
218
+ }
219
+ })
147
220
  })
@@ -24,6 +24,7 @@
24
24
  import { useCallback, useRef } from 'react'
25
25
  import { createPersistedState } from '../state'
26
26
  import type { ChatMessage } from './history'
27
+ import { mintThreadId } from './id'
27
28
  import type { AgentOutcome } from './outcome'
28
29
  import type { AgentPart } from './parts'
29
30
 
@@ -138,6 +139,20 @@ export type ThreadsStore<TPart = AgentPart> = {
138
139
  readonly remove: (id: string) => void
139
140
  /** Remove all threads and clear the active selection. */
140
141
  readonly clear: () => void
142
+ /**
143
+ * Whether `threads` reflects the backing store yet. Always `true` for the localStorage store
144
+ * (createPersistedState resolves synchronously); meaningful only for an async, server-backed
145
+ * store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
146
+ * first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
147
+ * failed load, `!hydrated && error === undefined` is still loading.
148
+ */
149
+ readonly hydrated: boolean
150
+ /**
151
+ * The most recent persistence failure, or `undefined`. Always `undefined` for the localStorage
152
+ * store, which has no failure the caller can act on. An async store surfaces the rejected
153
+ * load/write here after rolling its optimistic edit back.
154
+ */
155
+ readonly error: unknown
141
156
  }
142
157
 
143
158
  // ── createThreadsStore ────────────────────────────────────────────────────────
@@ -198,7 +213,7 @@ export function createThreadsStore<TPart = AgentPart>(
198
213
 
199
214
  const create = useCallback(
200
215
  (createOpts?: { readonly meta?: Record<string, unknown> }): string => {
201
- const id = crypto.randomUUID()
216
+ const id = mintThreadId()
202
217
  const now = Date.now()
203
218
  const thread: AgentThread<TPart> = {
204
219
  id,
@@ -307,6 +322,11 @@ export function createThreadsStore<TPart = AgentPart>(
307
322
  markRead,
308
323
  remove,
309
324
  clear,
325
+ // localStorage resolves synchronously and swallows its own quota errors, so there is never
326
+ // a pending load to await nor a failure a caller could act on. See ./adapter for the async
327
+ // store where these two carry real information.
328
+ hydrated: true,
329
+ error: undefined,
310
330
  }
311
331
  }
312
332
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * transport — `isResumable` and `edenTransport`'s opt-in `idempotentReplay` flag.
3
+ *
4
+ * `resume` alone is NOT enough for a transport to be treated as resumable: `isResumable` also
5
+ * requires the literal `idempotentReplay: true` assertion, and `edenTransport` only sets it when
6
+ * the caller opts in explicitly via a third argument — never inferred from `resumeCall` alone.
7
+ */
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { edenTransport, isResumable } from './transport'
10
+ import type { AgentTransport } from './transport'
11
+ import type { AgentPart } from './parts'
12
+
13
+ describe('isResumable', () => {
14
+ test('false for a transport with no resume() at all', () => {
15
+ const transport: AgentTransport<AgentPart, string> = {
16
+ async *stream() {},
17
+ }
18
+ expect(isResumable(transport)).toBe(false)
19
+ })
20
+
21
+ test('false for a transport with resume() but WITHOUT idempotentReplay — resume alone is not enough', () => {
22
+ const transport: AgentTransport<AgentPart, string> = {
23
+ async *stream() {},
24
+ async *resume() {
25
+ yield { id: 'p1', type: 'text', text: 'resumed' }
26
+ },
27
+ }
28
+ expect(isResumable(transport)).toBe(false)
29
+ })
30
+
31
+ test('true only once BOTH resume() and idempotentReplay: true are present', () => {
32
+ const transport = {
33
+ async *stream() {},
34
+ async *resume() {
35
+ yield { id: 'p1', type: 'text', text: 'resumed' }
36
+ },
37
+ idempotentReplay: true as const,
38
+ }
39
+ expect(isResumable(transport)).toBe(true)
40
+ })
41
+ })
42
+
43
+ describe('edenTransport — idempotentReplay is opt-in, never inferred', () => {
44
+ test('no resumeCall at all → the returned object has NO resume key', () => {
45
+ const transport = edenTransport<AgentPart>(async () => ({
46
+ data: (async function* () {})(),
47
+ error: null,
48
+ }))
49
+ expect('resume' in transport).toBe(false)
50
+ expect(isResumable(transport)).toBe(false)
51
+ })
52
+
53
+ test('resumeCall supplied but NO options → resume() exists but isResumable is still false', () => {
54
+ const transport = edenTransport<AgentPart>(
55
+ async () => ({ data: (async function* () {})(), error: null }),
56
+ async () => ({ data: (async function* () {})(), error: null }),
57
+ )
58
+ expect(typeof transport.resume).toBe('function')
59
+ expect(isResumable(transport)).toBe(false)
60
+ })
61
+
62
+ test('resumeCall + { idempotentReplay: true } → isResumable is true', () => {
63
+ const transport = edenTransport<AgentPart>(
64
+ async () => ({ data: (async function* () {})(), error: null }),
65
+ async () => ({ data: (async function* () {})(), error: null }),
66
+ { idempotentReplay: true },
67
+ )
68
+ expect(isResumable(transport)).toBe(true)
69
+ })
70
+ })