basalt-ui 1.12.0 → 1.14.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.
- package/AGENTS.md +21 -21
- package/CHANGELOG.md +24 -0
- package/README.md +38 -35
- package/agent/rules/basalt-agent.md +14 -0
- package/configs/oxlint-plugin.js +7 -3
- package/configs/oxlint-plugin.test.ts +34 -5
- package/configs/oxlint.json +4 -4
- package/dist/agent/adapter.d.ts +5 -1
- package/dist/agent/adapter.d.ts.map +1 -1
- package/dist/agent/adapter.js +22 -13
- package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- package/dist/agent/id.d.ts +67 -19
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +21 -7
- package/dist/agent/thread.d.ts +4 -0
- package/dist/agent/thread.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- package/dist/agent/use-agent-thread-runs.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +55 -15
- package/dist/agent-chat/index.d.ts +4 -0
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/agent-chat/index.js +2 -0
- package/dist/agent-chat/message-affordances.d.ts +59 -0
- package/dist/agent-chat/message-affordances.d.ts.map +1 -0
- package/dist/agent-chat/message-affordances.js +7 -0
- package/dist/agent-chat/relative-time.d.ts +27 -0
- package/dist/agent-chat/relative-time.d.ts.map +1 -0
- package/dist/agent-chat/relative-time.js +27 -0
- package/dist/agent-chat/relative-time.test.d.ts +2 -0
- package/dist/agent-chat/relative-time.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.d.ts +65 -0
- package/dist/agent-chat/thread-feed-row.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed-row.js +156 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed-row.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-feed.d.ts +34 -5
- package/dist/agent-chat/thread-feed.d.ts.map +1 -1
- package/dist/agent-chat/thread-feed.js +52 -19
- package/dist/agent-chat/thread-feed.test.d.ts +2 -0
- package/dist/agent-chat/thread-feed.test.d.ts.map +1 -0
- package/dist/agent-chat/thread-message.d.ts +183 -2
- package/dist/agent-chat/thread-message.d.ts.map +1 -1
- package/dist/agent-chat/thread-message.js +315 -34
- package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
- package/dist/agent-chat/thread-outcome-card.js +1 -16
- package/dist/agent-chat/thread-workspace.d.ts +14 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +29 -3
- package/dist/agent-chat/thread-workspace.test.d.ts +2 -0
- package/dist/agent-chat/thread-workspace.test.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.d.ts +123 -0
- package/dist/agent-chat/virtualize.d.ts.map +1 -0
- package/dist/agent-chat/virtualize.js +9 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts +2 -0
- package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +1 -0
- package/dist/data/index.d.ts +5 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual.d.ts +2 -1
- package/dist/data/virtual.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/shell/app-sidebar.d.ts +19 -1
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +30 -26
- package/dist/shell/app-sidebar.module.css +11 -0
- package/dist/shell/app-sidebar.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.test.d.ts +2 -0
- package/dist/shell/app-sidebar.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +10 -1
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +2 -0
- package/dist/shell/index.test.d.ts +2 -0
- package/dist/shell/index.test.d.ts.map +1 -0
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +3 -2
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/llms.txt +6 -6
- package/package.json +2 -2
- package/src/agent/adapter.test.ts +237 -0
- package/src/agent/adapter.ts +72 -14
- package/src/agent/ai-sdk-transport.test.ts +64 -0
- package/src/agent/ai-sdk-transport.ts +8 -2
- package/src/agent/id.test.ts +95 -1
- package/src/agent/id.ts +98 -28
- package/src/agent/thread.ts +4 -0
- package/src/agent/use-agent-stream.test.tsx +22 -0
- package/src/agent/use-agent-stream.ts +6 -3
- package/src/agent/use-agent-thread-runs.test.tsx +326 -0
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +80 -0
- package/src/agent-chat/index.ts +12 -0
- package/src/agent-chat/message-affordances.ts +61 -0
- package/src/agent-chat/relative-time.test.ts +95 -0
- package/src/agent-chat/relative-time.ts +55 -0
- package/src/agent-chat/thread-feed-row.test.tsx +510 -0
- package/src/agent-chat/thread-feed-row.tsx +274 -0
- package/src/agent-chat/thread-feed.test.tsx +241 -0
- package/src/agent-chat/thread-feed.tsx +119 -18
- package/src/agent-chat/thread-message.test.tsx +937 -3
- package/src/agent-chat/thread-message.tsx +766 -38
- package/src/agent-chat/thread-outcome-card.tsx +1 -25
- package/src/agent-chat/thread-workspace.test.tsx +169 -0
- package/src/agent-chat/thread-workspace.tsx +69 -5
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- package/src/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +5 -0
- package/src/shell/app-sidebar.module.css +11 -0
- package/src/shell/app-sidebar.module.css.d.ts +1 -0
- package/src/shell/app-sidebar.test.tsx +98 -0
- package/src/shell/app-sidebar.tsx +22 -0
- package/src/shell/index.test.tsx +35 -0
- package/src/shell/index.tsx +11 -0
- package/src/surfaces.ts +2 -1
- package/src/theme/shadow-surfaces.test.ts +9 -0
package/dist/surfaces.js
CHANGED
|
@@ -210,7 +210,7 @@ const SURFACES = {
|
|
|
210
210
|
rule: "agent",
|
|
211
211
|
skill: ["basalt-app"],
|
|
212
212
|
guardKinds: [],
|
|
213
|
-
description:
|
|
213
|
+
description: 'Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll \u2014 a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional \u2014 ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here \u2014 ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane).',
|
|
214
214
|
optionalPeers: [
|
|
215
215
|
"ai",
|
|
216
216
|
"motion",
|
|
@@ -221,7 +221,8 @@ const SURFACES = {
|
|
|
221
221
|
"shiki",
|
|
222
222
|
"@shikijs/langs",
|
|
223
223
|
"@shikijs/themes",
|
|
224
|
-
"beautiful-mermaid"
|
|
224
|
+
"beautiful-mermaid",
|
|
225
|
+
"@tanstack/react-virtual"
|
|
225
226
|
],
|
|
226
227
|
forbiddenImports: []
|
|
227
228
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shadow-surfaces.test.d.ts","sourceRoot":"","sources":["../../src/theme/shadow-surfaces.test.ts"],"names":[],"mappings":"AAsHA,KAAK,UAAU,GAAG;IAChB,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;iGAE6F;IAC7F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB,CAAA;AA6FD,KAAK,kBAAkB,GAAG,UAAU,GAAG;IACrC;;;;;mFAK+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,kBAAkB,
|
|
1
|
+
{"version":3,"file":"shadow-surfaces.test.d.ts","sourceRoot":"","sources":["../../src/theme/shadow-surfaces.test.ts"],"names":[],"mappings":"AAsHA,KAAK,UAAU,GAAG;IAChB,iEAAiE;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;iGAE6F;IAC7F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB,CAAA;AA6FD,KAAK,kBAAkB,GAAG,UAAU,GAAG;IACrC;;;;;mFAK+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,SAAS,kBAAkB,EAmLxD,CAAA"}
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# basalt-ui v1.
|
|
1
|
+
# basalt-ui v1.14.0
|
|
2
2
|
# Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
|
|
3
3
|
#
|
|
4
4
|
# basalt-ui is an opinionated framework for Mantine-based React apps.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
# ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
|
|
20
20
|
|
|
21
21
|
Package: basalt-ui
|
|
22
|
-
Version: 1.
|
|
22
|
+
Version: 1.14.0
|
|
23
23
|
|
|
24
24
|
## basalt-ui
|
|
25
25
|
Import: import { ... } from 'basalt-ui'
|
|
@@ -96,7 +96,7 @@ Import: import { ... } from 'basalt-ui/data'
|
|
|
96
96
|
Description: Convenience barrel pulling both TanStack Table + Virtual peer groups: BasaltDataTable, BasaltVirtualList (Mantine-rendered) — prefer ./data/table or ./data/virtual for per-feature opt-in
|
|
97
97
|
Layer: mantine-coupled
|
|
98
98
|
Rule: basalt-data
|
|
99
|
-
OptionalPeers: @tanstack/react-table@>=8 <9, @tanstack/react-virtual@>=3 <4
|
|
99
|
+
OptionalPeers: @tanstack/react-table@>=8 <9, @tanstack/react-virtual@>=3.13.26 <4
|
|
100
100
|
|
|
101
101
|
## basalt-ui/data/table
|
|
102
102
|
Import: import { ... } from 'basalt-ui/data/table'
|
|
@@ -110,7 +110,7 @@ Import: import { ... } from 'basalt-ui/data/virtual'
|
|
|
110
110
|
Description: BasaltVirtualList: a windowed virtual list over TanStack Virtual, rendered with Mantine (Mantine-rendered)
|
|
111
111
|
Layer: mantine-coupled
|
|
112
112
|
Rule: basalt-data
|
|
113
|
-
OptionalPeers: @tanstack/react-virtual@>=3 <4
|
|
113
|
+
OptionalPeers: @tanstack/react-virtual@>=3.13.26 <4
|
|
114
114
|
|
|
115
115
|
## basalt-ui/agent
|
|
116
116
|
Import: import { ... } from 'basalt-ui/agent'
|
|
@@ -121,10 +121,10 @@ OptionalPeers: ai@^7.0.15, use-stick-to-bottom@^1.1.6
|
|
|
121
121
|
|
|
122
122
|
## basalt-ui/agent-chat
|
|
123
123
|
Import: import { ... } from 'basalt-ui/agent-chat'
|
|
124
|
-
Description: Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed, ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx.
|
|
124
|
+
Description: Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane).
|
|
125
125
|
Layer: mantine-coupled
|
|
126
126
|
Rule: basalt-agent
|
|
127
|
-
OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0
|
|
127
|
+
OptionalPeers: ai@^7.0.15, motion@12.42.0, remend@1.3.0, use-stick-to-bottom@^1.1.6, react-markdown@^10.1.0, remark-gfm@^4.0.1, shiki@^4.3.0, @shikijs/langs@^4.3.1, @shikijs/themes@^4.3.1, beautiful-mermaid@^1.1.0, @tanstack/react-virtual@>=3.13.26 <4
|
|
128
128
|
|
|
129
129
|
## basalt-ui/content
|
|
130
130
|
Import: import { ... } from 'basalt-ui/content'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "basalt-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"charts",
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
"@tanstack/react-query-devtools": "^5.101.0",
|
|
191
191
|
"@tanstack/react-router": "^1.170.0",
|
|
192
192
|
"@tanstack/react-table": ">=8 <9",
|
|
193
|
-
"@tanstack/react-virtual": ">=3 <4",
|
|
193
|
+
"@tanstack/react-virtual": ">=3.13.26 <4",
|
|
194
194
|
"@visx/axis": "4.0.0",
|
|
195
195
|
"@visx/curve": "4.0.0",
|
|
196
196
|
"@visx/event": "4.0.0",
|
|
@@ -134,6 +134,21 @@ describe('threadsStoreAdapterContract', () => {
|
|
|
134
134
|
expect(new Set(cases.map((c) => c.name)).size).toBe(cases.length)
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
+
test("the contract suite itself does not depend on crypto.randomUUID — it still runs to completion via the getRandomValues fallback (mintThreadId, since every id it mints here is a THREAD id, never appendMessage's idempotency key)", async () => {
|
|
138
|
+
const originalCrypto = globalThis.crypto
|
|
139
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
140
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
141
|
+
configurable: true,
|
|
142
|
+
})
|
|
143
|
+
try {
|
|
144
|
+
for (const conformanceCase of threadsStoreAdapterContract(() => createMemoryAdapter())) {
|
|
145
|
+
await conformanceCase.run()
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
137
152
|
test('FAILS an adapter whose appendMessage is not idempotent on message.id', async () => {
|
|
138
153
|
const cases = threadsStoreAdapterContract(() => {
|
|
139
154
|
const memory = createMemoryAdapter()
|
|
@@ -417,6 +432,228 @@ describe('createAdapterThreadsStore', () => {
|
|
|
417
432
|
expect(result.current.threads[0]?.read).toBe(true)
|
|
418
433
|
})
|
|
419
434
|
|
|
435
|
+
// ── rolled-back create() and its dependent writes ───────────────────────────
|
|
436
|
+
|
|
437
|
+
test('a rolled-back create surfaces its own error, not a cascade from the writes queued behind it', async () => {
|
|
438
|
+
const calls: string[] = []
|
|
439
|
+
const memory = createMemoryAdapter()
|
|
440
|
+
const useThreads = createAdapterThreadsStore<AgentPart>({
|
|
441
|
+
...memory,
|
|
442
|
+
createThread: async () => {
|
|
443
|
+
calls.push('createThread')
|
|
444
|
+
throw new Error('create-boom')
|
|
445
|
+
},
|
|
446
|
+
// Mirrors the real finding: a real backend REJECTS a write against a row that was never
|
|
447
|
+
// created, it does not silently no-op the way the permissive reference `memory` adapter
|
|
448
|
+
// does — so these reproduce the exact "unknown thread" cascade from the console trace.
|
|
449
|
+
markRead: async (id) => {
|
|
450
|
+
calls.push('markRead')
|
|
451
|
+
if ((await memory.loadThread(id)) === null)
|
|
452
|
+
throw new Error(`markRead: unknown thread ${id}`)
|
|
453
|
+
await memory.markRead(id)
|
|
454
|
+
},
|
|
455
|
+
appendMessage: async (i) => {
|
|
456
|
+
calls.push('appendMessage')
|
|
457
|
+
if ((await memory.loadThread(i.threadId)) === null) {
|
|
458
|
+
throw new Error(`appendMessage: unknown thread ${i.threadId}`)
|
|
459
|
+
}
|
|
460
|
+
await memory.appendMessage(i)
|
|
461
|
+
},
|
|
462
|
+
setStatus: async (i) => {
|
|
463
|
+
calls.push('setStatus')
|
|
464
|
+
if ((await memory.loadThread(i.threadId)) === null) {
|
|
465
|
+
throw new Error(`setStatus: unknown thread ${i.threadId}`)
|
|
466
|
+
}
|
|
467
|
+
await memory.setStatus(i)
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
const { result } = renderHook(() => useThreads())
|
|
472
|
+
await waitFor(() => {
|
|
473
|
+
expect(result.current.hydrated).toBe(true)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
// Exactly the observed sequence: create() -> markRead() -> appendMessage() -> setStatus() in
|
|
477
|
+
// one synchronous block, every dependent write chained behind the same rejected createThread.
|
|
478
|
+
let id = ''
|
|
479
|
+
act(() => {
|
|
480
|
+
id = result.current.create()
|
|
481
|
+
result.current.markRead(id)
|
|
482
|
+
result.current.appendMessage(id, makeMessage('m-first'))
|
|
483
|
+
result.current.setStatus(id, 'streaming')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
await waitFor(() => {
|
|
487
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
488
|
+
})
|
|
489
|
+
await settle()
|
|
490
|
+
|
|
491
|
+
// The surfaced error names the create, not one of the three cascade rejections that would
|
|
492
|
+
// otherwise overwrite it in issue order.
|
|
493
|
+
expect((result.current.error as Error).message).toBe('create-boom')
|
|
494
|
+
// The dependent writes never reached the adapter at all — dropped, not run-and-failed.
|
|
495
|
+
expect(calls).toEqual(['createThread'])
|
|
496
|
+
// Rollback semantics are untouched: the thread is gone, both locally and server-side.
|
|
497
|
+
expect(result.current.threads).toHaveLength(0)
|
|
498
|
+
expect(await memory.loadThread(id)).toBeNull()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('a rolled-back create does not stall or spoil the write queue for a different thread', async () => {
|
|
502
|
+
const memory = createMemoryAdapter()
|
|
503
|
+
const calls: string[] = []
|
|
504
|
+
let createCalls = 0
|
|
505
|
+
const useThreads = createAdapterThreadsStore<AgentPart>({
|
|
506
|
+
...memory,
|
|
507
|
+
createThread: async (i) => {
|
|
508
|
+
createCalls += 1
|
|
509
|
+
if (createCalls === 1) {
|
|
510
|
+
calls.push('createThread:doomed')
|
|
511
|
+
throw new Error('create-boom')
|
|
512
|
+
}
|
|
513
|
+
calls.push('createThread:healthy')
|
|
514
|
+
await memory.createThread(i)
|
|
515
|
+
},
|
|
516
|
+
markRead: async (id) => {
|
|
517
|
+
calls.push(`markRead:${id}`)
|
|
518
|
+
await memory.markRead(id)
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const { result } = renderHook(() => useThreads())
|
|
523
|
+
await waitFor(() => {
|
|
524
|
+
expect(result.current.hydrated).toBe(true)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
let doomed = ''
|
|
528
|
+
let healthy = ''
|
|
529
|
+
act(() => {
|
|
530
|
+
doomed = result.current.create()
|
|
531
|
+
result.current.markRead(doomed)
|
|
532
|
+
healthy = result.current.create()
|
|
533
|
+
result.current.markRead(healthy)
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
await waitFor(() => {
|
|
537
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
538
|
+
})
|
|
539
|
+
await waitFor(async () => {
|
|
540
|
+
expect(await memory.loadThread(healthy)).not.toBeNull()
|
|
541
|
+
})
|
|
542
|
+
await settle()
|
|
543
|
+
|
|
544
|
+
expect((result.current.error as Error).message).toBe('create-boom')
|
|
545
|
+
// doomed's markRead was dropped; healthy's own write for a DIFFERENT thread ran normally.
|
|
546
|
+
expect(calls).toEqual(['createThread:doomed', 'createThread:healthy', `markRead:${healthy}`])
|
|
547
|
+
expect((await memory.loadThread(healthy))?.read).toBe(true)
|
|
548
|
+
expect(await memory.loadThread(doomed)).toBeNull()
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('a genuine failure that arrives after a rolled-back create still surfaces', async () => {
|
|
552
|
+
const memory = createMemoryAdapter()
|
|
553
|
+
let createCalls = 0
|
|
554
|
+
const useThreads = createAdapterThreadsStore<AgentPart>({
|
|
555
|
+
...memory,
|
|
556
|
+
createThread: async (i) => {
|
|
557
|
+
createCalls += 1
|
|
558
|
+
if (createCalls === 1) throw new Error('create-boom')
|
|
559
|
+
await memory.createThread(i)
|
|
560
|
+
},
|
|
561
|
+
markRead: async () => {
|
|
562
|
+
// A genuine, unrelated failure against a HEALTHY thread — not a cascade of the rollback.
|
|
563
|
+
throw new Error('markRead-boom')
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
const { result } = renderHook(() => useThreads())
|
|
568
|
+
await waitFor(() => {
|
|
569
|
+
expect(result.current.hydrated).toBe(true)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
act(() => {
|
|
573
|
+
result.current.create()
|
|
574
|
+
})
|
|
575
|
+
await waitFor(() => {
|
|
576
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
577
|
+
})
|
|
578
|
+
expect((result.current.error as Error).message).toBe('create-boom')
|
|
579
|
+
|
|
580
|
+
let healthy = ''
|
|
581
|
+
act(() => {
|
|
582
|
+
healthy = result.current.create()
|
|
583
|
+
})
|
|
584
|
+
await waitFor(async () => {
|
|
585
|
+
expect(await memory.loadThread(healthy)).not.toBeNull()
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
act(() => {
|
|
589
|
+
result.current.markRead(healthy)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
await waitFor(() => {
|
|
593
|
+
expect((result.current.error as Error).message).toBe('markRead-boom')
|
|
594
|
+
})
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('a rolled-back create keeps dropping its thread’s writes after the queue drains', async () => {
|
|
598
|
+
// The run's COMPLETION path (useAgentThreadRuns: appendMessage(assistant) -> setOutcome ->
|
|
599
|
+
// setStatus -> setResumeToken) lands when the stream ends, long after the create's own
|
|
600
|
+
// rollback has drained the per-thread chain. Those writes target the same rolled-back row, so
|
|
601
|
+
// they are the same cascade as the synchronous send-path trio — just later. If the failed-id
|
|
602
|
+
// record is cleared when the chain drains, the fix above covers the first ~200ms and then the
|
|
603
|
+
// cascade returns at stream end, which is precisely when the user is looking at the thread.
|
|
604
|
+
const calls: string[] = []
|
|
605
|
+
const memory = createMemoryAdapter()
|
|
606
|
+
const useThreads = createAdapterThreadsStore<AgentPart>({
|
|
607
|
+
...memory,
|
|
608
|
+
createThread: async () => {
|
|
609
|
+
calls.push('createThread')
|
|
610
|
+
throw new Error('create-boom')
|
|
611
|
+
},
|
|
612
|
+
appendMessage: async (i) => {
|
|
613
|
+
calls.push('appendMessage')
|
|
614
|
+
if ((await memory.loadThread(i.threadId)) === null) {
|
|
615
|
+
throw new Error(`appendMessage: unknown thread ${i.threadId}`)
|
|
616
|
+
}
|
|
617
|
+
await memory.appendMessage(i)
|
|
618
|
+
},
|
|
619
|
+
setStatus: async (i) => {
|
|
620
|
+
calls.push('setStatus')
|
|
621
|
+
if ((await memory.loadThread(i.threadId)) === null) {
|
|
622
|
+
throw new Error(`setStatus: unknown thread ${i.threadId}`)
|
|
623
|
+
}
|
|
624
|
+
await memory.setStatus(i)
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
const { result } = renderHook(() => useThreads())
|
|
629
|
+
await waitFor(() => {
|
|
630
|
+
expect(result.current.hydrated).toBe(true)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
let id = ''
|
|
634
|
+
act(() => {
|
|
635
|
+
id = result.current.create()
|
|
636
|
+
})
|
|
637
|
+
await waitFor(() => {
|
|
638
|
+
expect(result.current.error).toBeInstanceOf(Error)
|
|
639
|
+
})
|
|
640
|
+
// Drain fully: the create's chain entry is gone by the time the completion writes arrive.
|
|
641
|
+
await settle()
|
|
642
|
+
await settle()
|
|
643
|
+
expect((result.current.error as Error).message).toBe('create-boom')
|
|
644
|
+
|
|
645
|
+
// The completion path fires now, against the row that was never materialized.
|
|
646
|
+
act(() => {
|
|
647
|
+
result.current.appendMessage(id, makeMessage('m-assistant'))
|
|
648
|
+
result.current.setStatus(id, 'done')
|
|
649
|
+
})
|
|
650
|
+
await settle()
|
|
651
|
+
await settle()
|
|
652
|
+
|
|
653
|
+
expect(calls).toEqual(['createThread'])
|
|
654
|
+
expect((result.current.error as Error).message).toBe('create-boom')
|
|
655
|
+
})
|
|
656
|
+
|
|
420
657
|
test('setResumeToken(undefined) clears the key through the adapter', async () => {
|
|
421
658
|
const memory = createMemoryAdapter()
|
|
422
659
|
const useThreads = createAdapterThreadsStore<AgentPart>(memory)
|
package/src/agent/adapter.ts
CHANGED
|
@@ -168,7 +168,11 @@ type ThreadPatch<TPart> = {
|
|
|
168
168
|
* - **Roll back on rejection.** A rejected write's patch is discarded, the rejection is surfaced
|
|
169
169
|
* on `error`, and the store re-lists to converge on whatever the server really did. Patches are
|
|
170
170
|
* tracked individually, so a failing write rolls back only itself and not whatever else was in
|
|
171
|
-
* flight beside it.
|
|
171
|
+
* flight beside it. A rejected `create()` additionally drops every later single-thread write for
|
|
172
|
+
* that id (`markRead`/`appendMessage`/`setStatus`/… — both the ones chained directly behind it and
|
|
173
|
+
* the run's completion writes at stream end) instead of letting each fail independently against a
|
|
174
|
+
* row that no longer exists — see `failedCreateIds`. Without that, the create's own error is
|
|
175
|
+
* overwritten by a run of "unknown thread" rejections that name nothing a caller can act on.
|
|
172
176
|
* - **`hydrated` means "a `listThreads` has succeeded"**, not "a load has been attempted". A
|
|
173
177
|
* store that is `!hydrated` with a non-undefined `error` failed to load; check both.
|
|
174
178
|
* - **`error` latches the most recent failure until something disproves it.** A successful
|
|
@@ -412,6 +416,31 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
|
|
|
412
416
|
*/
|
|
413
417
|
const chains = new Map<string, Promise<void>>()
|
|
414
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Thread ids whose `createThread` has rejected and been rolled back. The send path issues
|
|
421
|
+
* `create()` → `markRead()` → `appendMessage()` → `setStatus()` in one synchronous block, all
|
|
422
|
+
* chained behind the same per-thread queue above — so when `createThread` rejects, the three
|
|
423
|
+
* dependent writes queued right behind it are guaranteed to reach the adapter next, each against
|
|
424
|
+
* a row that was never materialized. Left alone, each of those independently rejects with its own
|
|
425
|
+
* "unknown thread" error and overwrites the one that actually explains what happened (the
|
|
426
|
+
* create's), so the user ends up staring at the last cascade error instead of the root cause.
|
|
427
|
+
*
|
|
428
|
+
* Recording the id here lets `mutate` recognize a single-thread write against a rolled-back
|
|
429
|
+
* create and drop it silently instead of running a doomed commit — see `mutate`'s `isCreate`
|
|
430
|
+
* guard.
|
|
431
|
+
*
|
|
432
|
+
* Entries are NEVER removed, deliberately. The cascade is not confined to the synchronous block:
|
|
433
|
+
* `useAgentThreadRuns`' COMPLETION path (`appendMessage(assistant)` → `setOutcome` → `setStatus`
|
|
434
|
+
* → `setResumeToken`) fires when the stream ends, long after this thread's write chain has
|
|
435
|
+
* drained — same rolled-back row, same "unknown thread" rejections, and landing at exactly the
|
|
436
|
+
* moment the user is watching the thread. Clearing the entry when the chain drains would fix the
|
|
437
|
+
* first few hundred milliseconds and hand the cascade straight back at stream end. Retaining it
|
|
438
|
+
* is safe and bounded: `create()` always mints its own id (`mintThreadId`, never caller-supplied),
|
|
439
|
+
* so an id in here can never be re-created and the set only grows by one per FAILED create in a
|
|
440
|
+
* session.
|
|
441
|
+
*/
|
|
442
|
+
const failedCreateIds = new Set<string>()
|
|
443
|
+
|
|
415
444
|
/** `task` must never reject, so a failed write cannot poison the chain behind it. */
|
|
416
445
|
function enqueue(threadIds: readonly string[], task: () => Promise<void>): void {
|
|
417
446
|
const ids = [...new Set(threadIds)]
|
|
@@ -421,6 +450,7 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
|
|
|
421
450
|
for (const id of ids) chains.set(id, run)
|
|
422
451
|
void run.finally(() => {
|
|
423
452
|
// Only the tail clears its own entry, so a later write chained behind it is not orphaned.
|
|
453
|
+
// `failedCreateIds` is deliberately NOT cleared here — see its doc.
|
|
424
454
|
for (const id of ids) if (chains.get(id) === run) chains.delete(id)
|
|
425
455
|
})
|
|
426
456
|
}
|
|
@@ -438,6 +468,12 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
|
|
|
438
468
|
readonly apply: ThreadPatch<TPart>['apply']
|
|
439
469
|
readonly applyActive?: (activeId: string | null) => string | null
|
|
440
470
|
readonly commit: () => Promise<void>
|
|
471
|
+
/**
|
|
472
|
+
* True only for `create()`'s own write. On rejection, marks its thread id as failed so the
|
|
473
|
+
* dependent writes chained behind it in the same synchronous send-path block recognize the row
|
|
474
|
+
* was rolled back — see `failedCreateIds`.
|
|
475
|
+
*/
|
|
476
|
+
readonly isCreate?: boolean
|
|
441
477
|
}): void {
|
|
442
478
|
const patch: ThreadPatch<TPart> = {
|
|
443
479
|
apply: memoizeApply(i.apply),
|
|
@@ -448,9 +484,30 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
|
|
|
448
484
|
const tokenAtIssue = errorToken
|
|
449
485
|
recompute()
|
|
450
486
|
enqueue(i.threadIds, async (): Promise<void> => {
|
|
487
|
+
// A single-thread write against a create that already rolled back cannot succeed — the row
|
|
488
|
+
// was never materialized, and running it only manufactures a second "unknown thread" error
|
|
489
|
+
// that would bury the create's own. This covers both the writes chained directly behind the
|
|
490
|
+
// create and the run's later completion writes (see `failedCreateIds`). Scoped to
|
|
491
|
+
// single-thread writes deliberately:
|
|
492
|
+
// `clear()`'s multi-id commit already has its own per-id fan-out (`Promise.allSettled`) and
|
|
493
|
+
// `removeThread` is contractually a no-op on an unknown id anyway, so folding this check into
|
|
494
|
+
// a multi-id write would risk dropping legitimate deletes for OTHER, healthy threads in the
|
|
495
|
+
// same batch just because one id in it happened to be orphaned.
|
|
496
|
+
const [soleThreadId] = i.threadIds
|
|
497
|
+
if (
|
|
498
|
+
!i.isCreate &&
|
|
499
|
+
i.threadIds.length === 1 &&
|
|
500
|
+
soleThreadId !== undefined &&
|
|
501
|
+
failedCreateIds.has(soleThreadId)
|
|
502
|
+
) {
|
|
503
|
+
drop(patch)
|
|
504
|
+
recompute()
|
|
505
|
+
return
|
|
506
|
+
}
|
|
451
507
|
try {
|
|
452
508
|
await i.commit()
|
|
453
509
|
} catch (cause) {
|
|
510
|
+
if (i.isCreate) for (const threadId of i.threadIds) failedCreateIds.add(threadId)
|
|
454
511
|
// Roll back: discard THIS patch only, leaving any other in-flight patch applied.
|
|
455
512
|
drop(patch)
|
|
456
513
|
error = cause
|
|
@@ -540,6 +597,7 @@ export function createAdapterThreadsStore<TPart = AgentPart>(
|
|
|
540
597
|
mutate({
|
|
541
598
|
threadIds: [id],
|
|
542
599
|
apply: (threads) => (threads.some((t) => t.id === id) ? [...threads] : [thread, ...threads]),
|
|
600
|
+
isCreate: true,
|
|
543
601
|
commit: () =>
|
|
544
602
|
adapter.createThread({
|
|
545
603
|
id,
|
|
@@ -738,7 +796,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
738
796
|
|
|
739
797
|
return [
|
|
740
798
|
define('createThread then listThreads returns the thread with empty defaults', async (a) => {
|
|
741
|
-
const id =
|
|
799
|
+
const id = mintThreadId()
|
|
742
800
|
await a.createThread({ id })
|
|
743
801
|
const threads = await a.listThreads()
|
|
744
802
|
const found = threads.filter((t) => t.id === id)
|
|
@@ -754,19 +812,19 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
754
812
|
}),
|
|
755
813
|
|
|
756
814
|
define('createThread persists meta', async (a) => {
|
|
757
|
-
const id =
|
|
815
|
+
const id = mintThreadId()
|
|
758
816
|
await a.createThread({ id, meta: { source: 'contract' } })
|
|
759
817
|
const thread = await loadOrThrow(a, id)
|
|
760
818
|
assert(thread.meta?.['source'] === 'contract', 'meta did not round-trip')
|
|
761
819
|
}),
|
|
762
820
|
|
|
763
821
|
define('loadThread returns null for an unknown id', async (a) => {
|
|
764
|
-
const thread = await a.loadThread(
|
|
822
|
+
const thread = await a.loadThread(mintThreadId())
|
|
765
823
|
assert(thread === null, 'loadThread must return null (not throw, not undefined) when absent')
|
|
766
824
|
}),
|
|
767
825
|
|
|
768
826
|
define('appendMessage is idempotent on message.id', async (a) => {
|
|
769
|
-
const id =
|
|
827
|
+
const id = mintThreadId()
|
|
770
828
|
await a.createThread({ id })
|
|
771
829
|
const msg = message<TPart>('contract-msg-1')
|
|
772
830
|
await a.appendMessage({ threadId: id, message: msg })
|
|
@@ -780,7 +838,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
780
838
|
}),
|
|
781
839
|
|
|
782
840
|
define('appendMessage preserves order for distinct ids', async (a) => {
|
|
783
|
-
const id =
|
|
841
|
+
const id = mintThreadId()
|
|
784
842
|
await a.createThread({ id })
|
|
785
843
|
await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-1') })
|
|
786
844
|
await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-2') })
|
|
@@ -792,7 +850,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
792
850
|
}),
|
|
793
851
|
|
|
794
852
|
define('setStatus round-trips', async (a) => {
|
|
795
|
-
const id =
|
|
853
|
+
const id = mintThreadId()
|
|
796
854
|
await a.createThread({ id })
|
|
797
855
|
await a.setStatus({ threadId: id, status: 'streaming' })
|
|
798
856
|
assert((await loadOrThrow(a, id)).status === 'streaming', "status did not become 'streaming'")
|
|
@@ -801,7 +859,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
801
859
|
}),
|
|
802
860
|
|
|
803
861
|
define('setOutcome round-trips', async (a) => {
|
|
804
|
-
const id =
|
|
862
|
+
const id = mintThreadId()
|
|
805
863
|
await a.createThread({ id })
|
|
806
864
|
const outcome: AgentOutcome = { title: 'Title', summary: 'Summary', status: 'done' }
|
|
807
865
|
await a.setOutcome({ threadId: id, outcome })
|
|
@@ -812,7 +870,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
812
870
|
}),
|
|
813
871
|
|
|
814
872
|
define('setResumeToken round-trips and clears with undefined', async (a) => {
|
|
815
|
-
const id =
|
|
873
|
+
const id = mintThreadId()
|
|
816
874
|
await a.createThread({ id })
|
|
817
875
|
await a.setResumeToken({ threadId: id, token: 'tok-1' })
|
|
818
876
|
assert((await loadOrThrow(a, id)).resumeToken === 'tok-1', 'resumeToken did not round-trip')
|
|
@@ -824,7 +882,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
824
882
|
}),
|
|
825
883
|
|
|
826
884
|
define('markRead marks the thread read', async (a) => {
|
|
827
|
-
const id =
|
|
885
|
+
const id = mintThreadId()
|
|
828
886
|
await a.createThread({ id })
|
|
829
887
|
assert((await loadOrThrow(a, id)).read === false, 'a new thread must start unread')
|
|
830
888
|
await a.markRead(id)
|
|
@@ -835,7 +893,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
835
893
|
// The store keeps a write's optimistic patch applied until a listThreads STARTED after that
|
|
836
894
|
// write resolved lands in the confirmed base. An eventually-consistent list (a stale read
|
|
837
895
|
// replica, a cached response) breaks that and flashes the pre-write state back into the UI.
|
|
838
|
-
const id =
|
|
896
|
+
const id = mintThreadId()
|
|
839
897
|
await a.createThread({ id })
|
|
840
898
|
await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-raw') })
|
|
841
899
|
await a.setStatus({ threadId: id, status: 'streaming' })
|
|
@@ -856,7 +914,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
856
914
|
async (a) => {
|
|
857
915
|
// The store serializes writes per thread id, so this exact sequence — the send path's
|
|
858
916
|
// create-then-first-message — is what a real adapter receives. Each write must find the row.
|
|
859
|
-
const id =
|
|
917
|
+
const id = mintThreadId()
|
|
860
918
|
await a.createThread({ id })
|
|
861
919
|
await a.markRead(id)
|
|
862
920
|
await a.appendMessage({ threadId: id, message: message<TPart>('contract-msg-dep') })
|
|
@@ -872,7 +930,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
872
930
|
),
|
|
873
931
|
|
|
874
932
|
define('removeThread removes the thread', async (a) => {
|
|
875
|
-
const id =
|
|
933
|
+
const id = mintThreadId()
|
|
876
934
|
await a.createThread({ id })
|
|
877
935
|
await a.removeThread(id)
|
|
878
936
|
assert(await a.loadThread(id).then((t) => t === null), 'loadThread must be null after remove')
|
|
@@ -881,7 +939,7 @@ export function threadsStoreAdapterContract<TPart>(
|
|
|
881
939
|
}),
|
|
882
940
|
|
|
883
941
|
define('removeThread of an unknown id does not throw', async (a) => {
|
|
884
|
-
await a.removeThread(
|
|
942
|
+
await a.removeThread(mintThreadId())
|
|
885
943
|
}),
|
|
886
944
|
]
|
|
887
945
|
}
|
|
@@ -592,3 +592,67 @@ describe('aiSdkTransport — deterministic ids and replay idempotency', () => {
|
|
|
592
592
|
)
|
|
593
593
|
})
|
|
594
594
|
})
|
|
595
|
+
|
|
596
|
+
describe('aiSdkTransport — id minting under degraded crypto', () => {
|
|
597
|
+
test('the fixed chatId (mintThreadId, low collision cost) mints fine with no usable crypto at all — construction never throws', () => {
|
|
598
|
+
const originalCrypto = globalThis.crypto
|
|
599
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
600
|
+
try {
|
|
601
|
+
expect(() =>
|
|
602
|
+
aiSdkTransport({
|
|
603
|
+
api: '/api/chat',
|
|
604
|
+
fetch: mockFetch(() => {
|
|
605
|
+
throw new Error('must not be called')
|
|
606
|
+
}),
|
|
607
|
+
}),
|
|
608
|
+
).not.toThrow()
|
|
609
|
+
} finally {
|
|
610
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
test('the outbound user message id (mintMessageId, an idempotency key on the AI SDK backend) works via getRandomValues when randomUUID is unavailable', async () => {
|
|
615
|
+
const chunks: UIMessageChunk[] = [
|
|
616
|
+
{ type: 'text-start', id: 't1' },
|
|
617
|
+
{ type: 'text-end', id: 't1' },
|
|
618
|
+
]
|
|
619
|
+
const transport = aiSdkTransport({
|
|
620
|
+
api: '/api/chat',
|
|
621
|
+
fetch: mockFetch(async () => scriptedResponse(chunks)),
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
const originalCrypto = globalThis.crypto
|
|
625
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
626
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
627
|
+
configurable: true,
|
|
628
|
+
})
|
|
629
|
+
try {
|
|
630
|
+
const parts = await collect(transport.stream('hi'))
|
|
631
|
+
expect(parts[0]?.type).toBe('start')
|
|
632
|
+
} finally {
|
|
633
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test('the outbound user message id THROWS on a host with no usable crypto at all, rather than silently colliding', async () => {
|
|
638
|
+
const transport = aiSdkTransport({
|
|
639
|
+
api: '/api/chat',
|
|
640
|
+
fetch: mockFetch(() => {
|
|
641
|
+
throw new Error('must not be called — must throw before any network call')
|
|
642
|
+
}),
|
|
643
|
+
})
|
|
644
|
+
const gen = transport.stream('hi')
|
|
645
|
+
|
|
646
|
+
// The synthesized StartPart is yielded first, before mintMessageId is ever reached.
|
|
647
|
+
const start = await gen.next()
|
|
648
|
+
expect(start.value).toMatchObject({ type: 'start' })
|
|
649
|
+
|
|
650
|
+
const originalCrypto = globalThis.crypto
|
|
651
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
652
|
+
try {
|
|
653
|
+
await expect(gen.next()).rejects.toThrow(/idempotency key/)
|
|
654
|
+
} finally {
|
|
655
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
})
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* })
|
|
49
49
|
*/
|
|
50
50
|
import { assertNever } from '../register'
|
|
51
|
+
import { mintMessageId, mintThreadId } from './id'
|
|
51
52
|
import { TERMINAL_TOOL_STATES } from './parts'
|
|
52
53
|
import type { AgentPart, AgentPartDraft } from './parts'
|
|
53
54
|
import type { ResumableAgentTransport } from './transport'
|
|
@@ -401,7 +402,10 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
401
402
|
|
|
402
403
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk()
|
|
403
404
|
const userMessage: UIMessage = {
|
|
404
|
-
id
|
|
405
|
+
// This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same
|
|
406
|
+
// idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so
|
|
407
|
+
// mintMessageId, not mintThreadId, is the right helper here.
|
|
408
|
+
id: mintMessageId(),
|
|
405
409
|
role: 'user',
|
|
406
410
|
parts: [{ type: 'text', text: input }],
|
|
407
411
|
}
|
|
@@ -425,7 +429,9 @@ export function aiSdkTransport<TPart = AgentPart>(
|
|
|
425
429
|
}
|
|
426
430
|
}
|
|
427
431
|
|
|
428
|
-
|
|
432
|
+
// Client-side conversation namespace, not an idempotency key — a collision here would merge two
|
|
433
|
+
// conversations' index-addressed part ids, the same low collision cost `mintThreadId` covers.
|
|
434
|
+
const fixedChatId = mintThreadId()
|
|
429
435
|
return {
|
|
430
436
|
...makeTransport(fixedChatId),
|
|
431
437
|
forThread: (chatId: string) => makeTransport(chatId),
|