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
@@ -0,0 +1,1403 @@
1
+ /**
2
+ * ThreadsStoreAdapter — the shipped conformance suite run against a reference in-memory adapter,
3
+ * plus the wrapping store's optimistic/rollback/hydration behaviour.
4
+ *
5
+ * Two distinct things are under test here and they are worth keeping apart:
6
+ * 1. `threadsStoreAdapterContract` itself. Running it against a KNOWN-GOOD adapter proves the
7
+ * suite passes on a correct implementation; the deliberately-broken adapters below prove it
8
+ * actually FAILS on a wrong one (a green-on-everything contract is worse than none).
9
+ * 2. `createAdapterThreadsStore`, which is where the async→sync bridge lives.
10
+ *
11
+ * Timing is driven by explicit deferreds rather than by racing the microtask queue: a gated
12
+ * adapter call is guaranteed still in flight when the optimistic assertion runs, so "optimistic"
13
+ * and "settled" are never the same instant by accident.
14
+ */
15
+ import { describe, expect, test } from 'bun:test'
16
+ import { act, renderHook, waitFor } from '@testing-library/react'
17
+ import { createAdapterThreadsStore, threadsStoreAdapterContract } from './adapter'
18
+ import type { ThreadsStoreAdapter } from './adapter'
19
+ import type { ChatMessage } from './history'
20
+ import type { AgentPart } from './parts'
21
+ import type { AgentThread } from './thread'
22
+
23
+ // ── reference in-memory adapter ───────────────────────────────────────────────
24
+
25
+ function createMemoryAdapter(): ThreadsStoreAdapter<AgentPart> {
26
+ // Map insertion order is the creation order, so reversing it is a deterministic newest-first
27
+ // — no reliance on Date.now() resolution, which collides for same-tick creates.
28
+ const threads = new Map<string, AgentThread<AgentPart>>()
29
+
30
+ function update(id: string, next: (thread: AgentThread<AgentPart>) => AgentThread<AgentPart>) {
31
+ const thread = threads.get(id)
32
+ if (thread === undefined) return
33
+ threads.set(id, next(thread))
34
+ }
35
+
36
+ return {
37
+ listThreads: async () => [...threads.values()].toReversed(),
38
+ loadThread: async (id) => threads.get(id) ?? null,
39
+ createThread: async ({ id, meta }) => {
40
+ const now = Date.now()
41
+ threads.set(id, {
42
+ id,
43
+ messages: [],
44
+ outcome: null,
45
+ status: 'pending',
46
+ read: false,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ ...(meta !== undefined ? { meta } : {}),
50
+ })
51
+ },
52
+ appendMessage: async ({ threadId, message }) => {
53
+ update(threadId, (thread) =>
54
+ // The contract's central clause: message.id is the only idempotency key.
55
+ thread.messages.some((m) => m.id === message.id)
56
+ ? thread
57
+ : { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() },
58
+ )
59
+ },
60
+ setStatus: async ({ threadId, status }) => {
61
+ update(threadId, (thread) => ({ ...thread, status, updatedAt: Date.now() }))
62
+ },
63
+ setOutcome: async ({ threadId, outcome }) => {
64
+ update(threadId, (thread) => ({ ...thread, outcome, updatedAt: Date.now() }))
65
+ },
66
+ setResumeToken: async ({ threadId, token }) => {
67
+ update(threadId, (thread) => {
68
+ const { resumeToken: _resumeToken, ...rest } = thread
69
+ return {
70
+ ...rest,
71
+ ...(token !== undefined ? { resumeToken: token } : {}),
72
+ updatedAt: Date.now(),
73
+ }
74
+ })
75
+ },
76
+ markRead: async (threadId) => {
77
+ update(threadId, (thread) => ({ ...thread, read: true }))
78
+ },
79
+ removeThread: async (threadId) => {
80
+ threads.delete(threadId)
81
+ },
82
+ }
83
+ }
84
+
85
+ function makeMessage(id: string): ChatMessage<AgentPart> {
86
+ return {
87
+ id,
88
+ role: 'user',
89
+ parts: [{ id: `${id}-p0`, type: 'text', text: id }],
90
+ createdAt: Date.now(),
91
+ }
92
+ }
93
+
94
+ type Deferred = {
95
+ readonly promise: Promise<void>
96
+ readonly resolve: () => void
97
+ readonly reject: (cause: unknown) => void
98
+ }
99
+
100
+ /**
101
+ * Drains the mutation → revalidate → emit chain inside act(). Needed wherever a write is NOT
102
+ * gated by a deferred: its follow-up revalidate resolves a few microtasks later and would emit a
103
+ * React update outside act(), which is a warning, not a failure — but a warning that would then
104
+ * mask a real one.
105
+ */
106
+ async function settle(): Promise<void> {
107
+ await act(async () => {
108
+ await new Promise<void>((resolve) => setTimeout(resolve, 0))
109
+ })
110
+ }
111
+
112
+ function deferred(): Deferred {
113
+ let resolve!: () => void
114
+ let reject!: (cause: unknown) => void
115
+ const promise = new Promise<void>((res, rej) => {
116
+ resolve = () => res()
117
+ reject = rej
118
+ })
119
+ return { promise, resolve, reject }
120
+ }
121
+
122
+ // ── 1. the shipped contract, against the reference adapter ────────────────────
123
+
124
+ describe('threadsStoreAdapterContract', () => {
125
+ for (const conformanceCase of threadsStoreAdapterContract(() => createMemoryAdapter())) {
126
+ test(conformanceCase.name, async () => {
127
+ await conformanceCase.run()
128
+ })
129
+ }
130
+
131
+ test('exposes a stable, non-empty list of named cases', () => {
132
+ const cases = threadsStoreAdapterContract(() => createMemoryAdapter())
133
+ expect(cases.length).toBeGreaterThan(8)
134
+ expect(new Set(cases.map((c) => c.name)).size).toBe(cases.length)
135
+ })
136
+
137
+ test('FAILS an adapter whose appendMessage is not idempotent on message.id', async () => {
138
+ const cases = threadsStoreAdapterContract(() => {
139
+ const memory = createMemoryAdapter()
140
+ const seen = new Map<string, ChatMessage<AgentPart>[]>()
141
+ return {
142
+ ...memory,
143
+ // The classic wrong implementation: an unconditional insert.
144
+ appendMessage: async ({ threadId, message }) => {
145
+ const list = seen.get(threadId) ?? []
146
+ list.push(message)
147
+ seen.set(threadId, list)
148
+ await memory.appendMessage({ threadId, message: { ...message, id: crypto.randomUUID() } })
149
+ },
150
+ }
151
+ })
152
+ const idempotency = cases.find((c) => c.name.includes('idempotent'))
153
+ expect(idempotency).toBeDefined()
154
+ await expect(idempotency?.run()).rejects.toThrow(/idempotency key/)
155
+ })
156
+
157
+ test('FAILS an adapter whose listThreads serves a stale (pre-write) list', async () => {
158
+ const cases = threadsStoreAdapterContract(() => {
159
+ const memory = createMemoryAdapter()
160
+ // A read replica one write behind — the failure mode the store's patch lifetime depends on
161
+ // NOT happening, and one that only listThreads (never loadThread) exposes.
162
+ let previous: readonly AgentThread<AgentPart>[] = []
163
+ return {
164
+ ...memory,
165
+ listThreads: async (signal) => {
166
+ const current = await memory.listThreads(signal)
167
+ const stale = previous
168
+ previous = current
169
+ return stale
170
+ },
171
+ }
172
+ })
173
+ const readAfterWrite = cases.find((c) => c.name.includes('already resolved'))
174
+ expect(readAfterWrite).toBeDefined()
175
+ await expect(readAfterWrite?.run()).rejects.toThrow(/listThreads/)
176
+ })
177
+
178
+ test('FAILS an adapter whose loadThread throws instead of returning null', async () => {
179
+ const cases = threadsStoreAdapterContract(() => ({
180
+ ...createMemoryAdapter(),
181
+ loadThread: async (id: string) => {
182
+ throw new Error(`no such thread: ${id}`)
183
+ },
184
+ }))
185
+ const missing = cases.find(
186
+ (c) => c.name.includes('unknown id') && c.name.includes('loadThread'),
187
+ )
188
+ expect(missing).toBeDefined()
189
+ await expect(missing?.run()).rejects.toThrow(/no such thread/)
190
+ })
191
+ })
192
+
193
+ // ── 2. createAdapterThreadsStore ──────────────────────────────────────────────
194
+
195
+ describe('createAdapterThreadsStore', () => {
196
+ test('hydrated goes false -> true once the first listThreads succeeds', async () => {
197
+ const gate = deferred()
198
+ const memory = createMemoryAdapter()
199
+ const useThreads = createAdapterThreadsStore<AgentPart>({
200
+ ...memory,
201
+ listThreads: async (signal) => {
202
+ await gate.promise
203
+ return memory.listThreads(signal)
204
+ },
205
+ })
206
+
207
+ const { result } = renderHook(() => useThreads())
208
+
209
+ // The load is still gated, so this is the pre-hydration state, not a race.
210
+ expect(result.current.hydrated).toBe(false)
211
+ expect(result.current.error).toBeUndefined()
212
+ expect(result.current.threads).toEqual([])
213
+
214
+ await act(async () => {
215
+ gate.resolve()
216
+ await gate.promise
217
+ })
218
+
219
+ await waitFor(() => {
220
+ expect(result.current.hydrated).toBe(true)
221
+ })
222
+ expect(result.current.error).toBeUndefined()
223
+ })
224
+
225
+ test('a failed initial load surfaces on error and leaves hydrated false', async () => {
226
+ const useThreads = createAdapterThreadsStore<AgentPart>({
227
+ ...createMemoryAdapter(),
228
+ listThreads: async () => {
229
+ throw new Error('list-boom')
230
+ },
231
+ })
232
+
233
+ const { result } = renderHook(() => useThreads())
234
+
235
+ await waitFor(() => {
236
+ expect(result.current.error).toBeInstanceOf(Error)
237
+ })
238
+ // The pair is the signal: !hydrated && error means "load failed", not "still loading".
239
+ expect(result.current.hydrated).toBe(false)
240
+ expect((result.current.error as Error).message).toBe('list-boom')
241
+ })
242
+
243
+ test('create() returns an id synchronously and the thread is visible before the write settles', async () => {
244
+ const gate = deferred()
245
+ const memory = createMemoryAdapter()
246
+ const useThreads = createAdapterThreadsStore<AgentPart>({
247
+ ...memory,
248
+ createThread: async (i) => {
249
+ await gate.promise
250
+ await memory.createThread(i)
251
+ },
252
+ })
253
+
254
+ const { result } = renderHook(() => useThreads())
255
+ await waitFor(() => {
256
+ expect(result.current.hydrated).toBe(true)
257
+ })
258
+
259
+ let id = ''
260
+ act(() => {
261
+ id = result.current.create({ meta: { source: 'test' } })
262
+ })
263
+
264
+ expect(id).not.toBe('')
265
+ // createThread has not resolved yet — this row exists only as an optimistic patch.
266
+ expect(result.current.threads).toHaveLength(1)
267
+ expect(result.current.threads[0]?.id).toBe(id)
268
+ expect(result.current.threads[0]?.status).toBe('pending')
269
+ expect(result.current.threads[0]?.meta?.['source']).toBe('test')
270
+ expect(await memory.loadThread(id)).toBeNull()
271
+
272
+ await act(async () => {
273
+ gate.resolve()
274
+ await gate.promise
275
+ })
276
+
277
+ await waitFor(async () => {
278
+ expect(await memory.loadThread(id)).not.toBeNull()
279
+ })
280
+ // Still exactly one row after the patch was dropped in favour of the revalidated list —
281
+ // this is what the patches' idempotency buys.
282
+ await waitFor(() => {
283
+ expect(result.current.threads).toHaveLength(1)
284
+ })
285
+ expect(result.current.error).toBeUndefined()
286
+ })
287
+
288
+ test('appendMessage is optimistic and survives a successful revalidate', async () => {
289
+ const gate = deferred()
290
+ const memory = createMemoryAdapter()
291
+ const useThreads = createAdapterThreadsStore<AgentPart>({
292
+ ...memory,
293
+ appendMessage: async (i) => {
294
+ await gate.promise
295
+ await memory.appendMessage(i)
296
+ },
297
+ })
298
+
299
+ const { result } = renderHook(() => useThreads())
300
+ await waitFor(() => {
301
+ expect(result.current.hydrated).toBe(true)
302
+ })
303
+
304
+ let id = ''
305
+ act(() => {
306
+ id = result.current.create()
307
+ })
308
+ await waitFor(async () => {
309
+ expect(await memory.loadThread(id)).not.toBeNull()
310
+ })
311
+
312
+ act(() => {
313
+ result.current.appendMessage(id, makeMessage('m-optimistic'))
314
+ })
315
+
316
+ // Visible immediately, while the adapter write is still gated.
317
+ expect(result.current.threads[0]?.messages.map((m) => m.id)).toEqual(['m-optimistic'])
318
+
319
+ await act(async () => {
320
+ gate.resolve()
321
+ await gate.promise
322
+ })
323
+
324
+ await waitFor(async () => {
325
+ expect((await memory.loadThread(id))?.messages).toHaveLength(1)
326
+ })
327
+ // No duplicate frame: the patch stays applied across the revalidate, then is dropped.
328
+ await waitFor(() => {
329
+ expect(result.current.threads[0]?.messages.map((m) => m.id)).toEqual(['m-optimistic'])
330
+ })
331
+ expect(result.current.error).toBeUndefined()
332
+ })
333
+
334
+ test('a rejected appendMessage rolls the optimistic message back and records the error', async () => {
335
+ const gate = deferred()
336
+ const memory = createMemoryAdapter()
337
+ const useThreads = createAdapterThreadsStore<AgentPart>({
338
+ ...memory,
339
+ appendMessage: async () => {
340
+ await gate.promise
341
+ },
342
+ })
343
+
344
+ const { result } = renderHook(() => useThreads())
345
+ await waitFor(() => {
346
+ expect(result.current.hydrated).toBe(true)
347
+ })
348
+
349
+ let id = ''
350
+ act(() => {
351
+ id = result.current.create()
352
+ })
353
+ await waitFor(async () => {
354
+ expect(await memory.loadThread(id)).not.toBeNull()
355
+ })
356
+
357
+ act(() => {
358
+ result.current.appendMessage(id, makeMessage('m-doomed'))
359
+ })
360
+ expect(result.current.threads[0]?.messages).toHaveLength(1)
361
+
362
+ await act(async () => {
363
+ gate.reject(new Error('append-boom'))
364
+ await gate.promise.catch(() => {})
365
+ })
366
+
367
+ await waitFor(() => {
368
+ expect(result.current.error).toBeInstanceOf(Error)
369
+ })
370
+ expect((result.current.error as Error).message).toBe('append-boom')
371
+ // Rolled back: the optimistic patch is gone and the backend never saw the row.
372
+ expect(result.current.threads[0]?.messages).toHaveLength(0)
373
+ expect((await memory.loadThread(id))?.messages).toHaveLength(0)
374
+ })
375
+
376
+ test('a rejected write rolls back only its own patch, not a concurrent one', async () => {
377
+ const failing = deferred()
378
+ const memory = createMemoryAdapter()
379
+ const useThreads = createAdapterThreadsStore<AgentPart>({
380
+ ...memory,
381
+ setStatus: async (i) => {
382
+ await failing.promise
383
+ await memory.setStatus(i)
384
+ },
385
+ })
386
+
387
+ const { result } = renderHook(() => useThreads())
388
+ await waitFor(() => {
389
+ expect(result.current.hydrated).toBe(true)
390
+ })
391
+
392
+ let id = ''
393
+ act(() => {
394
+ id = result.current.create()
395
+ })
396
+ await waitFor(async () => {
397
+ expect(await memory.loadThread(id)).not.toBeNull()
398
+ })
399
+
400
+ act(() => {
401
+ result.current.setStatus(id, 'streaming') // gated, will reject
402
+ result.current.markRead(id) // ungated, will succeed
403
+ })
404
+ expect(result.current.threads[0]?.status).toBe('streaming')
405
+ expect(result.current.threads[0]?.read).toBe(true)
406
+
407
+ await act(async () => {
408
+ failing.reject(new Error('status-boom'))
409
+ await failing.promise.catch(() => {})
410
+ })
411
+
412
+ await waitFor(() => {
413
+ expect(result.current.error).toBeInstanceOf(Error)
414
+ })
415
+ // Per-patch rollback: the status reverts, the read flag beside it does not.
416
+ expect(result.current.threads[0]?.status).toBe('pending')
417
+ expect(result.current.threads[0]?.read).toBe(true)
418
+ })
419
+
420
+ test('setResumeToken(undefined) clears the key through the adapter', async () => {
421
+ const memory = createMemoryAdapter()
422
+ const useThreads = createAdapterThreadsStore<AgentPart>(memory)
423
+
424
+ const { result } = renderHook(() => useThreads())
425
+ await waitFor(() => {
426
+ expect(result.current.hydrated).toBe(true)
427
+ })
428
+
429
+ let id = ''
430
+ act(() => {
431
+ id = result.current.create()
432
+ })
433
+ await waitFor(async () => {
434
+ expect(await memory.loadThread(id)).not.toBeNull()
435
+ })
436
+
437
+ act(() => {
438
+ result.current.setResumeToken(id, 'tok-1')
439
+ })
440
+ await waitFor(() => {
441
+ expect(result.current.threads[0]?.resumeToken).toBe('tok-1')
442
+ })
443
+
444
+ act(() => {
445
+ result.current.setResumeToken(id, undefined)
446
+ })
447
+ await waitFor(() => {
448
+ expect(result.current.threads[0]?.resumeToken).toBeUndefined()
449
+ })
450
+ expect('resumeToken' in ((await memory.loadThread(id)) ?? {})).toBe(false)
451
+ })
452
+
453
+ test('remove clears activeId and deletes through the adapter; clear empties everything', async () => {
454
+ const memory = createMemoryAdapter()
455
+ const useThreads = createAdapterThreadsStore<AgentPart>(memory)
456
+
457
+ const { result } = renderHook(() => useThreads())
458
+ await waitFor(() => {
459
+ expect(result.current.hydrated).toBe(true)
460
+ })
461
+
462
+ let first = ''
463
+ let second = ''
464
+ act(() => {
465
+ first = result.current.create()
466
+ second = result.current.create()
467
+ result.current.select(first)
468
+ })
469
+ expect(result.current.activeId).toBe(first)
470
+ await settle()
471
+
472
+ act(() => {
473
+ result.current.remove(first)
474
+ })
475
+ expect(result.current.activeId).toBeNull()
476
+ await waitFor(async () => {
477
+ expect(await memory.loadThread(first)).toBeNull()
478
+ })
479
+ expect(result.current.threads.map((t) => t.id)).toEqual([second])
480
+ // Still null once the delete is CONFIRMED and its patch retires — the cleared selection is
481
+ // committed with the patch, not merely simulated by it.
482
+ await settle()
483
+ expect(result.current.activeId).toBeNull()
484
+
485
+ act(() => {
486
+ result.current.clear()
487
+ })
488
+ expect(result.current.threads).toEqual([])
489
+ await waitFor(async () => {
490
+ expect(await memory.listThreads()).toHaveLength(0)
491
+ })
492
+ })
493
+
494
+ test('two hook instances share one registry', async () => {
495
+ const useThreads = createAdapterThreadsStore<AgentPart>(createMemoryAdapter())
496
+
497
+ const a = renderHook(() => useThreads())
498
+ const b = renderHook(() => useThreads())
499
+ await waitFor(() => {
500
+ expect(a.result.current.hydrated).toBe(true)
501
+ })
502
+
503
+ let id = ''
504
+ act(() => {
505
+ id = a.result.current.create()
506
+ })
507
+
508
+ expect(b.result.current.threads.map((t) => t.id)).toEqual([id])
509
+ await settle()
510
+ expect(b.result.current.threads.map((t) => t.id)).toEqual([id])
511
+ })
512
+
513
+ test('unmounting aborts the in-flight load — the abort is not reported as an error', async () => {
514
+ const gate = deferred()
515
+ let observedSignal: AbortSignal | undefined
516
+ let listCalls = 0
517
+ const memory = createMemoryAdapter()
518
+ const useThreads = createAdapterThreadsStore<AgentPart>({
519
+ ...memory,
520
+ listThreads: async (signal) => {
521
+ listCalls += 1
522
+ if (listCalls > 1) {
523
+ // The probe mount below must not settle a load of its own: a second result would
524
+ // overwrite whatever the aborted first load left behind and hide the very thing this
525
+ // test is looking at.
526
+ return new Promise<never>(() => {})
527
+ }
528
+ observedSignal = signal
529
+ await gate.promise
530
+ signal?.throwIfAborted()
531
+ return memory.listThreads(signal)
532
+ },
533
+ })
534
+
535
+ const first = renderHook(() => useThreads())
536
+ expect(first.result.current.hydrated).toBe(false)
537
+
538
+ first.unmount()
539
+ expect(observedSignal?.aborted).toBe(true)
540
+
541
+ await act(async () => {
542
+ gate.resolve()
543
+ await gate.promise
544
+ })
545
+
546
+ // `first.result` is FROZEN after unmount (the subscription is gone), so asserting on it proves
547
+ // nothing. Mount a fresh consumer instead: its first render reads the store's live snapshot,
548
+ // which is where an abort mistakenly recorded as a failure would show up.
549
+ const probe = renderHook(() => useThreads())
550
+ // The abort rejection is swallowed: a cancelled load is not a store failure.
551
+ expect(probe.result.current.error).toBeUndefined()
552
+ expect(probe.result.current.hydrated).toBe(false)
553
+ probe.unmount()
554
+ })
555
+
556
+ // ── write ordering ──────────────────────────────────────────────────────────
557
+
558
+ test('writes for one thread reach the adapter in issue order, behind createThread', async () => {
559
+ const created = deferred()
560
+ const calls: string[] = []
561
+ const memory = createMemoryAdapter()
562
+ const useThreads = createAdapterThreadsStore<AgentPart>({
563
+ ...memory,
564
+ createThread: async (i) => {
565
+ calls.push('createThread')
566
+ await created.promise
567
+ await memory.createThread(i)
568
+ },
569
+ markRead: async (id) => {
570
+ calls.push('markRead')
571
+ await memory.markRead(id)
572
+ },
573
+ appendMessage: async (i) => {
574
+ calls.push('appendMessage')
575
+ await memory.appendMessage(i)
576
+ },
577
+ setStatus: async (i) => {
578
+ calls.push('setStatus')
579
+ await memory.setStatus(i)
580
+ },
581
+ })
582
+
583
+ const { result } = renderHook(() => useThreads())
584
+ await waitFor(() => {
585
+ expect(result.current.hydrated).toBe(true)
586
+ })
587
+
588
+ // Exactly what a "new thread + first message" does: one synchronous block issuing a create
589
+ // plus three writes that depend on the row existing.
590
+ let id = ''
591
+ act(() => {
592
+ id = result.current.create()
593
+ result.current.select(id)
594
+ result.current.markRead(id)
595
+ result.current.appendMessage(id, makeMessage('m-first'))
596
+ result.current.setStatus(id, 'streaming')
597
+ })
598
+
599
+ // createThread is still gated, so nothing that depends on the row may have been issued yet.
600
+ await settle()
601
+ expect(calls).toEqual(['createThread'])
602
+
603
+ await act(async () => {
604
+ created.resolve()
605
+ await created.promise
606
+ })
607
+ await settle()
608
+
609
+ expect(calls).toEqual(['createThread', 'markRead', 'appendMessage', 'setStatus'])
610
+ const stored = await memory.loadThread(id)
611
+ expect(stored?.read).toBe(true)
612
+ expect(stored?.status).toBe('streaming')
613
+ expect(stored?.messages.map((m) => m.id)).toEqual(['m-first'])
614
+ expect(result.current.error).toBeUndefined()
615
+ })
616
+
617
+ test('writes for DIFFERENT threads are not serialized behind each other', async () => {
618
+ const blocked = deferred()
619
+ const seen: string[] = []
620
+ let blockedId = ''
621
+ const memory = createMemoryAdapter()
622
+ const useThreads = createAdapterThreadsStore<AgentPart>({
623
+ ...memory,
624
+ setStatus: async (i) => {
625
+ seen.push(i.threadId)
626
+ if (i.threadId === blockedId) await blocked.promise
627
+ await memory.setStatus(i)
628
+ },
629
+ })
630
+
631
+ const { result } = renderHook(() => useThreads())
632
+ await waitFor(() => {
633
+ expect(result.current.hydrated).toBe(true)
634
+ })
635
+
636
+ let a = ''
637
+ let b = ''
638
+ act(() => {
639
+ a = result.current.create()
640
+ b = result.current.create()
641
+ })
642
+ await settle()
643
+ blockedId = a
644
+
645
+ act(() => {
646
+ result.current.setStatus(a, 'streaming') // blocked
647
+ result.current.setStatus(b, 'streaming') // must not queue behind a
648
+ })
649
+ await settle()
650
+
651
+ expect(seen).toEqual([a, b])
652
+ expect((await memory.loadThread(b))?.status).toBe('streaming')
653
+ expect((await memory.loadThread(a))?.status).toBe('pending')
654
+
655
+ await act(async () => {
656
+ blocked.resolve()
657
+ await blocked.promise
658
+ })
659
+ await settle()
660
+ expect((await memory.loadThread(a))?.status).toBe('streaming')
661
+ })
662
+
663
+ // ── patch lifetime ──────────────────────────────────────────────────────────
664
+
665
+ test('a write that lands mid-load coalesces onto ONE follow-up and is not retired by that load', async () => {
666
+ // Was: "a confirmed write stays visible when its revalidate is superseded by a later write".
667
+ // Renamed and re-mechanized for the fix — revalidate() no longer aborts-and-restarts on every
668
+ // write (that is the bug this run fixes: it starved every load under sustained writes), it
669
+ // coalesces. The invariant under test is the same one the old name pinned (a write that lands
670
+ // while a load is in flight must not be retired by THAT load — only by the one proven to have
671
+ // started after it), just exercised through the new mechanism: one shared follow-up instead of
672
+ // a second abort-and-restart.
673
+ const memory = createMemoryAdapter()
674
+ const gates: Deferred[] = []
675
+ let gating = false
676
+ const useThreads = createAdapterThreadsStore<AgentPart>({
677
+ ...memory,
678
+ listThreads: async (signal) => {
679
+ if (gating) {
680
+ const gate = deferred()
681
+ gates.push(gate)
682
+ await gate.promise
683
+ }
684
+ return memory.listThreads(signal)
685
+ },
686
+ })
687
+
688
+ const { result } = renderHook(() => useThreads())
689
+ await waitFor(() => {
690
+ expect(result.current.hydrated).toBe(true)
691
+ })
692
+
693
+ let a = ''
694
+ let b = ''
695
+ act(() => {
696
+ a = result.current.create()
697
+ b = result.current.create()
698
+ })
699
+ await settle()
700
+
701
+ const read = (): boolean | undefined => result.current.threads.find((t) => t.id === a)?.read
702
+ const status = (): string | undefined => result.current.threads.find((t) => t.id === b)?.status
703
+ expect(read()).toBe(false)
704
+
705
+ gating = true
706
+ act(() => {
707
+ result.current.markRead(a)
708
+ })
709
+ await settle()
710
+ expect(gates).toHaveLength(1) // markRead's write started the only in-flight load
711
+
712
+ act(() => {
713
+ result.current.setStatus(b, 'streaming')
714
+ })
715
+ await settle()
716
+ // The SECOND write does NOT start a second round trip — it coalesces onto a follow-up that
717
+ // will run once the first load lands. This is the request-amplification fix: aborting and
718
+ // restarting here (the old behaviour) is exactly what starves every load under sustained writes.
719
+ expect(gates).toHaveLength(1)
720
+ expect(read()).toBe(true)
721
+ expect(status()).toBe('streaming')
722
+
723
+ // The first load lands. It only proves markRead(a) — setStatus(b) arrived after it started, so
724
+ // per the adapter's read-after-write clause this load is not evidence for it. Both values still
725
+ // render correctly (the still-pending patch renders identically to a confirmed one), but the
726
+ // coalesced follow-up must now start on its own — that is the proof b was not retired here.
727
+ await act(async () => {
728
+ gates[0]?.resolve()
729
+ await gates[0]?.promise
730
+ })
731
+ await settle()
732
+ expect(gates).toHaveLength(2) // the coalesced follow-up, started automatically on landing
733
+ expect(read()).toBe(true)
734
+ expect(status()).toBe('streaming')
735
+
736
+ // The follow-up lands and actually proves setStatus(b); both effects are now confirmed and no
737
+ // third round trip is issued.
738
+ await act(async () => {
739
+ gates[1]?.resolve()
740
+ await gates[1]?.promise
741
+ })
742
+ await settle()
743
+ expect(gates).toHaveLength(2)
744
+ expect(read()).toBe(true)
745
+ expect(status()).toBe('streaming')
746
+ expect(result.current.error).toBeUndefined()
747
+ })
748
+
749
+ test('a genuinely superseded (forced) revalidate is discarded wholesale, not merely left unconfirmed', async () => {
750
+ // Abort is still correct for revalidateOnFocus — this pins that a load force-superseded by it,
751
+ // should it resolve anyway (a real fetch whose response arrives after cancellation), must have
752
+ // ZERO effect on `base`, not just "not retire a patch". Proven by mutating the backend OUT OF
753
+ // BAND between the two landings: only a load that reads AFTER that mutation can see it.
754
+ const memory = createMemoryAdapter()
755
+ const gates: Deferred[] = []
756
+ let gating = false
757
+ const useThreads = createAdapterThreadsStore<AgentPart>(
758
+ {
759
+ ...memory,
760
+ listThreads: async (signal) => {
761
+ if (gating) {
762
+ const gate = deferred()
763
+ gates.push(gate)
764
+ await gate.promise
765
+ }
766
+ return memory.listThreads(signal)
767
+ },
768
+ },
769
+ { revalidateOnFocus: true },
770
+ )
771
+
772
+ const { result, unmount } = renderHook(() => useThreads())
773
+ await waitFor(() => {
774
+ expect(result.current.hydrated).toBe(true)
775
+ })
776
+
777
+ let a = ''
778
+ act(() => {
779
+ a = result.current.create()
780
+ })
781
+ await settle()
782
+ const status = (): string | undefined => result.current.threads.find((t) => t.id === a)?.status
783
+
784
+ gating = true
785
+ act(() => {
786
+ result.current.markRead(a)
787
+ })
788
+ await settle()
789
+ expect(gates).toHaveLength(1) // load #1, gated, in flight
790
+
791
+ // A refocus genuinely supersedes: aborts load #1, starts a fresh load #2.
792
+ act(() => {
793
+ window.dispatchEvent(new Event('focus'))
794
+ })
795
+ await settle()
796
+ expect(gates).toHaveLength(2)
797
+
798
+ await act(async () => {
799
+ gates[0]?.resolve()
800
+ await gates[0]?.promise
801
+ })
802
+ await settle()
803
+ // Load #1 resolved despite being superseded — the memory adapter here does not honor the
804
+ // abort signal, matching a real fetch whose response arrives late. Its landing must be
805
+ // discarded wholesale: mutate the backend now, out of band, bypassing the optimistic layer.
806
+ await memory.setStatus({ threadId: a, status: 'error' })
807
+ // If load #1's landing had touched `base` at all, this would already be irrelevant either way
808
+ // (status is untouched by markRead's patch) — the real assertion is that it stays 'pending'
809
+ // (the value at creation), proving base was never refreshed by the discarded load.
810
+ expect(status()).toBe('pending')
811
+
812
+ // The surviving load #2 lands and reads the CURRENT backend — which now includes the
813
+ // out-of-band write — proving `base` reflects load #2, not the discarded load #1.
814
+ await act(async () => {
815
+ gates[1]?.resolve()
816
+ await gates[1]?.promise
817
+ })
818
+ await settle()
819
+ expect(status()).toBe('error')
820
+ expect(result.current.threads.find((t) => t.id === a)?.read).toBe(true)
821
+ expect(result.current.error).toBeUndefined()
822
+ // revalidateOnFocus:true attaches a 'focus' listener to the shared window — unmount so it does
823
+ // not stay live for every test that follows (several later tests dispatch 'focus').
824
+ unmount()
825
+ })
826
+
827
+ // ── regression: the request-amplification / never-settles bug ───────────────
828
+
829
+ test('a write storm against a slow listThreads completes both loads and retires every patch', async () => {
830
+ // Regression for the finding: sustained writes each aborted-and-restarted revalidate(), so
831
+ // under a slow listThreads no load ever survived long enough to land — base never refreshed
832
+ // and pending patches never retired. Probe A: listThreads at 120ms, writes at 5ms, one
833
+ // setStatus every 30ms for ~900ms → 32 loads started, 1 completed, 0 during the storm.
834
+ const memory = createMemoryAdapter()
835
+ const gates: Deferred[] = []
836
+ let gating = false
837
+ let listStarted = 0
838
+ const useThreads = createAdapterThreadsStore<AgentPart>({
839
+ ...memory,
840
+ listThreads: async (signal) => {
841
+ listStarted += 1
842
+ if (gating) {
843
+ const gate = deferred()
844
+ gates.push(gate)
845
+ await gate.promise
846
+ }
847
+ return memory.listThreads(signal)
848
+ },
849
+ })
850
+
851
+ const { result, unmount } = renderHook(() => useThreads())
852
+ await waitFor(() => {
853
+ expect(result.current.hydrated).toBe(true)
854
+ })
855
+
856
+ let id = ''
857
+ act(() => {
858
+ id = result.current.create()
859
+ })
860
+ await settle()
861
+
862
+ gating = true
863
+ const beforeStorm = listStarted
864
+ const STORM = 40
865
+ // All against ONE thread, issued synchronously while the first follow-up load is gated — the
866
+ // exact shape (a streaming append firing every few ms) that starved every load before the fix.
867
+ act(() => {
868
+ for (let i = 0; i < STORM; i += 1) {
869
+ result.current.appendMessage(id, makeMessage(`m-${i}`))
870
+ }
871
+ })
872
+ await settle()
873
+
874
+ // Exactly one real round trip is in flight for the whole storm — nothing aborted it, nothing
875
+ // restarted it.
876
+ expect(listStarted).toBe(beforeStorm + 1)
877
+
878
+ await act(async () => {
879
+ gates[0]?.resolve()
880
+ await gates[0]?.promise
881
+ })
882
+ await settle()
883
+ // Landing starts the ONE coalesced follow-up for every write that arrived mid-flight.
884
+ expect(listStarted).toBe(beforeStorm + 2)
885
+
886
+ await act(async () => {
887
+ gates[1]?.resolve()
888
+ await gates[1]?.promise
889
+ })
890
+ await settle()
891
+ // No third round trip — two loads accounted for the entire storm, not STORM of them.
892
+ expect(listStarted).toBe(beforeStorm + 2)
893
+ expect(result.current.threads.find((t) => t.id === id)?.messages).toHaveLength(STORM)
894
+
895
+ // Prove every patch actually RETIRED — not merely "looks right because the backend already
896
+ // agrees". Reset the backend for this thread OUT OF BAND, bypassing the optimistic layer
897
+ // entirely, then force one more (ungated) revalidate. A patch still stuck in `pending` would
898
+ // idempotently re-insert its message regardless of what the backend now says, masking the
899
+ // reset; a fully drained store shows it immediately.
900
+ await memory.removeThread(id)
901
+ await memory.createThread({ id })
902
+ gating = false
903
+ act(() => {
904
+ result.current.markRead(id)
905
+ })
906
+ await waitFor(() => {
907
+ expect(result.current.threads.find((t) => t.id === id)?.messages).toHaveLength(0)
908
+ })
909
+ expect(result.current.error).toBeUndefined()
910
+ unmount()
911
+ })
912
+
913
+ test('a large write burst issues O(1) list round-trips, not one per write', async () => {
914
+ // Regression for the finding's request-amplification bound. Probe B: 800 appendMessage calls
915
+ // against a no-op appendMessage (so anything visible is purely unretired optimistic state) →
916
+ // 802 round trips started, 2 completed, 800 patches still resident. Fixed: at most 2, ever,
917
+ // regardless of burst size.
918
+ const memory = createMemoryAdapter()
919
+ let listStarted = 0
920
+ let gating = false
921
+ let gate: Deferred | null = null
922
+ const useThreads = createAdapterThreadsStore<AgentPart>({
923
+ ...memory,
924
+ // No-op write: isolates the round-trip count from drain correctness (covered above).
925
+ appendMessage: async () => {},
926
+ listThreads: async (signal) => {
927
+ listStarted += 1
928
+ if (gating) {
929
+ gate = deferred()
930
+ await gate.promise
931
+ }
932
+ return memory.listThreads(signal)
933
+ },
934
+ })
935
+
936
+ const { result, unmount } = renderHook(() => useThreads())
937
+ await waitFor(() => {
938
+ expect(result.current.hydrated).toBe(true)
939
+ })
940
+
941
+ let id = ''
942
+ act(() => {
943
+ id = result.current.create()
944
+ })
945
+ await settle()
946
+
947
+ gating = true
948
+ const beforeStorm = listStarted
949
+ const WRITES = 300
950
+ act(() => {
951
+ for (let i = 0; i < WRITES; i += 1) {
952
+ result.current.appendMessage(id, makeMessage(`m-${i}`))
953
+ }
954
+ })
955
+ await settle()
956
+
957
+ const startedWhileGated = listStarted - beforeStorm
958
+ // The bug: every write aborted-and-restarted its own listThreads — ~WRITES round trips (802
959
+ // for 800 appends in the probe). Fixed: exactly the one already in flight.
960
+ expect(startedWhileGated).toBe(1)
961
+ expect(startedWhileGated).toBeLessThan(WRITES / 10)
962
+ expect(result.current.threads.find((t) => t.id === id)?.messages).toHaveLength(WRITES)
963
+
964
+ await act(async () => {
965
+ gate?.resolve()
966
+ await gate?.promise
967
+ })
968
+ await settle()
969
+ // The single coalesced follow-up for the whole burst — total started is 2, never WRITES + 1.
970
+ expect(listStarted - beforeStorm).toBe(2)
971
+ unmount()
972
+ })
973
+
974
+ test('an unrelated select() does not re-stamp or re-identify a thread mid-write', async () => {
975
+ const gate = deferred()
976
+ const memory = createMemoryAdapter()
977
+ const useThreads = createAdapterThreadsStore<AgentPart>({
978
+ ...memory,
979
+ setStatus: async (i) => {
980
+ await gate.promise
981
+ await memory.setStatus(i)
982
+ },
983
+ })
984
+
985
+ const { result } = renderHook(() => useThreads())
986
+ await waitFor(() => {
987
+ expect(result.current.hydrated).toBe(true)
988
+ })
989
+
990
+ let id = ''
991
+ act(() => {
992
+ id = result.current.create()
993
+ })
994
+ await settle()
995
+
996
+ act(() => {
997
+ result.current.setStatus(id, 'streaming')
998
+ })
999
+ const optimistic = result.current.threads[0]
1000
+ expect(optimistic?.status).toBe('streaming')
1001
+
1002
+ act(() => {
1003
+ result.current.select(id)
1004
+ })
1005
+
1006
+ expect(result.current.activeId).toBe(id)
1007
+ // Re-applying the in-flight patch must be a genuine no-op: same timestamp AND same object,
1008
+ // or every list consumer's referential memoization is dead while any write is in flight.
1009
+ expect(result.current.threads[0]?.updatedAt).toBe(optimistic?.updatedAt)
1010
+ expect(result.current.threads[0]).toBe(optimistic)
1011
+
1012
+ await act(async () => {
1013
+ gate.resolve()
1014
+ await gate.promise
1015
+ })
1016
+ await settle()
1017
+ })
1018
+
1019
+ test('a patch stamps updatedAt once at issue time, not on every re-apply', async () => {
1020
+ // The identity memo above absorbs a re-apply over the SAME base; this pins the case it
1021
+ // cannot — a fresh list landing under a still-in-flight write, which re-runs `apply` for
1022
+ // real. A clock read inside `apply` would silently drift updatedAt forward there.
1023
+ const realNow = Date.now
1024
+ let clock = 1_000
1025
+ Date.now = (): number => clock
1026
+ const gate = deferred()
1027
+ const memory = createMemoryAdapter()
1028
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1029
+ ...memory,
1030
+ setStatus: async (i) => {
1031
+ await gate.promise
1032
+ await memory.setStatus(i)
1033
+ },
1034
+ })
1035
+
1036
+ try {
1037
+ const { result } = renderHook(() => useThreads())
1038
+ await settle()
1039
+ expect(result.current.hydrated).toBe(true)
1040
+
1041
+ let first = ''
1042
+ act(() => {
1043
+ first = result.current.create()
1044
+ })
1045
+ await settle()
1046
+
1047
+ clock = 2_000
1048
+ act(() => {
1049
+ result.current.setStatus(first, 'streaming')
1050
+ })
1051
+ expect(result.current.threads[0]?.updatedAt).toBe(2_000)
1052
+
1053
+ // A second thread's write lands a fresh list, so the in-flight patch is re-applied over a
1054
+ // DIFFERENT base array.
1055
+ clock = 3_000
1056
+ act(() => {
1057
+ result.current.create()
1058
+ })
1059
+ await settle()
1060
+
1061
+ const stamped = result.current.threads.find((t) => t.id === first)
1062
+ expect(stamped?.status).toBe('streaming')
1063
+ expect(stamped?.updatedAt).toBe(2_000)
1064
+ } finally {
1065
+ Date.now = realNow
1066
+ }
1067
+
1068
+ await act(async () => {
1069
+ gate.resolve()
1070
+ await gate.promise
1071
+ })
1072
+ await settle()
1073
+ })
1074
+
1075
+ // ── remove / clear ──────────────────────────────────────────────────────────
1076
+
1077
+ test('a rejected remove restores the row AND the selection', async () => {
1078
+ const gate = deferred()
1079
+ const memory = createMemoryAdapter()
1080
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1081
+ ...memory,
1082
+ removeThread: async (id) => {
1083
+ await gate.promise
1084
+ await memory.removeThread(id)
1085
+ },
1086
+ })
1087
+
1088
+ const { result } = renderHook(() => useThreads())
1089
+ await waitFor(() => {
1090
+ expect(result.current.hydrated).toBe(true)
1091
+ })
1092
+
1093
+ let id = ''
1094
+ act(() => {
1095
+ id = result.current.create()
1096
+ })
1097
+ await settle()
1098
+ act(() => {
1099
+ result.current.select(id)
1100
+ })
1101
+ expect(result.current.activeId).toBe(id)
1102
+
1103
+ act(() => {
1104
+ result.current.remove(id)
1105
+ })
1106
+ expect(result.current.threads).toHaveLength(0)
1107
+ expect(result.current.activeId).toBeNull()
1108
+
1109
+ await act(async () => {
1110
+ gate.reject(new Error('remove-boom'))
1111
+ await gate.promise.catch(() => {})
1112
+ })
1113
+
1114
+ await waitFor(() => {
1115
+ expect(result.current.error).toBeInstanceOf(Error)
1116
+ })
1117
+ // The selection is part of the optimistic edit, so it comes back with the row.
1118
+ expect(result.current.threads.map((t) => t.id)).toEqual([id])
1119
+ expect(result.current.activeId).toBe(id)
1120
+ await settle()
1121
+ })
1122
+
1123
+ test('a partially failed clear() converges on the server instead of resurrecting deleted rows', async () => {
1124
+ const slow = deferred()
1125
+ const memory = createMemoryAdapter()
1126
+ let doomed = ''
1127
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1128
+ ...memory,
1129
+ removeThread: async (id) => {
1130
+ // The undeletable row rejects IMMEDIATELY while the other two are still in flight — the
1131
+ // exact shape that makes a `Promise.all` fan-out converge on a list it has already raced.
1132
+ if (id === doomed) throw new Error('remove-boom')
1133
+ await slow.promise
1134
+ await memory.removeThread(id)
1135
+ },
1136
+ })
1137
+
1138
+ const { result } = renderHook(() => useThreads())
1139
+ await waitFor(() => {
1140
+ expect(result.current.hydrated).toBe(true)
1141
+ })
1142
+
1143
+ let second = ''
1144
+ act(() => {
1145
+ result.current.create()
1146
+ second = result.current.create()
1147
+ result.current.create()
1148
+ })
1149
+ await settle()
1150
+ expect(result.current.threads).toHaveLength(3)
1151
+ doomed = second
1152
+
1153
+ act(() => {
1154
+ result.current.clear()
1155
+ })
1156
+ expect(result.current.threads).toEqual([])
1157
+
1158
+ // One delete has already rejected, but the others have not settled: nothing may be reported
1159
+ // or rolled back yet, or the convergence load races the deletes still in flight.
1160
+ await settle()
1161
+ expect(result.current.error).toBeUndefined()
1162
+ expect(result.current.threads).toEqual([])
1163
+
1164
+ await act(async () => {
1165
+ slow.resolve()
1166
+ await slow.promise
1167
+ })
1168
+
1169
+ await waitFor(() => {
1170
+ expect(result.current.error).toBeInstanceOf(AggregateError)
1171
+ })
1172
+ // Only the undeletable thread comes back. The two that really were deleted stay deleted.
1173
+ await waitFor(() => {
1174
+ expect(result.current.threads.map((t) => t.id)).toEqual([second])
1175
+ })
1176
+ expect((await memory.listThreads()).map((t) => t.id)).toEqual([second])
1177
+ expect(result.current.error).toBeInstanceOf(AggregateError)
1178
+ })
1179
+
1180
+ test('a select() into a thread created after clear() survives the clear settling', async () => {
1181
+ // clear() removes the threads it captured at ISSUE time — a selection minted afterwards, into
1182
+ // a thread the clear never knew about, must come through unharmed both while the clear's
1183
+ // patch is still being re-applied and once it confirms and prune() folds it into activeId.
1184
+ const gate = deferred()
1185
+ const memory = createMemoryAdapter()
1186
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1187
+ ...memory,
1188
+ removeThread: async (id) => {
1189
+ await gate.promise
1190
+ await memory.removeThread(id)
1191
+ },
1192
+ })
1193
+
1194
+ const { result } = renderHook(() => useThreads())
1195
+ await waitFor(() => {
1196
+ expect(result.current.hydrated).toBe(true)
1197
+ })
1198
+
1199
+ act(() => {
1200
+ result.current.create()
1201
+ })
1202
+ await settle()
1203
+
1204
+ act(() => {
1205
+ result.current.clear()
1206
+ })
1207
+ expect(result.current.threads).toEqual([])
1208
+ expect(result.current.activeId).toBeNull()
1209
+
1210
+ let fresh = ''
1211
+ act(() => {
1212
+ fresh = result.current.create()
1213
+ result.current.select(fresh)
1214
+ })
1215
+ expect(result.current.threads.map((t) => t.id)).toEqual([fresh])
1216
+ // Optimistic: the clear's applyActive is still being re-applied on top of this select.
1217
+ expect(result.current.activeId).toBe(fresh)
1218
+
1219
+ await act(async () => {
1220
+ gate.resolve()
1221
+ await gate.promise
1222
+ })
1223
+ await settle()
1224
+
1225
+ // Confirmed: clear()'s patch has retired and prune() folded its applyActive into the
1226
+ // committed activeId — the selection must still point at the thread that survived it.
1227
+ expect(result.current.activeId).toBe(fresh)
1228
+ expect(result.current.threads.map((t) => t.id)).toEqual([fresh])
1229
+ })
1230
+
1231
+ // ── error latching ──────────────────────────────────────────────────────────
1232
+
1233
+ test('a write queued behind a failure does not clear it; a later retry does', async () => {
1234
+ const gate = deferred()
1235
+ let statusCalls = 0
1236
+ const memory = createMemoryAdapter()
1237
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1238
+ ...memory,
1239
+ setStatus: async (i) => {
1240
+ statusCalls += 1
1241
+ if (statusCalls === 1) await gate.promise // rejects
1242
+ await memory.setStatus(i)
1243
+ },
1244
+ })
1245
+
1246
+ const { result } = renderHook(() => useThreads())
1247
+ await waitFor(() => {
1248
+ expect(result.current.hydrated).toBe(true)
1249
+ })
1250
+
1251
+ let id = ''
1252
+ act(() => {
1253
+ id = result.current.create()
1254
+ })
1255
+ await settle()
1256
+
1257
+ act(() => {
1258
+ result.current.setStatus(id, 'streaming') // will reject
1259
+ result.current.markRead(id) // issued BEFORE the failure, runs after it
1260
+ })
1261
+
1262
+ await act(async () => {
1263
+ gate.reject(new Error('status-boom'))
1264
+ await gate.promise.catch(() => {})
1265
+ })
1266
+ await waitFor(() => {
1267
+ expect(result.current.error).toBeInstanceOf(Error)
1268
+ })
1269
+ await settle()
1270
+
1271
+ // markRead succeeded afterwards, but it was issued before the failure — it is not evidence
1272
+ // the failure is over, and must not swallow it.
1273
+ expect(result.current.threads[0]?.read).toBe(true)
1274
+ expect((result.current.error as Error).message).toBe('status-boom')
1275
+
1276
+ // A retry issued after the failure IS evidence of recovery.
1277
+ act(() => {
1278
+ result.current.setStatus(id, 'streaming')
1279
+ })
1280
+ await waitFor(() => {
1281
+ expect(result.current.error).toBeUndefined()
1282
+ })
1283
+ expect((await memory.loadThread(id))?.status).toBe('streaming')
1284
+ await settle()
1285
+ })
1286
+
1287
+ // ── revalidateOnFocus ───────────────────────────────────────────────────────
1288
+
1289
+ test('revalidateOnFocus is off by default', async () => {
1290
+ let listCalls = 0
1291
+ const memory = createMemoryAdapter()
1292
+ const useThreads = createAdapterThreadsStore<AgentPart>({
1293
+ ...memory,
1294
+ listThreads: async (signal) => {
1295
+ listCalls += 1
1296
+ return memory.listThreads(signal)
1297
+ },
1298
+ })
1299
+
1300
+ const { result } = renderHook(() => useThreads())
1301
+ await waitFor(() => {
1302
+ expect(result.current.hydrated).toBe(true)
1303
+ })
1304
+ const afterMount = listCalls
1305
+
1306
+ window.dispatchEvent(new Event('focus'))
1307
+ await settle()
1308
+
1309
+ expect(listCalls).toBe(afterMount)
1310
+ })
1311
+
1312
+ test('revalidateOnFocus re-lists on focus and unsubscribes when the last consumer unmounts', async () => {
1313
+ let listCalls = 0
1314
+ const memory = createMemoryAdapter()
1315
+ const useThreads = createAdapterThreadsStore<AgentPart>(
1316
+ {
1317
+ ...memory,
1318
+ listThreads: async (signal) => {
1319
+ listCalls += 1
1320
+ return memory.listThreads(signal)
1321
+ },
1322
+ },
1323
+ { revalidateOnFocus: true },
1324
+ )
1325
+
1326
+ const { result, unmount } = renderHook(() => useThreads())
1327
+ await waitFor(() => {
1328
+ expect(result.current.hydrated).toBe(true)
1329
+ })
1330
+ const afterMount = listCalls
1331
+
1332
+ window.dispatchEvent(new Event('focus'))
1333
+ await settle()
1334
+ expect(listCalls).toBe(afterMount + 1)
1335
+
1336
+ unmount()
1337
+ window.dispatchEvent(new Event('focus'))
1338
+ await settle()
1339
+ // The listener is removed with the last consumer — a background tab does not keep polling.
1340
+ expect(listCalls).toBe(afterMount + 1)
1341
+ })
1342
+
1343
+ // ── id minting: create() outside a secure context ────────────────────────────
1344
+
1345
+ test('create() mints distinct ids via crypto.getRandomValues when randomUUID is unavailable', async () => {
1346
+ const originalCrypto = globalThis.crypto
1347
+ Object.defineProperty(globalThis, 'crypto', {
1348
+ value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
1349
+ configurable: true,
1350
+ })
1351
+ try {
1352
+ const useThreads = createAdapterThreadsStore<AgentPart>(createMemoryAdapter())
1353
+ const { result, unmount } = renderHook(() => useThreads())
1354
+ await waitFor(() => {
1355
+ expect(result.current.hydrated).toBe(true)
1356
+ })
1357
+
1358
+ let first = ''
1359
+ let second = ''
1360
+ act(() => {
1361
+ first = result.current.create()
1362
+ second = result.current.create()
1363
+ })
1364
+
1365
+ expect(first).not.toBe('')
1366
+ expect(second).not.toBe('')
1367
+ expect(first).not.toBe(second)
1368
+ await settle()
1369
+ unmount()
1370
+ } finally {
1371
+ // Restore the real crypto so no later test in the process is affected.
1372
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
1373
+ }
1374
+ })
1375
+
1376
+ test('create() still mints distinct ids with no usable crypto at all (randomUUID and getRandomValues both absent)', async () => {
1377
+ const originalCrypto = globalThis.crypto
1378
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
1379
+ try {
1380
+ const useThreads = createAdapterThreadsStore<AgentPart>(createMemoryAdapter())
1381
+ const { result, unmount } = renderHook(() => useThreads())
1382
+ await waitFor(() => {
1383
+ expect(result.current.hydrated).toBe(true)
1384
+ })
1385
+
1386
+ let first = ''
1387
+ let second = ''
1388
+ act(() => {
1389
+ first = result.current.create()
1390
+ second = result.current.create()
1391
+ })
1392
+
1393
+ expect(first).not.toBe('')
1394
+ expect(second).not.toBe('')
1395
+ expect(first).not.toBe(second)
1396
+ await settle()
1397
+ unmount()
1398
+ } finally {
1399
+ // Restore the real crypto so no later test in the process is affected.
1400
+ Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
1401
+ }
1402
+ })
1403
+ })