basalt-ui 1.9.0 → 1.11.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 (104) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/configs/oxlint-plugin.js +279 -0
  6. package/configs/oxlint-plugin.test.ts +216 -1
  7. package/configs/oxlint.json +5 -2
  8. package/dist/agent/ai-sdk-transport.d.ts +3 -52
  9. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  10. package/dist/agent/ai-sdk-transport.js +108 -24
  11. package/dist/agent/coalesce.d.ts +26 -0
  12. package/dist/agent/coalesce.d.ts.map +1 -0
  13. package/dist/agent/coalesce.js +69 -0
  14. package/dist/agent/foreign.d.ts +97 -0
  15. package/dist/agent/foreign.d.ts.map +1 -0
  16. package/dist/agent/foreign.js +18 -0
  17. package/dist/agent/history.d.ts +2 -0
  18. package/dist/agent/history.d.ts.map +1 -1
  19. package/dist/agent/id.d.ts +35 -0
  20. package/dist/agent/id.d.ts.map +1 -0
  21. package/dist/agent/id.js +14 -0
  22. package/dist/agent/index.d.ts +9 -4
  23. package/dist/agent/index.d.ts.map +1 -1
  24. package/dist/agent/index.js +15 -3
  25. package/dist/agent/merge.d.ts +8 -0
  26. package/dist/agent/merge.d.ts.map +1 -0
  27. package/dist/agent/merge.js +23 -0
  28. package/dist/agent/part-list.d.ts.map +1 -1
  29. package/dist/agent/part-list.js +16 -6
  30. package/dist/agent/parts.d.ts +100 -21
  31. package/dist/agent/parts.d.ts.map +1 -1
  32. package/dist/agent/parts.js +128 -11
  33. package/dist/agent/transport.d.ts +39 -3
  34. package/dist/agent/transport.d.ts.map +1 -1
  35. package/dist/agent/transport.js +20 -17
  36. package/dist/agent/use-agent-stream.d.ts +2 -1
  37. package/dist/agent/use-agent-stream.d.ts.map +1 -1
  38. package/dist/agent/use-agent-stream.js +5 -2
  39. package/dist/agent/use-agent-thread-runs.d.ts +9 -3
  40. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  41. package/dist/agent/use-agent-thread-runs.js +76 -23
  42. package/dist/agent-chat/index.d.ts +6 -3
  43. package/dist/agent-chat/index.d.ts.map +1 -1
  44. package/dist/agent-chat/index.js +2 -0
  45. package/dist/agent-chat/thread-message.d.ts +24 -7
  46. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  47. package/dist/agent-chat/thread-message.js +102 -43
  48. package/dist/agent-chat/tool-chip.d.ts +21 -0
  49. package/dist/agent-chat/tool-chip.d.ts.map +1 -0
  50. package/dist/agent-chat/tool-chip.js +120 -0
  51. package/dist/cli/index.d.ts +33 -0
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +131 -0
  54. package/dist/guard/index.d.ts.map +1 -1
  55. package/dist/guard/index.js +1 -3
  56. package/dist/index.d.ts +2 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -1
  59. package/dist/surfaces.d.ts +11 -1
  60. package/dist/surfaces.d.ts.map +1 -1
  61. package/dist/surfaces.js +22 -1
  62. package/llms.txt +10 -3
  63. package/package.json +6 -1
  64. package/src/agent/ai-sdk-transport.test.ts +567 -0
  65. package/src/agent/ai-sdk-transport.ts +206 -53
  66. package/src/agent/coalesce.test.ts +194 -0
  67. package/src/agent/coalesce.ts +118 -0
  68. package/src/agent/foreign.test.ts +47 -0
  69. package/src/agent/foreign.ts +140 -0
  70. package/src/agent/history.ts +2 -0
  71. package/src/agent/id.test.ts +71 -0
  72. package/src/agent/id.ts +43 -0
  73. package/src/agent/index.ts +26 -3
  74. package/src/agent/merge.test.ts +119 -0
  75. package/src/agent/merge.ts +67 -0
  76. package/src/agent/part-list.test.tsx +82 -0
  77. package/src/agent/part-list.tsx +31 -6
  78. package/src/agent/parts.test.ts +603 -0
  79. package/src/agent/parts.ts +262 -32
  80. package/src/agent/thread.test.ts +147 -0
  81. package/src/agent/transport.test.ts +70 -0
  82. package/src/agent/transport.ts +72 -20
  83. package/src/agent/use-agent-stream.test.tsx +261 -0
  84. package/src/agent/use-agent-stream.ts +13 -3
  85. package/src/agent/use-agent-thread-runs.foreign.test.tsx +133 -0
  86. package/src/agent/use-agent-thread-runs.resume.test.tsx +289 -0
  87. package/src/agent/use-agent-thread-runs.test.tsx +781 -0
  88. package/src/agent/use-agent-thread-runs.ts +177 -27
  89. package/src/agent/use-agent-thread-runs.wedge.test.tsx +301 -0
  90. package/src/agent-chat/index.ts +8 -3
  91. package/src/agent-chat/thread-message.test.tsx +211 -0
  92. package/src/agent-chat/thread-message.tsx +235 -62
  93. package/src/agent-chat/tool-chip.test.tsx +139 -0
  94. package/src/agent-chat/tool-chip.tsx +219 -0
  95. package/src/charts/primitives/ChartFrame.test.tsx +8 -5
  96. package/src/cli/index.ts +285 -0
  97. package/src/dashboard/stat-card.test.tsx +5 -3
  98. package/src/guard/check-source.test.ts +8 -4
  99. package/src/guard/index.ts +1 -3
  100. package/src/index.ts +2 -0
  101. package/src/provider/index.test.tsx +6 -5
  102. package/src/surfaces.ts +23 -1
  103. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  104. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -0,0 +1,289 @@
