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