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/src/agent/id.test.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* withPartIds — stamps `${runId}#${n}` onto any draft part arriving without an id; idempotent for
|
|
3
3
|
* drafts that already have one (untouched, and doesn't advance the counter).
|
|
4
|
+
*
|
|
5
|
+
* mintThreadId/mintMessageId — the two id-minting helpers behind every unguarded
|
|
6
|
+
* `crypto.randomUUID()` call site this file's siblings used to have. Both share rungs 1
|
|
7
|
+
* (`crypto.randomUUID`) and 2 (`crypto.getRandomValues`, hand-assembled into a UUIDv4); they
|
|
8
|
+
* deliberately diverge on rung 3 (no crypto at all) — `mintThreadId` degrades, `mintMessageId`
|
|
9
|
+
* throws. See each function's own doc for why.
|
|
4
10
|
*/
|
|
5
11
|
import { describe, expect, test } from 'bun:test'
|
|
6
|
-
import { withPartIds } from './id'
|
|
12
|
+
import { mintMessageId, mintThreadId, withPartIds } from './id'
|
|
7
13
|
import type { AgentPartDraft } from './parts'
|
|
8
14
|
|
|
9
15
|
async function* gen<T>(values: T[]): AsyncGenerator<T> {
|
|
@@ -16,6 +22,33 @@ async function collect<T>(source: AsyncGenerator<T>): Promise<T[]> {
|
|
|
16
22
|
return out
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
26
|
+
|
|
27
|
+
/** Rung 2: randomUUID absent, getRandomValues present (a real, non-secure-context host). */
|
|
28
|
+
function withRung2Crypto<T>(run: () => T): T {
|
|
29
|
+
const originalCrypto = globalThis.crypto
|
|
30
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
31
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
32
|
+
configurable: true,
|
|
33
|
+
})
|
|
34
|
+
try {
|
|
35
|
+
return run()
|
|
36
|
+
} finally {
|
|
37
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Rung 3: no usable crypto at all. */
|
|
42
|
+
function withRung3Crypto<T>(run: () => T): T {
|
|
43
|
+
const originalCrypto = globalThis.crypto
|
|
44
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
45
|
+
try {
|
|
46
|
+
return run()
|
|
47
|
+
} finally {
|
|
48
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
19
52
|
describe('withPartIds', () => {
|
|
20
53
|
test('stamps a sequential id on every draft missing one', async () => {
|
|
21
54
|
// Annotated: all three literals uniformly omit `id`, so without this TS infers an `id`-less
|
|
@@ -73,3 +106,64 @@ describe('withPartIds', () => {
|
|
|
73
106
|
])
|
|
74
107
|
})
|
|
75
108
|
})
|
|
109
|
+
|
|
110
|
+
describe('mintThreadId', () => {
|
|
111
|
+
test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
|
|
112
|
+
const id = mintThreadId()
|
|
113
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('rung 1: many mints are distinct', () => {
|
|
117
|
+
const ids = Array.from({ length: 1000 }, () => mintThreadId())
|
|
118
|
+
expect(new Set(ids).size).toBe(1000)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
|
|
122
|
+
const id = withRung2Crypto(() => mintThreadId())
|
|
123
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('rung 2: many mints are distinct', () => {
|
|
127
|
+
const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintThreadId()))
|
|
128
|
+
expect(new Set(ids).size).toBe(1000)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('rung 3 (no usable crypto at all): does NOT throw, still returns distinct non-empty strings', () => {
|
|
132
|
+
const ids = withRung3Crypto(() => Array.from({ length: 50 }, () => mintThreadId()))
|
|
133
|
+
for (const id of ids) {
|
|
134
|
+
expect(typeof id).toBe('string')
|
|
135
|
+
expect(id.length).toBeGreaterThan(0)
|
|
136
|
+
}
|
|
137
|
+
// Weak (Date.now() + Math.random()), not cryptographically random — but still distinct across
|
|
138
|
+
// this many mints in one tick, since Math.random() varies even when Date.now() doesn't.
|
|
139
|
+
expect(new Set(ids).size).toBe(50)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('mintMessageId', () => {
|
|
144
|
+
test('rung 1 (crypto.randomUUID): returns a valid UUIDv4 string', () => {
|
|
145
|
+
const id = mintMessageId()
|
|
146
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('rung 1: many mints are distinct', () => {
|
|
150
|
+
const ids = Array.from({ length: 1000 }, () => mintMessageId())
|
|
151
|
+
expect(new Set(ids).size).toBe(1000)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('rung 2 (getRandomValues only): assembles a valid UUIDv4 — version/variant bits set', () => {
|
|
155
|
+
const id = withRung2Crypto(() => mintMessageId())
|
|
156
|
+
expect(id).toMatch(UUID_V4_RE)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('rung 2: many mints are distinct', () => {
|
|
160
|
+
const ids = withRung2Crypto(() => Array.from({ length: 1000 }, () => mintMessageId()))
|
|
161
|
+
expect(new Set(ids).size).toBe(1000)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('rung 3 (no usable crypto at all): THROWS rather than minting a non-random id', () => {
|
|
165
|
+
// Diverges from mintThreadId's rung 3 on purpose — appendMessage's idempotency key must never
|
|
166
|
+
// silently degrade to a non-collision-resistant id. See mintMessageId's own doc.
|
|
167
|
+
expect(() => withRung3Crypto(() => mintMessageId())).toThrow(/idempotency key/)
|
|
168
|
+
})
|
|
169
|
+
})
|
package/src/agent/id.ts
CHANGED
|
@@ -1,51 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Id-minting helpers shared
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Id-minting helpers shared across the agent layer: `mintThreadId` (a low-stakes client-side id —
|
|
3
|
+
* threads, run/chat namespaces — used by `./thread`'s `createThreadsStore`, `./adapter`'s
|
|
4
|
+
* `createAdapterThreadsStore`/`threadsStoreAdapterContract`, `./use-agent-stream`'s per-send run
|
|
5
|
+
* id, and `aiSdkTransport`'s fixed chat id), `mintMessageId` (the higher-stakes id `appendMessage`
|
|
6
|
+
* treats as its only idempotency key), and `withPartIds` (stamps sequence ids onto streamed
|
|
7
|
+
* parts). Grouped here because they're the id-minting leaves every store/hook/transport in this
|
|
8
|
+
* layer sits above — not because they share a mechanism (`mintMessageId` deliberately diverges
|
|
9
|
+
* from `mintThreadId` on rung 3; see its own doc).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
* Hand-assembles a UUIDv4 string from 16 random bytes — the shared rung-2 mechanism both
|
|
14
|
+
* `mintThreadId` and `mintMessageId` use once `crypto.randomUUID` is unavailable but
|
|
15
|
+
* `crypto.getRandomValues` still is. The version/variant bits are set per RFC 4122, since this
|
|
16
|
+
* rung hands back raw bytes, not a formatted UUID.
|
|
17
|
+
*/
|
|
18
|
+
function assembleUuidV4(bytes: Uint8Array): string {
|
|
19
|
+
const hex = Array.from({ length: 16 }, (_, i) => {
|
|
20
|
+
const raw = bytes.at(i) ?? 0
|
|
21
|
+
// Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
|
|
22
|
+
// clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
|
|
23
|
+
const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
|
|
24
|
+
return byte.toString(16).padStart(2, '0')
|
|
25
|
+
}).join('')
|
|
26
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mints a client-side id for a LOW-COLLISION-COST use: a thread id, a per-send run-id namespace, a
|
|
31
|
+
* fixed chat id — anything minted client-side that is NOT the idempotency key some write depends
|
|
32
|
+
* on. `crypto.randomUUID()` exists only in SECURE CONTEXTS (HTTPS or localhost) — a consumer served
|
|
33
|
+
* over plain HTTP on a LAN hostname (a homelab dashboard on a bare IP, a staging box) would
|
|
34
|
+
* otherwise have `create()`, about the most basic user action a thread store has, throw a plain
|
|
35
|
+
* TypeError. A store used from a browser must not throw on a user gesture — the same principle
|
|
36
|
+
* already applied twice this release (`spliceText` clamps rather than throws, `coalesceParts`
|
|
37
|
+
* degrades rather than throws).
|
|
16
38
|
*
|
|
17
39
|
* Fallback chain, each rung reached only when the one above it is unavailable:
|
|
18
40
|
* 1. `crypto.randomUUID()` — cryptographically random, RFC 4122 UUID. The normal path.
|
|
19
41
|
* 2. `crypto.getRandomValues()` — still cryptographically random; hand-assembled into a UUIDv4
|
|
20
|
-
*
|
|
21
|
-
* formatted UUID).
|
|
42
|
+
* via `assembleUuidV4`.
|
|
22
43
|
* 3. No usable `crypto` at all (missing entirely, or missing both methods above — an old WebView,
|
|
23
44
|
* an SSR shim). NOT cryptographically random and NOT collision-resistant in general. This rung
|
|
24
|
-
* exists solely so
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* not this
|
|
28
|
-
*
|
|
29
|
-
* collision cost than that
|
|
45
|
+
* exists solely so a caller never throws on such a host. It is acceptable ONLY because an id
|
|
46
|
+
* minted here is minted a handful of times per client session and is not the idempotency key
|
|
47
|
+
* any write depends on — `ThreadsStoreAdapter.appendMessage`'s idempotency key is the MESSAGE
|
|
48
|
+
* id (see `mintMessageId`), not this one, so a collision on this rung costs a locally
|
|
49
|
+
* merged/duplicated thread (or two runs sharing a part-id namespace), not silent data loss on
|
|
50
|
+
* the append path. Do not reuse this rung for anything with a higher collision cost than that
|
|
51
|
+
* — see `mintMessageId` for the id that needs one.
|
|
30
52
|
*/
|
|
31
53
|
export function mintThreadId(): string {
|
|
32
54
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
33
55
|
return crypto.randomUUID()
|
|
34
56
|
}
|
|
35
57
|
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
36
|
-
|
|
37
|
-
const hex = Array.from({ length: 16 }, (_, i) => {
|
|
38
|
-
const raw = bytes.at(i) ?? 0
|
|
39
|
-
// Version 4 (bits 12-15 of time_hi_and_version) and variant 10 (bits 6-7 of
|
|
40
|
-
// clock_seq_hi_and_reserved) — the only structure a UUIDv4 promises over raw random bytes.
|
|
41
|
-
const byte = i === 6 ? (raw & 0x0f) | 0x40 : i === 8 ? (raw & 0x3f) | 0x80 : raw
|
|
42
|
-
return byte.toString(16).padStart(2, '0')
|
|
43
|
-
}).join('')
|
|
44
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
58
|
+
return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
|
|
45
59
|
}
|
|
46
60
|
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
47
61
|
}
|
|
48
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Mints a message id — the value `ThreadsStoreAdapter.appendMessage` treats as its ONLY
|
|
65
|
+
* idempotency key (see that method's contract doc in `./adapter`). Shares `mintThreadId`'s rungs
|
|
66
|
+
* 1-2 (`crypto.randomUUID()`, then `crypto.getRandomValues()` assembled into a UUIDv4) — both are
|
|
67
|
+
* cryptographically random and collision-resistant, and rung 2 is the fix that actually matters in
|
|
68
|
+
* practice (a plain-HTTP/LAN host still has `getRandomValues`; only `randomUUID` is secure-context-
|
|
69
|
+
* gated).
|
|
70
|
+
*
|
|
71
|
+
* Rung 3 deliberately DIVERGES from `mintThreadId`: it THROWS instead of degrading to a
|
|
72
|
+
* non-random fallback. A thread id's rung-3 collision costs a locally merged/duplicated thread —
|
|
73
|
+
* annoying, recoverable, visible. A message id's collision cost is different IN KIND, not degree:
|
|
74
|
+
* `appendMessage` is idempotent on this id, so two messages that collide are not two rows, they
|
|
75
|
+
* are ONE — the second write silently no-ops and its content is gone, with nothing downstream able
|
|
76
|
+
* to tell. Degrading here would trade a loud, debuggable failure for silent, permanent data loss.
|
|
77
|
+
*
|
|
78
|
+
* This deliberately diverges from this layer's other standing rule — render-path code degrades,
|
|
79
|
+
* never throws (`spliceText` clamps, `coalesceParts` degrades, a consumer fence renderer must not
|
|
80
|
+
* take the message down) — because minting a message id is NOT render-path code. It runs once, in
|
|
81
|
+
* the write path that constructs a message before handing it to a store (`start()`,
|
|
82
|
+
* `consumeAndFinalize`'s finalize step, `finalizeStop`, `aiSdkTransport`'s per-turn user message),
|
|
83
|
+
* never inside the per-chunk accumulation/render loop those functions guard. A host with no usable
|
|
84
|
+
* `crypto` at all (missing both `randomUUID` and `getRandomValues`) is vanishingly rare — no
|
|
85
|
+
* runtime with a DOM/fetch surface worth targeting ships without a Crypto object — and an uncaught
|
|
86
|
+
* throw here is a loud, debuggable failure rather than a message silently vanishing with no trace,
|
|
87
|
+
* but WHERE it surfaces differs by call site (audited, not assumed — this paragraph previously
|
|
88
|
+
* claimed a single shared catch handled all of them, which was false):
|
|
89
|
+
* - `consumeAndFinalize`'s own assistantMessage mint runs inside that function's try/catch, so a
|
|
90
|
+
* throw there is correctly turned into `onFailureStatus` ('error' from `start()`, 'interrupted'
|
|
91
|
+
* from the mount-time resume) — a visibly stuck-then-settled run, as originally claimed.
|
|
92
|
+
* - `useAgentThreadRuns.start()`'s userMessage mint runs BEFORE any state is touched
|
|
93
|
+
* (`appendMessage`/`setStatus`/the controller registration all come after it), so a throw there
|
|
94
|
+
* is a true no-op that propagates synchronously out of `start()` itself — not a stuck run at
|
|
95
|
+
* all, since nothing ever started. See `UseAgentThreadRunsReturn.start`'s `@throws` doc.
|
|
96
|
+
* - `finalizeStop`'s stoppedMessage mint is wrapped in its own try/catch precisely so a throw
|
|
97
|
+
* here cannot wedge the thread at 'streaming' forever (stop() has already torn down this run's
|
|
98
|
+
* bookkeeping by the time finalizeStop runs, so a second stop() is always a no-op) — see
|
|
99
|
+
* `finalizeStop`'s doc for the guarded behavior and the resulting 'error' status.
|
|
100
|
+
* A stuck spinner is debuggable; silently discarded chat history is not — that half of the
|
|
101
|
+
* original claim stands regardless of which of the three paths above catches it.
|
|
102
|
+
*
|
|
103
|
+
* @throws {Error} when neither `crypto.randomUUID` nor `crypto.getRandomValues` is available.
|
|
104
|
+
*/
|
|
105
|
+
export function mintMessageId(): string {
|
|
106
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
107
|
+
return crypto.randomUUID()
|
|
108
|
+
}
|
|
109
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
110
|
+
return assembleUuidV4(crypto.getRandomValues(new Uint8Array(16)))
|
|
111
|
+
}
|
|
112
|
+
throw new Error(
|
|
113
|
+
'mintMessageId: no usable crypto (both randomUUID and getRandomValues are unavailable) — ' +
|
|
114
|
+
'refusing to mint a non-random message id, since ThreadsStoreAdapter.appendMessage treats ' +
|
|
115
|
+
'this id as its only idempotency key and a collision would silently drop a message.',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
49
119
|
/**
|
|
50
120
|
* withPartIds — stamps a stable id onto every draft part arriving without one.
|
|
51
121
|
*
|
package/src/agent/thread.ts
CHANGED
|
@@ -145,6 +145,10 @@ export type ThreadsStore<TPart = AgentPart> = {
|
|
|
145
145
|
* store built with `createAdapterThreadsStore` (./adapter), where it stays `false` until the
|
|
146
146
|
* first `listThreads` SUCCEEDS. Pair it with `error` — `!hydrated && error !== undefined` is a
|
|
147
147
|
* failed load, `!hydrated && error === undefined` is still loading.
|
|
148
|
+
*
|
|
149
|
+
* Gating an empty state on this is the CALLER's job when hand-assembling `ThreadFeed` /
|
|
150
|
+
* `ThreadDetailPanel`; the shipped `ThreadWorkspace` already does it internally, so an
|
|
151
|
+
* async store never flashes "no threads yet" through it before it actually knows.
|
|
148
152
|
*/
|
|
149
153
|
readonly hydrated: boolean
|
|
150
154
|
/**
|
|
@@ -262,4 +262,26 @@ describe('useAgentStream', () => {
|
|
|
262
262
|
// Only the SECOND (surviving) call's part made it into final state — the first is discarded.
|
|
263
263
|
expect(capturedParts).toEqual([{ id: 'call-2', type: 'text', text: 'call-2' }])
|
|
264
264
|
})
|
|
265
|
+
|
|
266
|
+
test('send() still streams without throwing on a host with no usable crypto at all — its runId is a low-collision-cost mint (mintThreadId), never the message-id one', async () => {
|
|
267
|
+
const originalCrypto = globalThis.crypto
|
|
268
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
269
|
+
try {
|
|
270
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
271
|
+
async *stream() {
|
|
272
|
+
yield { type: 'text', text: 'a' } as AgentPartDraft as AgentPart
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
const { result } = renderHook(() => useAgentStream({ transport }))
|
|
276
|
+
|
|
277
|
+
await act(async () => {
|
|
278
|
+
await result.current.send('hi')
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
expect(result.current.status).toBe('done')
|
|
282
|
+
expect(result.current.parts).toHaveLength(1)
|
|
283
|
+
} finally {
|
|
284
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
285
|
+
}
|
|
286
|
+
})
|
|
265
287
|
})
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
30
|
-
import { withPartIds } from './id'
|
|
30
|
+
import { mintThreadId, withPartIds } from './id'
|
|
31
31
|
import { isStartPart } from './parts'
|
|
32
32
|
import { mergePart } from './merge'
|
|
33
33
|
import type { PartLike } from './merge'
|
|
@@ -107,8 +107,11 @@ export function useAgentStream<TPart extends PartLike = AgentPart, TInput = stri
|
|
|
107
107
|
// fully resets `parts` anyway, so a fresh id per call is enough to keep two id-less drafts
|
|
108
108
|
// ARRIVING WITHIN THE SAME SEND from colliding on `undefined === undefined` in mergePart (the
|
|
109
109
|
// F1 bug — text→tool→text destroying each other). Not a replay mechanism — this hook has no
|
|
110
|
-
// resume() path — see withPartIds' own doc for why it can't be one anyway.
|
|
111
|
-
|
|
110
|
+
// resume() path — see withPartIds' own doc for why it can't be one anyway. Low collision
|
|
111
|
+
// cost (this hook persists nothing — `parts` is discarded on the next send()/unmount), so
|
|
112
|
+
// `mintThreadId` — never `mintMessageId` — is the right rung-3 behavior here: this must not
|
|
113
|
+
// throw on a host with no usable crypto at all.
|
|
114
|
+
const runId = mintThreadId()
|
|
112
115
|
|
|
113
116
|
try {
|
|
114
117
|
for await (const part of withPartIds(runId, transport.stream(input, controller.signal))) {
|
|
@@ -792,3 +792,329 @@ describe('useAgentThreadRuns — F1 regression: id-less drafts within one run',
|
|
|
792
792
|
expect(assistantMessage?.parts.map((part) => part.type)).toEqual(['text', 'tool', 'text'])
|
|
793
793
|
})
|
|
794
794
|
})
|
|
795
|
+
|
|
796
|
+
describe('useAgentThreadRuns — message-id minting under degraded crypto', () => {
|
|
797
|
+
test('start() mints distinct message ids via crypto.getRandomValues when randomUUID is unavailable', async () => {
|
|
798
|
+
const store = createTestThreadsStore()
|
|
799
|
+
const threadA = store.create()
|
|
800
|
+
const threadB = store.create()
|
|
801
|
+
|
|
802
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
803
|
+
async *stream(input) {
|
|
804
|
+
yield { id: 'p1', type: 'text', text: `done:${input}` }
|
|
805
|
+
},
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const originalCrypto = globalThis.crypto
|
|
809
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
810
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
811
|
+
configurable: true,
|
|
812
|
+
})
|
|
813
|
+
try {
|
|
814
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
815
|
+
|
|
816
|
+
act(() => {
|
|
817
|
+
result.current.start(threadA, 'input-a')
|
|
818
|
+
result.current.start(threadB, 'input-b')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
await waitFor(() => {
|
|
822
|
+
expect(store.threads.find((t) => t.id === threadA)?.status).toBe('done')
|
|
823
|
+
expect(store.threads.find((t) => t.id === threadB)?.status).toBe('done')
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
const allMessageIds = store.threads.flatMap((t) => t.messages.map((m) => m.id))
|
|
827
|
+
expect(allMessageIds.length).toBeGreaterThan(0)
|
|
828
|
+
expect(new Set(allMessageIds).size).toBe(allMessageIds.length)
|
|
829
|
+
} finally {
|
|
830
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
831
|
+
}
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
test('start() THROWS on a host with no usable crypto at all, rather than silently minting a colliding message id', () => {
|
|
835
|
+
const store = createTestThreadsStore()
|
|
836
|
+
const threadId = store.create()
|
|
837
|
+
|
|
838
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
839
|
+
async *stream() {
|
|
840
|
+
yield { id: 'p1', type: 'text', text: 'unreachable' }
|
|
841
|
+
},
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
845
|
+
|
|
846
|
+
const originalCrypto = globalThis.crypto
|
|
847
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
848
|
+
try {
|
|
849
|
+
expect(() => {
|
|
850
|
+
act(() => {
|
|
851
|
+
result.current.start(threadId, 'hi')
|
|
852
|
+
})
|
|
853
|
+
}).toThrow(/idempotency key/)
|
|
854
|
+
} finally {
|
|
855
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The deliberate divergence documented on mintMessageId: throwing means the write never
|
|
859
|
+
// happened at all (no half-appended message, no orphaned run entry) rather than silently
|
|
860
|
+
// dropping content behind a colliding id.
|
|
861
|
+
expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(0)
|
|
862
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
863
|
+
})
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
// A ThreadsStore double that delegates everything to a real test store EXCEPT appendMessage,
|
|
867
|
+
// which throws for messages matching `shouldThrow` — lets a test simulate consumer-adapter code
|
|
868
|
+
// (appendMessage) throwing without touching the shared createTestThreadsStore factory used by
|
|
869
|
+
// every other test in this file.
|
|
870
|
+
function wrapStoreWithThrowingAppend(
|
|
871
|
+
store: ThreadsStore<AgentPart>,
|
|
872
|
+
shouldThrow: (message: AgentThread<AgentPart>['messages'][number]) => boolean,
|
|
873
|
+
): ThreadsStore<AgentPart> {
|
|
874
|
+
return {
|
|
875
|
+
get threads() {
|
|
876
|
+
return store.threads
|
|
877
|
+
},
|
|
878
|
+
get activeId() {
|
|
879
|
+
return store.activeId
|
|
880
|
+
},
|
|
881
|
+
select: store.select,
|
|
882
|
+
create: store.create,
|
|
883
|
+
appendMessage(id, message) {
|
|
884
|
+
if (shouldThrow(message)) {
|
|
885
|
+
throw new Error('consumer appendMessage boom')
|
|
886
|
+
}
|
|
887
|
+
store.appendMessage(id, message)
|
|
888
|
+
},
|
|
889
|
+
setOutcome: store.setOutcome,
|
|
890
|
+
setStatus: store.setStatus,
|
|
891
|
+
setResumeToken: store.setResumeToken,
|
|
892
|
+
markRead: store.markRead,
|
|
893
|
+
remove: store.remove,
|
|
894
|
+
clear: store.clear,
|
|
895
|
+
hydrated: store.hydrated,
|
|
896
|
+
error: store.error,
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** As above, but for the settle half of `finalizeStop` — a consumer store whose `setStatus` throws
|
|
901
|
+
* (a remote-backed adapter rejecting a thread removed mid-stream is the realistic shape). Narrowed
|
|
902
|
+
* by `shouldThrow` because the hook ALSO calls `setStatus` from its mount-time orphan sweep, which
|
|
903
|
+
* is a different call site with a different (non-wedge) failure mode. */
|
|
904
|
+
function wrapStoreWithThrowingSetStatus(
|
|
905
|
+
store: ThreadsStore<AgentPart>,
|
|
906
|
+
shouldThrow: (status: AgentThread<AgentPart>['status']) => boolean,
|
|
907
|
+
): ThreadsStore<AgentPart> {
|
|
908
|
+
return {
|
|
909
|
+
get threads() {
|
|
910
|
+
return store.threads
|
|
911
|
+
},
|
|
912
|
+
get activeId() {
|
|
913
|
+
return store.activeId
|
|
914
|
+
},
|
|
915
|
+
select: store.select,
|
|
916
|
+
create: store.create,
|
|
917
|
+
appendMessage: store.appendMessage,
|
|
918
|
+
setOutcome: store.setOutcome,
|
|
919
|
+
setStatus(id, status) {
|
|
920
|
+
if (shouldThrow(status)) throw new Error('consumer setStatus boom')
|
|
921
|
+
store.setStatus(id, status)
|
|
922
|
+
},
|
|
923
|
+
setResumeToken: store.setResumeToken,
|
|
924
|
+
markRead: store.markRead,
|
|
925
|
+
remove: store.remove,
|
|
926
|
+
clear: store.clear,
|
|
927
|
+
hydrated: store.hydrated,
|
|
928
|
+
error: store.error,
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
describe('useAgentThreadRuns — finalizeStop must reach a terminal state no matter what', () => {
|
|
933
|
+
test('stop() on a host with no usable crypto still reaches a terminal status and clears the run entry (was: wedged at "streaming" forever)', async () => {
|
|
934
|
+
const store = createTestThreadsStore()
|
|
935
|
+
const threadId = store.create()
|
|
936
|
+
|
|
937
|
+
const gate = deferred<void>()
|
|
938
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
939
|
+
async *stream() {
|
|
940
|
+
yield { id: 'p1', type: 'text', text: 'partial' }
|
|
941
|
+
await gate.promise
|
|
942
|
+
yield { id: 'p2', type: 'text', text: 'never arrives' }
|
|
943
|
+
},
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
947
|
+
|
|
948
|
+
act(() => {
|
|
949
|
+
result.current.start(threadId, 'hi')
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
await waitFor(() => {
|
|
953
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
const originalCrypto = globalThis.crypto
|
|
957
|
+
Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
|
|
958
|
+
try {
|
|
959
|
+
act(() => {
|
|
960
|
+
result.current.stop(threadId)
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
964
|
+
// Before the fix: mintMessageId's throw escaped finalizeStop entirely, so setStatus/setRuns
|
|
965
|
+
// never ran and this stayed 'streaming' forever.
|
|
966
|
+
expect(thread?.status).toBe('error')
|
|
967
|
+
// The stopped message never minted an id, so appendMessage was never even attempted —
|
|
968
|
+
// only the original user message is present.
|
|
969
|
+
expect(thread?.messages).toHaveLength(1)
|
|
970
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
971
|
+
|
|
972
|
+
// A second stop() must not be the user's only recourse — and it isn't: controllersRef
|
|
973
|
+
// already had this threadId deleted before finalizeStop ever ran, so this is an ordinary
|
|
974
|
+
// no-op, not a retry of the failed append.
|
|
975
|
+
act(() => {
|
|
976
|
+
result.current.stop(threadId)
|
|
977
|
+
})
|
|
978
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
|
|
979
|
+
} finally {
|
|
980
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
gate.resolve(undefined)
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
test("stop() reaches a terminal status and clears the run entry even when the consumer's appendMessage throws (was: wedged at 'streaming' forever)", async () => {
|
|
987
|
+
const baseStore = createTestThreadsStore()
|
|
988
|
+
const threadId = baseStore.create()
|
|
989
|
+
const store = wrapStoreWithThrowingAppend(
|
|
990
|
+
baseStore,
|
|
991
|
+
(message) => message.role === 'assistant' && message.finish === 'stopped',
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
const gate = deferred<void>()
|
|
995
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
996
|
+
async *stream() {
|
|
997
|
+
yield { id: 'p1', type: 'text', text: 'partial' }
|
|
998
|
+
await gate.promise
|
|
999
|
+
yield { id: 'p2', type: 'text', text: 'never arrives' }
|
|
1000
|
+
},
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
1004
|
+
|
|
1005
|
+
act(() => {
|
|
1006
|
+
result.current.start(threadId, 'hi')
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
await waitFor(() => {
|
|
1010
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
act(() => {
|
|
1014
|
+
result.current.stop(threadId)
|
|
1015
|
+
})
|
|
1016
|
+
|
|
1017
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
1018
|
+
expect(thread?.status).toBe('error')
|
|
1019
|
+
// Only the user message — the consumer's appendMessage rejected the stopped message.
|
|
1020
|
+
expect(thread?.messages).toHaveLength(1)
|
|
1021
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
1022
|
+
|
|
1023
|
+
// A second stop() remains a true no-op — not the user's only recourse.
|
|
1024
|
+
act(() => {
|
|
1025
|
+
result.current.stop(threadId)
|
|
1026
|
+
})
|
|
1027
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('error')
|
|
1028
|
+
|
|
1029
|
+
gate.resolve(undefined)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
test("stop() clears the hook's own run entry even when the consumer's setStatus throws (the second half of the same wedge)", async () => {
|
|
1033
|
+
const baseStore = createTestThreadsStore()
|
|
1034
|
+
const threadId = baseStore.create()
|
|
1035
|
+
const store = wrapStoreWithThrowingSetStatus(baseStore, (status) => status === 'done')
|
|
1036
|
+
|
|
1037
|
+
const gate = deferred<void>()
|
|
1038
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
1039
|
+
async *stream() {
|
|
1040
|
+
yield { id: 'p1', type: 'text', text: 'partial' }
|
|
1041
|
+
await gate.promise
|
|
1042
|
+
yield { id: 'p2', type: 'text', text: 'never arrives' }
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
1047
|
+
|
|
1048
|
+
act(() => {
|
|
1049
|
+
result.current.start(threadId, 'hi')
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
await waitFor(() => {
|
|
1053
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
act(() => {
|
|
1057
|
+
result.current.stop(threadId)
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
// The store's own status is whatever the throwing adapter left it as — unrecoverable from
|
|
1061
|
+
// here. What IS this hook's to guarantee is that `runs` no longer reports an in-flight turn:
|
|
1062
|
+
// that entry is the hook's own state and nothing later can ever clear it (stop() already
|
|
1063
|
+
// deleted the thread from controllersRef), so a throw above the teardown stranded it forever.
|
|
1064
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
1065
|
+
// The partial content still landed — the append runs before the settle.
|
|
1066
|
+
expect(store.threads.find((t) => t.id === threadId)?.messages).toHaveLength(2)
|
|
1067
|
+
|
|
1068
|
+
act(() => {
|
|
1069
|
+
result.current.stop(threadId)
|
|
1070
|
+
})
|
|
1071
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
1072
|
+
|
|
1073
|
+
gate.resolve(undefined)
|
|
1074
|
+
})
|
|
1075
|
+
|
|
1076
|
+
test('stop() still succeeds via crypto.getRandomValues when randomUUID is unavailable (rung 2 — the rung that matters in practice)', async () => {
|
|
1077
|
+
const store = createTestThreadsStore()
|
|
1078
|
+
const threadId = store.create()
|
|
1079
|
+
|
|
1080
|
+
const gate = deferred<void>()
|
|
1081
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
1082
|
+
async *stream() {
|
|
1083
|
+
yield { id: 'p1', type: 'text', text: 'partial' }
|
|
1084
|
+
await gate.promise
|
|
1085
|
+
yield { id: 'p2', type: 'text', text: 'never arrives' }
|
|
1086
|
+
},
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const originalCrypto = globalThis.crypto
|
|
1090
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
1091
|
+
value: { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) },
|
|
1092
|
+
configurable: true,
|
|
1093
|
+
})
|
|
1094
|
+
try {
|
|
1095
|
+
const { result } = renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
|
|
1096
|
+
|
|
1097
|
+
act(() => {
|
|
1098
|
+
result.current.start(threadId, 'hi')
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
await waitFor(() => {
|
|
1102
|
+
expect(result.current.runs.get(threadId)?.parts).toHaveLength(1)
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
act(() => {
|
|
1106
|
+
result.current.stop(threadId)
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
const thread = store.threads.find((t) => t.id === threadId)
|
|
1110
|
+
expect(thread?.status).toBe('done')
|
|
1111
|
+
expect(thread?.messages).toHaveLength(2)
|
|
1112
|
+
expect(thread?.messages[1]?.finish).toBe('stopped')
|
|
1113
|
+
expect(result.current.runs.has(threadId)).toBe(false)
|
|
1114
|
+
} finally {
|
|
1115
|
+
Object.defineProperty(globalThis, 'crypto', { value: originalCrypto, configurable: true })
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
gate.resolve(undefined)
|
|
1119
|
+
})
|
|
1120
|
+
})
|