1
+ /**
2
+ * useAgentThreadRuns — mount-time reconcile ("resume orphaned threads after a reload") coverage.
3
+ *
4
+ * The mount-reconcile effect (empty deps — a one-time sweep of whatever the store held when this
5
+ * manager first attaches) treats any persisted 'pending' or 'streaming' thread with no live
6
+ * controller as orphaned, and either attempts `transport.resume(resumeToken, signal)` (when the
7
+ * transport is RESUMABLE per `isResumable` — `resume` AND the literal `idempotentReplay: true`
8
+ * assertion, a resumeToken is present, and there's a last user message to attribute the resumed
9
+ * turn to) or falls back straight to 'interrupted'. `resume` alone is NOT enough post-B2 — see the
10
+ * dedicated "resume() present but no idempotentReplay" test below.
11
+ *
12
+ * The F3 wedge (StrictMode/`<Activity>` re-running this same effect on the SAME fiber) has its
13
+ * own file: `use-agent-thread-runs.wedge.test.tsx`.
14
+ */
15
+ import { describe, expect, test } from 'bun:test'
16
+ import { renderHook, waitFor } from '@testing-library/react'
17
+ import { useAgentThreadRuns } from './use-agent-thread-runs'
18
+ import type { AgentThread, ThreadsStore } from './thread'
19
+ import type { AgentTransport } from './transport'
20
+ import type { AgentPart } from './parts'
21
+ import type { ChatMessage } from './history'
22
+ import type { AgentOutcome } from './outcome'
23
+
24
+ // ── test-only ThreadsStore double — see use-agent-thread-runs.wedge.test.tsx for the rationale ──
25
+ function createTestThreadsStore(initial: AgentThread<AgentPart>[]): ThreadsStore<AgentPart> {
26
+ let threads = initial
27
+ let activeId: string | null = null
28
+ return {
29
+ get threads() {
30
+ return threads
31
+ },
32
+ get activeId() {
33
+ return activeId
34
+ },
35
+ select(id) {
36
+ activeId = id
37
+ },
38
+ create(opts) {
39
+ const id = crypto.randomUUID()
40
+ const now = Date.now()
41
+ const thread: AgentThread<AgentPart> = {
42
+ id,
43
+ messages: [],
44
+ outcome: null,
45
+ status: 'pending',
46
+ read: false,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ ...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
50
+ }
51
+ threads = [thread, ...threads]
52
+ return id
53
+ },
54
+ appendMessage(id, message) {
55
+ threads = threads.map((thread) =>
56
+ thread.id === id
57
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
58
+ : thread,
59
+ )
60
+ },
61
+ setOutcome(id, outcome) {
62
+ threads = threads.map((thread) =>
63
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
64
+ )
65
+ },
66
+ setStatus(id, status) {
67
+ threads = threads.map((thread) =>
68
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
69
+ )
70
+ },
71
+ setResumeToken(id, token) {
72
+ threads = threads.map((thread) => {
73
+ if (thread.id !== id) return thread
74
+ const { resumeToken: _resumeToken, ...rest } = thread
75
+ return {
76
+ ...rest,
77
+ ...(token !== undefined ? { resumeToken: token } : {}),
78
+ updatedAt: Date.now(),
79
+ }
80
+ })
81
+ },
82
+ markRead(id) {
83
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
84
+ },
85
+ remove(id) {
86
+ threads = threads.filter((thread) => thread.id !== id)
87
+ if (activeId === id) activeId = null
88
+ },
89
+ clear() {
90
+ threads = []
91
+ activeId = null
92
+ },
93
+ }
94
+ }
95
+
96
+ function makeStreamingThread(
97
+ overrides: Partial<AgentThread<AgentPart>> = {},
98
+ ): AgentThread<AgentPart> {
99
+ const now = Date.now()
100
+ const userMessage: ChatMessage<AgentPart> = {
101
+ id: crypto.randomUUID(),
102
+ role: 'user',
103
+ parts: [{ type: 'text', text: 'hello' }],
104
+ createdAt: now,
105
+ }
106
+ return {
107
+ id: crypto.randomUUID(),
108
+ messages: [userMessage],
109
+ outcome: null,
110
+ status: 'streaming',
111
+ read: false,
112
+ createdAt: now,
113
+ updatedAt: now,
114
+ resumeToken: 'resume-token',
115
+ ...overrides,
116
+ }
117
+ }
118
+
119
+ const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
120
+
121
+ describe('useAgentThreadRuns — mount-time reconcile', () => {
122
+ test('no resume() on the transport → falls back to interrupted', async () => {
123
+ const thread = makeStreamingThread()
124
+ const store = createTestThreadsStore([thread])
125
+
126
+ const transport: AgentTransport<AgentPart, string> = {
127
+ async *stream() {},
128
+ // no `resume` key at all
129
+ }
130
+
131
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
132
+
133
+ await waitFor(() => {
134
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
135
+ })
136
+ })
137
+
138
+ test('no resumeToken on the persisted thread → falls back to interrupted, even with a resumable transport', async () => {
139
+ // exactOptionalPropertyTypes forbids `resumeToken: undefined` as an override — build the
140
+ // thread directly, simply omitting the key, to represent "never had a resumeToken".
141
+ const now = Date.now()
142
+ const userMessage: ChatMessage<AgentPart> = {
143
+ id: crypto.randomUUID(),
144
+ role: 'user',
145
+ parts: [{ type: 'text', text: 'hello' }],
146
+ createdAt: now,
147
+ }
148
+ const thread: AgentThread<AgentPart> = {
149
+ id: crypto.randomUUID(),
150
+ messages: [userMessage],
151
+ outcome: null,
152
+ status: 'streaming',
153
+ read: false,
154
+ createdAt: now,
155
+ updatedAt: now,
156
+ }
157
+ const store = createTestThreadsStore([thread])
158
+
159
+ let resumeCalls = 0
160
+ const transport = {
161
+ async *stream() {},
162
+ async *resume() {
163
+ resumeCalls++
164
+ yield { type: 'text', text: 'resumed' }
165
+ },
166
+ idempotentReplay: true as const,
167
+ }
168
+
169
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
170
+
171
+ await waitFor(() => {
172
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
173
+ })
174
+ expect(resumeCalls).toBe(0)
175
+ })
176
+
177
+ test('no last user message on the persisted thread → falls back to interrupted, even with a resumable transport + token', async () => {
178
+ const thread = makeStreamingThread({ messages: [] })
179
+ const store = createTestThreadsStore([thread])
180
+
181
+ let resumeCalls = 0
182
+ const transport = {
183
+ async *stream() {},
184
+ async *resume() {
185
+ resumeCalls++
186
+ yield { type: 'text', text: 'resumed' }
187
+ },
188
+ idempotentReplay: true as const,
189
+ }
190
+
191
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
192
+
193
+ await waitFor(() => {
194
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
195
+ })
196
+ expect(resumeCalls).toBe(0)
197
+ })
198
+
199
+ test('resume() present but WITHOUT idempotentReplay → still falls back to interrupted (resume alone is not enough post-B2)', async () => {
200
+ const thread = makeStreamingThread()
201
+ const store = createTestThreadsStore([thread])
202
+
203
+ let resumeCalls = 0
204
+ const transport: AgentTransport<AgentPart, string> = {
205
+ async *stream() {},
206
+ async *resume() {
207
+ resumeCalls++
208
+ yield { type: 'text', text: 'resumed' }
209
+ },
210
+ // deliberately NO idempotentReplay: true
211
+ }
212
+
213
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
214
+
215
+ await waitFor(() => {
216
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
217
+ })
218
+ expect(resumeCalls).toBe(0)
219
+ })
220
+
221
+ test('resumable transport + token + last user message → resume() is called exactly once, with that token', async () => {
222
+ const thread = makeStreamingThread({ resumeToken: 'the-exact-token' })
223
+ const store = createTestThreadsStore([thread])
224
+
225
+ const resumeArgs: string[] = []
226
+ const transport = {
227
+ async *stream() {},
228
+ async *resume(token: string) {
229
+ resumeArgs.push(token)
230
+ yield { type: 'text', text: 'resumed' }
231
+ },
232
+ idempotentReplay: true as const,
233
+ }
234
+
235
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
236
+
237
+ await waitFor(() => {
238
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
239
+ })
240
+ expect(resumeArgs).toEqual(['the-exact-token'])
241
+ })
242
+
243
+ test('a failed resume() falls back to interrupted via onFailureStatus, not error', async () => {
244
+ const thread = makeStreamingThread()
245
+ const store = createTestThreadsStore([thread])
246
+
247
+ const transport = {
248
+ async *stream() {},
249
+ // oxlint-disable-next-line require-yield
250
+ async *resume() {
251
+ throw new Error('resume failed')
252
+ },
253
+ idempotentReplay: true as const,
254
+ }
255
+
256
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
257
+
258
+ await waitFor(() => {
259
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('interrupted')
260
+ })
261
+ })
262
+
263
+ test('a "pending" (never-started) thread with no live controller is also swept by reconcile', async () => {
264
+ // Not covered by "streaming" scenarios above: reconcile's orphan check is
265
+ // `status === 'pending' || status === 'streaming'`, so a thread that was created but never
266
+ // started is reconciled the same way on the very next mount.
267
+ const now = Date.now()
268
+ const pendingThread: AgentThread<AgentPart> = {
269
+ id: crypto.randomUUID(),
270
+ messages: [],
271
+ outcome: null,
272
+ status: 'pending',
273
+ read: false,
274
+ createdAt: now,
275
+ updatedAt: now,
276
+ }
277
+ const store = createTestThreadsStore([pendingThread])
278
+
279
+ const transport: AgentTransport<AgentPart, string> = {
280
+ async *stream() {},
281
+ }
282
+
283
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
284
+
285
+ await waitFor(() => {
286
+ expect(store.threads.find((t) => t.id === pendingThread.id)?.status).toBe('interrupted')
287
+ })
288
+ })
289
+ })