basalt-ui 1.11.0 → 1.13.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 +32 -0
- package/README.md +63 -51
- package/agent/rules/basalt-agent.md +14 -0
- package/agent/rules/basalt-content.md +16 -3
- 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 +150 -0
- package/dist/agent/adapter.d.ts.map +1 -0
- package/dist/agent/adapter.js +482 -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.d.ts.map +1 -1
- package/dist/agent/ai-sdk-transport.js +6 -2
- 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 +78 -0
- package/dist/agent/id.d.ts.map +1 -1
- package/dist/agent/id.js +30 -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 +18 -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.d.ts.map +1 -1
- package/dist/agent/use-agent-stream.js +2 -2
- 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.d.ts +24 -3
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- 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.js +55 -15
- 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 +5 -1
- 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-detail-panel.d.ts.map +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- 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 +329 -37
- 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-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 +30 -4
- 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/tool-chip.test.d.ts +2 -0
- package/dist/agent-chat/tool-chip.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/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/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/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 +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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 +5 -5
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +14 -4
- 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 +9 -9
- package/package.json +7 -2
- package/src/agent/adapter.test.ts +1640 -0
- package/src/agent/adapter.ts +945 -0
- package/src/agent/ai-sdk-transport.test.ts +117 -26
- package/src/agent/ai-sdk-transport.ts +8 -2
- 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 +101 -3
- package/src/agent/id.ts +118 -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 +25 -1
- package/src/agent/use-agent-stream.test.tsx +31 -5
- package/src/agent/use-agent-stream.ts +6 -3
- 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 +356 -17
- package/src/agent/use-agent-thread-runs.ts +159 -23
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +93 -7
- package/src/agent-chat/composer.test.tsx +924 -0
- package/src/agent-chat/composer.tsx +521 -52
- package/src/agent-chat/index.ts +13 -1
- 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-detail-panel.tsx +3 -1
- 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 +1078 -4
- package/src/agent-chat/thread-message.tsx +795 -42
- 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 +70 -6
- package/src/agent-chat/virtualize.ts +132 -0
- package/src/agent-chat/virtualize.type-guard.test.ts +49 -0
- 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/data/index.ts +5 -1
- package/src/data/virtual-list.tsx +2 -1
- package/src/data/virtual.ts +2 -1
- package/src/index.ts +18 -1
- package/src/surfaces.ts +13 -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 +21 -6
- package/src/theme/use-basalt-spacing.test.tsx +2 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ChatMessage } from './history.js';
|
|
2
|
+
import type { AgentOutcome } from './outcome.js';
|
|
3
|
+
import type { AgentPart } from './parts.js';
|
|
4
|
+
import type { AgentThread, ThreadStatus, ThreadsStore } from './thread.js';
|
|
5
|
+
/**
|
|
6
|
+
* The async persistence contract behind a `ThreadsStore`. Every method may reject; the wrapping
|
|
7
|
+
* store treats a rejection as "the optimistic edit did not happen" and rolls it back.
|
|
8
|
+
*
|
|
9
|
+
* Implement this against whatever owns the transcript (Postgres, an HTTP API, IndexedDB) and
|
|
10
|
+
* verify it with `threadsStoreAdapterContract` — the suite pins the parts of the contract that
|
|
11
|
+
* prose cannot enforce, chiefly `appendMessage`'s idempotency.
|
|
12
|
+
*
|
|
13
|
+
* ## What `createAdapterThreadsStore` guarantees you
|
|
14
|
+
*
|
|
15
|
+
* **Per-thread write ordering.** Writes carrying the same thread id reach this adapter strictly in
|
|
16
|
+
* issue order, each one awaiting the previous, so a write that depends on a row existing is never
|
|
17
|
+
* issued before that row's `createThread` has RESOLVED. This matters because the primary send path
|
|
18
|
+
* is one synchronous block — `create()`, `select()`, `markRead()`, `appendMessage()`,
|
|
19
|
+
* `setStatus()` — which without ordering would hand you three dependent writes against a row you
|
|
20
|
+
* have not created yet. You therefore never have to tolerate (or invent an upsert for) an
|
|
21
|
+
* out-of-order write. Writes for DIFFERENT thread ids stay concurrent and carry no ordering
|
|
22
|
+
* guarantee relative to each other.
|
|
23
|
+
*
|
|
24
|
+
* ## What it requires of you
|
|
25
|
+
*
|
|
26
|
+
* **Read-after-write consistency on `listThreads`.** A `listThreads` STARTED after a write has
|
|
27
|
+
* resolved must reflect that write. The store keeps a write's optimistic patch applied until such
|
|
28
|
+
* a list lands, then drops it; an eventually-consistent backend (a stale read replica, a cached
|
|
29
|
+
* list) will therefore flash the pre-write state back into the UI. `threadsStoreAdapterContract`
|
|
30
|
+
* pins this.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* const postgresThreadsAdapter = (api: Api): ThreadsStoreAdapter => ({
|
|
34
|
+
* listThreads: (signal) => api.threads.get({ fetch: { signal } }),
|
|
35
|
+
* loadThread: (id, signal) => api.threads({ id }).get({ fetch: { signal } }),
|
|
36
|
+
* createThread: ({ id, meta }) => api.threads.post({ id, meta }),
|
|
37
|
+
* // ...
|
|
38
|
+
* })
|
|
39
|
+
*/
|
|
40
|
+
export type ThreadsStoreAdapter<TPart = AgentPart> = {
|
|
41
|
+
/** All threads, NEWEST-FIRST (same ordering contract as `ThreadsStore.threads`). */
|
|
42
|
+
readonly listThreads: (signal?: AbortSignal) => Promise<readonly AgentThread<TPart>[]>;
|
|
43
|
+
/** One thread by id, or `null` when it does not exist. Must not throw on an unknown id. */
|
|
44
|
+
readonly loadThread: (id: string, signal?: AbortSignal) => Promise<AgentThread<TPart> | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Materialize a new thread under the CLIENT-MINTED `id`. The id is minted client-side because
|
|
47
|
+
* `ThreadsStore.create()` returns synchronously — there is no await to hand a server id back
|
|
48
|
+
* through. The created thread starts empty: `messages: []`, `outcome: null`,
|
|
49
|
+
* `status: 'pending'`, `read: false`, with `createdAt`/`updatedAt` set.
|
|
50
|
+
*/
|
|
51
|
+
readonly createThread: (i: {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly meta?: Record<string, unknown>;
|
|
54
|
+
}) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* CONTRACT: idempotent on `message.id`. The id is client-minted and is the ONLY idempotency
|
|
57
|
+
* key — a retried or double-fired write with the same id must be a no-op, not a second row.
|
|
58
|
+
*/
|
|
59
|
+
readonly appendMessage: (i: {
|
|
60
|
+
readonly threadId: string;
|
|
61
|
+
readonly message: ChatMessage<TPart>;
|
|
62
|
+
}) => Promise<void>;
|
|
63
|
+
readonly setStatus: (i: {
|
|
64
|
+
readonly threadId: string;
|
|
65
|
+
readonly status: ThreadStatus;
|
|
66
|
+
}) => Promise<void>;
|
|
67
|
+
readonly setOutcome: (i: {
|
|
68
|
+
readonly threadId: string;
|
|
69
|
+
readonly outcome: AgentOutcome;
|
|
70
|
+
}) => Promise<void>;
|
|
71
|
+
/** `token: undefined` CLEARS the token — the stored thread must then read `resumeToken` as absent. */
|
|
72
|
+
readonly setResumeToken: (i: {
|
|
73
|
+
readonly threadId: string;
|
|
74
|
+
readonly token: string | undefined;
|
|
75
|
+
}) => Promise<void>;
|
|
76
|
+
readonly markRead: (threadId: string) => Promise<void>;
|
|
77
|
+
/** Removing an unknown id is a no-op, not an error. */
|
|
78
|
+
readonly removeThread: (threadId: string) => Promise<void>;
|
|
79
|
+
};
|
|
80
|
+
export type AdapterThreadsStoreOptions = {
|
|
81
|
+
/**
|
|
82
|
+
* Re-list threads whenever the window regains focus. Off by default — a background tab that
|
|
83
|
+
* refocuses should not surprise a consumer with a network round-trip it did not ask for.
|
|
84
|
+
* @default false
|
|
85
|
+
*/
|
|
86
|
+
readonly revalidateOnFocus?: boolean;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Wraps an async `ThreadsStoreAdapter` into the synchronous `ThreadsStore` every component takes.
|
|
90
|
+
*
|
|
91
|
+
* Call this ONCE per module (not inside a component), exactly like `createThreadsStore`. All
|
|
92
|
+
* components calling the returned hook share one registry, backed by `useSyncExternalStore`.
|
|
93
|
+
*
|
|
94
|
+
* Semantics:
|
|
95
|
+
* - **Optimistic.** Every mutation applies locally first, so `create()` can return an id
|
|
96
|
+
* synchronously and the UI never waits on a round-trip.
|
|
97
|
+
* - **Ordered per thread.** Writes for one thread id reach the adapter in issue order, each
|
|
98
|
+
* awaiting the previous — so the dependent writes of a `create()`-then-send land after
|
|
99
|
+
* `createThread` has resolved. Different threads stay concurrent. See `ThreadsStoreAdapter`.
|
|
100
|
+
* - **Revalidate on success, coalesced.** When a write resolves the store re-lists; the patch is
|
|
101
|
+
* dropped only once a list has actually refreshed the confirmed base, so server-side derivations
|
|
102
|
+
* win over the local guess with no gap in between. Writes that resolve while a list is already in
|
|
103
|
+
* flight share ONE follow-up instead of each restarting it, so a burst of writes costs at most
|
|
104
|
+
* two round trips (the in-flight one, plus one follow-up), not one per write — see `revalidate`.
|
|
105
|
+
* - **Roll back on rejection.** A rejected write's patch is discarded, the rejection is surfaced
|
|
106
|
+
* on `error`, and the store re-lists to converge on whatever the server really did. Patches are
|
|
107
|
+
* tracked individually, so a failing write rolls back only itself and not whatever else was in
|
|
108
|
+
* flight beside it. A rejected `create()` additionally drops every later single-thread write for
|
|
109
|
+
* that id (`markRead`/`appendMessage`/`setStatus`/… — both the ones chained directly behind it and
|
|
110
|
+
* the run's completion writes at stream end) instead of letting each fail independently against a
|
|
111
|
+
* row that no longer exists — see `failedCreateIds`. Without that, the create's own error is
|
|
112
|
+
* overwritten by a run of "unknown thread" rejections that name nothing a caller can act on.
|
|
113
|
+
* - **`hydrated` means "a `listThreads` has succeeded"**, not "a load has been attempted". A
|
|
114
|
+
* store that is `!hydrated` with a non-undefined `error` failed to load; check both.
|
|
115
|
+
* - **`error` latches the most recent failure until something disproves it.** A successful
|
|
116
|
+
* `listThreads` clears a LOAD failure; it leaves a WRITE failure alone, since a working list is
|
|
117
|
+
* no evidence the rejected write landed. A write clears a latched write failure only if it was
|
|
118
|
+
* issued after that failure (a retry) — never the writes that were already queued behind it.
|
|
119
|
+
*
|
|
120
|
+
* `select` is local-only — which thread is active is UI state, not something the adapter owns.
|
|
121
|
+
* `clear()` maps onto `removeThread` for each known thread, since the adapter has no bulk delete.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* export const useThreads = createAdapterThreadsStore(myAdapter, { revalidateOnFocus: true })
|
|
125
|
+
*/
|
|
126
|
+
export declare function createAdapterThreadsStore<TPart = AgentPart>(adapter: ThreadsStoreAdapter<TPart>, opts?: AdapterThreadsStoreOptions): () => ThreadsStore<TPart>;
|
|
127
|
+
/** One conformance case. `run` rejects with a plain Error describing the violation. */
|
|
128
|
+
export type ThreadsStoreAdapterContractCase = {
|
|
129
|
+
readonly name: string;
|
|
130
|
+
readonly run: () => Promise<void>;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* A test-runner-agnostic conformance suite for a `ThreadsStoreAdapter` implementation.
|
|
134
|
+
*
|
|
135
|
+
* This ships in the published package, so it deliberately imports NO test framework and depends
|
|
136
|
+
* on no assertion library — each case throws a plain `Error`. Drive it from whatever runner the
|
|
137
|
+
* consumer uses.
|
|
138
|
+
*
|
|
139
|
+
* `makeAdapter` is called ONCE PER CASE and must hand back a fresh, empty backend: the cases are
|
|
140
|
+
* independent and none of them cleans up after itself.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* import { threadsStoreAdapterContract } from 'basalt-ui/agent'
|
|
144
|
+
*
|
|
145
|
+
* for (const c of threadsStoreAdapterContract(() => postgresThreadsAdapter(freshDb()))) {
|
|
146
|
+
* test(c.name, () => c.run())
|
|
147
|
+
* }
|
|
148
|
+
*/
|
|
149
|
+
export declare function threadsStoreAdapterContract<TPart>(makeAdapter: () => ThreadsStoreAdapter<TPart> | Promise<ThreadsStoreAdapter<TPart>>): readonly ThreadsStoreAdapterContractCase[];
|
|
150
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/agent/adapter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
2
|
+
import { mintThreadId } from "./id.js";
|
|
3
|
+
function createAdapterThreadsStore(adapter, opts) {
|
|
4
|
+
const revalidateOnFocus = opts?.revalidateOnFocus ?? false;
|
|
5
|
+
const EMPTY = [];
|
|
6
|
+
const SERVER_SNAPSHOT = {
|
|
7
|
+
threads: EMPTY,
|
|
8
|
+
activeId: null,
|
|
9
|
+
hydrated: false,
|
|
10
|
+
error: void 0
|
|
11
|
+
};
|
|
12
|
+
let base = EMPTY;
|
|
13
|
+
let baseSeq = 0;
|
|
14
|
+
let activeId = null;
|
|
15
|
+
let hydrated = false;
|
|
16
|
+
let error;
|
|
17
|
+
let errorToken = 0;
|
|
18
|
+
let errorFromWrite = false;
|
|
19
|
+
const pending = [];
|
|
20
|
+
let snapshot = SERVER_SNAPSHOT;
|
|
21
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
22
|
+
function recompute() {
|
|
23
|
+
let threads = base;
|
|
24
|
+
let active = activeId;
|
|
25
|
+
for (const patch of pending) {
|
|
26
|
+
threads = patch.apply(threads);
|
|
27
|
+
if (patch.applyActive !== void 0) active = patch.applyActive(active);
|
|
28
|
+
}
|
|
29
|
+
snapshot = { threads, activeId: active, hydrated, error };
|
|
30
|
+
for (const listener of listeners) listener();
|
|
31
|
+
}
|
|
32
|
+
function memoizeApply(apply) {
|
|
33
|
+
let lastIn;
|
|
34
|
+
let lastOut;
|
|
35
|
+
return (threads) => {
|
|
36
|
+
if (lastOut !== void 0 && threads === lastIn) return lastOut;
|
|
37
|
+
lastIn = threads;
|
|
38
|
+
lastOut = apply(threads);
|
|
39
|
+
return lastOut;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function subscribe(listener) {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function getSnapshot() {
|
|
49
|
+
return snapshot;
|
|
50
|
+
}
|
|
51
|
+
function getServerSnapshot() {
|
|
52
|
+
return SERVER_SNAPSHOT;
|
|
53
|
+
}
|
|
54
|
+
let loadController = null;
|
|
55
|
+
let loadSeq = 0;
|
|
56
|
+
let queuedSeq = null;
|
|
57
|
+
function prune() {
|
|
58
|
+
const kept = [];
|
|
59
|
+
for (const patch of pending) {
|
|
60
|
+
if (patch.confirmSeq !== null && patch.confirmSeq <= baseSeq) {
|
|
61
|
+
if (patch.applyActive !== void 0) activeId = patch.applyActive(activeId);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
kept.push(patch);
|
|
65
|
+
}
|
|
66
|
+
if (kept.length !== pending.length) pending.splice(0, pending.length, ...kept);
|
|
67
|
+
}
|
|
68
|
+
function startLoad(seq) {
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
loadController = controller;
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const next = await adapter.listThreads(controller.signal);
|
|
74
|
+
if (loadController !== controller) return;
|
|
75
|
+
base = [...next];
|
|
76
|
+
baseSeq = seq;
|
|
77
|
+
hydrated = true;
|
|
78
|
+
if (!errorFromWrite) error = void 0;
|
|
79
|
+
prune();
|
|
80
|
+
recompute();
|
|
81
|
+
} catch (cause) {
|
|
82
|
+
if (controller.signal.aborted || loadController !== controller) return;
|
|
83
|
+
error = cause;
|
|
84
|
+
errorToken += 1;
|
|
85
|
+
errorFromWrite = false;
|
|
86
|
+
recompute();
|
|
87
|
+
} finally {
|
|
88
|
+
if (loadController === controller) {
|
|
89
|
+
loadController = null;
|
|
90
|
+
if (queuedSeq !== null) {
|
|
91
|
+
const next = queuedSeq;
|
|
92
|
+
queuedSeq = null;
|
|
93
|
+
startLoad(next);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
}
|
|
99
|
+
function revalidate() {
|
|
100
|
+
if (loadController !== null) {
|
|
101
|
+
queuedSeq ??= ++loadSeq;
|
|
102
|
+
return queuedSeq;
|
|
103
|
+
}
|
|
104
|
+
const seq = ++loadSeq;
|
|
105
|
+
startLoad(seq);
|
|
106
|
+
return seq;
|
|
107
|
+
}
|
|
108
|
+
function forceRevalidate() {
|
|
109
|
+
loadController?.abort();
|
|
110
|
+
queuedSeq = null;
|
|
111
|
+
const seq = ++loadSeq;
|
|
112
|
+
startLoad(seq);
|
|
113
|
+
return seq;
|
|
114
|
+
}
|
|
115
|
+
const chains = /* @__PURE__ */ new Map();
|
|
116
|
+
const failedCreateIds = /* @__PURE__ */ new Set();
|
|
117
|
+
function enqueue(threadIds, task) {
|
|
118
|
+
const ids = [...new Set(threadIds)];
|
|
119
|
+
const prior = ids.map((id) => chains.get(id)).filter((p) => p !== void 0);
|
|
120
|
+
const after = prior.length === 0 ? Promise.resolve() : Promise.all(prior).then(() => void 0);
|
|
121
|
+
const run = after.then(task);
|
|
122
|
+
for (const id of ids) chains.set(id, run);
|
|
123
|
+
void run.finally(() => {
|
|
124
|
+
for (const id of ids) if (chains.get(id) === run) chains.delete(id);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function drop(patch) {
|
|
128
|
+
const index = pending.indexOf(patch);
|
|
129
|
+
if (index !== -1) pending.splice(index, 1);
|
|
130
|
+
}
|
|
131
|
+
function mutate(i) {
|
|
132
|
+
const patch = {
|
|
133
|
+
apply: memoizeApply(i.apply),
|
|
134
|
+
...i.applyActive !== void 0 ? { applyActive: i.applyActive } : {},
|
|
135
|
+
confirmSeq: null
|
|
136
|
+
};
|
|
137
|
+
pending.push(patch);
|
|
138
|
+
const tokenAtIssue = errorToken;
|
|
139
|
+
recompute();
|
|
140
|
+
enqueue(i.threadIds, async () => {
|
|
141
|
+
const [soleThreadId] = i.threadIds;
|
|
142
|
+
if (!i.isCreate && i.threadIds.length === 1 && soleThreadId !== void 0 && failedCreateIds.has(soleThreadId)) {
|
|
143
|
+
drop(patch);
|
|
144
|
+
recompute();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await i.commit();
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
if (i.isCreate) for (const threadId of i.threadIds) failedCreateIds.add(threadId);
|
|
151
|
+
drop(patch);
|
|
152
|
+
error = cause;
|
|
153
|
+
errorToken += 1;
|
|
154
|
+
errorFromWrite = true;
|
|
155
|
+
recompute();
|
|
156
|
+
revalidate();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (errorFromWrite && errorToken === tokenAtIssue) {
|
|
160
|
+
error = void 0;
|
|
161
|
+
errorFromWrite = false;
|
|
162
|
+
recompute();
|
|
163
|
+
}
|
|
164
|
+
patch.confirmSeq = revalidate();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function patchThread(id, update) {
|
|
168
|
+
return (threads) => threads.map((thread) => thread.id === id ? update(thread) : thread);
|
|
169
|
+
}
|
|
170
|
+
let mounted = 0;
|
|
171
|
+
function onFocus() {
|
|
172
|
+
forceRevalidate();
|
|
173
|
+
}
|
|
174
|
+
function retain() {
|
|
175
|
+
mounted += 1;
|
|
176
|
+
if (mounted !== 1) return;
|
|
177
|
+
revalidate();
|
|
178
|
+
if (revalidateOnFocus && typeof window !== "undefined") {
|
|
179
|
+
window.addEventListener("focus", onFocus);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function release() {
|
|
183
|
+
mounted -= 1;
|
|
184
|
+
if (mounted !== 0) return;
|
|
185
|
+
loadController?.abort();
|
|
186
|
+
loadController = null;
|
|
187
|
+
queuedSeq = null;
|
|
188
|
+
if (revalidateOnFocus && typeof window !== "undefined") {
|
|
189
|
+
window.removeEventListener("focus", onFocus);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function select(id) {
|
|
193
|
+
activeId = id;
|
|
194
|
+
recompute();
|
|
195
|
+
}
|
|
196
|
+
function create(createOpts) {
|
|
197
|
+
const id = mintThreadId();
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const thread = {
|
|
200
|
+
id,
|
|
201
|
+
messages: [],
|
|
202
|
+
outcome: null,
|
|
203
|
+
status: "pending",
|
|
204
|
+
read: false,
|
|
205
|
+
createdAt: now,
|
|
206
|
+
updatedAt: now,
|
|
207
|
+
...createOpts?.meta !== void 0 ? { meta: createOpts.meta } : {}
|
|
208
|
+
};
|
|
209
|
+
mutate({
|
|
210
|
+
threadIds: [id],
|
|
211
|
+
apply: (threads) => threads.some((t) => t.id === id) ? [...threads] : [thread, ...threads],
|
|
212
|
+
isCreate: true,
|
|
213
|
+
commit: () => adapter.createThread({
|
|
214
|
+
id,
|
|
215
|
+
...createOpts?.meta !== void 0 ? { meta: createOpts.meta } : {}
|
|
216
|
+
})
|
|
217
|
+
});
|
|
218
|
+
return id;
|
|
219
|
+
}
|
|
220
|
+
function appendMessage(id, message2) {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
mutate({
|
|
223
|
+
threadIds: [id],
|
|
224
|
+
apply: patchThread(
|
|
225
|
+
id,
|
|
226
|
+
(thread) => thread.messages.some((m) => m.id === message2.id) ? thread : { ...thread, messages: [...thread.messages, message2], updatedAt: now }
|
|
227
|
+
),
|
|
228
|
+
commit: () => adapter.appendMessage({ threadId: id, message: message2 })
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function setOutcome(id, outcome) {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
mutate({
|
|
234
|
+
threadIds: [id],
|
|
235
|
+
apply: patchThread(id, (thread) => ({ ...thread, outcome, updatedAt: now })),
|
|
236
|
+
commit: () => adapter.setOutcome({ threadId: id, outcome })
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function setStatus(id, status) {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
mutate({
|
|
242
|
+
threadIds: [id],
|
|
243
|
+
apply: patchThread(id, (thread) => ({ ...thread, status, updatedAt: now })),
|
|
244
|
+
commit: () => adapter.setStatus({ threadId: id, status })
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function setResumeToken(id, token) {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
mutate({
|
|
250
|
+
threadIds: [id],
|
|
251
|
+
apply: patchThread(id, (thread) => {
|
|
252
|
+
const { resumeToken: _resumeToken, ...rest } = thread;
|
|
253
|
+
return {
|
|
254
|
+
...rest,
|
|
255
|
+
...token !== void 0 ? { resumeToken: token } : {},
|
|
256
|
+
updatedAt: now
|
|
257
|
+
};
|
|
258
|
+
}),
|
|
259
|
+
commit: () => adapter.setResumeToken({ threadId: id, token })
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function markRead(id) {
|
|
263
|
+
mutate({
|
|
264
|
+
threadIds: [id],
|
|
265
|
+
// Identity-preserving when the server already agrees — markRead does not bump updatedAt.
|
|
266
|
+
apply: patchThread(id, (thread) => thread.read ? thread : { ...thread, read: true }),
|
|
267
|
+
commit: () => adapter.markRead(id)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function remove(id) {
|
|
271
|
+
mutate({
|
|
272
|
+
threadIds: [id],
|
|
273
|
+
apply: (threads) => threads.filter((thread) => thread.id !== id),
|
|
274
|
+
// Inside the patch, so a rejected delete restores the SELECTION along with the row. Written
|
|
275
|
+
// as a conditional clear rather than a flat `null` so a `select()` issued while the delete
|
|
276
|
+
// is in flight survives the patch being re-applied on top of it.
|
|
277
|
+
applyActive: (active) => active === id ? null : active,
|
|
278
|
+
commit: () => adapter.removeThread(id)
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function clear() {
|
|
282
|
+
const ids = snapshot.threads.map((thread) => thread.id);
|
|
283
|
+
const clearedIds = new Set(ids);
|
|
284
|
+
mutate({
|
|
285
|
+
threadIds: ids,
|
|
286
|
+
apply: () => [],
|
|
287
|
+
// Same idiom as remove(): only null a selection pointing at one of the threads THIS clear
|
|
288
|
+
// captured, so a select() issued while the clear is in flight — e.g. into a thread created
|
|
289
|
+
// concurrently by create() — survives the patch being re-applied on top of it.
|
|
290
|
+
applyActive: (active) => active !== null && clearedIds.has(active) ? null : active,
|
|
291
|
+
commit: async () => {
|
|
292
|
+
const results = await Promise.allSettled(ids.map((id) => adapter.removeThread(id)));
|
|
293
|
+
const failures = results.filter((r) => r.status === "rejected");
|
|
294
|
+
if (failures.length === 0) return;
|
|
295
|
+
throw new AggregateError(
|
|
296
|
+
failures.map((f) => f.reason),
|
|
297
|
+
`removeThread rejected for ${failures.length} of ${ids.length} threads`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return function useAdapterThreadsStore() {
|
|
303
|
+
const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
retain();
|
|
306
|
+
return () => {
|
|
307
|
+
release();
|
|
308
|
+
};
|
|
309
|
+
}, []);
|
|
310
|
+
return {
|
|
311
|
+
threads: current.threads,
|
|
312
|
+
activeId: current.activeId,
|
|
313
|
+
hydrated: current.hydrated,
|
|
314
|
+
error: current.error,
|
|
315
|
+
select,
|
|
316
|
+
create,
|
|
317
|
+
appendMessage,
|
|
318
|
+
setOutcome,
|
|
319
|
+
setStatus,
|
|
320
|
+
setResumeToken,
|
|
321
|
+
markRead,
|
|
322
|
+
remove,
|
|
323
|
+
clear
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function assert(condition, message2) {
|
|
328
|
+
if (!condition) throw new Error(`ThreadsStoreAdapter contract: ${message2}`);
|
|
329
|
+
}
|
|
330
|
+
function message(id) {
|
|
331
|
+
return { id, role: "user", parts: [], createdAt: Date.now() };
|
|
332
|
+
}
|
|
333
|
+
function threadsStoreAdapterContract(makeAdapter) {
|
|
334
|
+
function define(name, run) {
|
|
335
|
+
return {
|
|
336
|
+
name,
|
|
337
|
+
run: async () => {
|
|
338
|
+
await run(await makeAdapter());
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async function loadOrThrow(adapter, id) {
|
|
343
|
+
const thread = await adapter.loadThread(id);
|
|
344
|
+
assert(thread !== null, `loadThread('${id}') returned null for a thread that was created`);
|
|
345
|
+
return thread;
|
|
346
|
+
}
|
|
347
|
+
return [
|
|
348
|
+
define("createThread then listThreads returns the thread with empty defaults", async (a) => {
|
|
349
|
+
const id = mintThreadId();
|
|
350
|
+
await a.createThread({ id });
|
|
351
|
+
const threads = await a.listThreads();
|
|
352
|
+
const found = threads.filter((t) => t.id === id);
|
|
353
|
+
assert(found.length === 1, `listThreads returned ${found.length} threads for one create`);
|
|
354
|
+
const thread = found[0];
|
|
355
|
+
assert(thread !== void 0, "listThreads entry was undefined");
|
|
356
|
+
assert(thread.messages.length === 0, "a new thread must start with no messages");
|
|
357
|
+
assert(thread.outcome === null, "a new thread's outcome must be null");
|
|
358
|
+
assert(thread.status === "pending", `a new thread's status must be 'pending'`);
|
|
359
|
+
assert(thread.read === false, "a new thread must be unread");
|
|
360
|
+
assert(typeof thread.createdAt === "number", "createdAt must be a number");
|
|
361
|
+
assert(typeof thread.updatedAt === "number", "updatedAt must be a number");
|
|
362
|
+
}),
|
|
363
|
+
define("createThread persists meta", async (a) => {
|
|
364
|
+
const id = mintThreadId();
|
|
365
|
+
await a.createThread({ id, meta: { source: "contract" } });
|
|
366
|
+
const thread = await loadOrThrow(a, id);
|
|
367
|
+
assert(thread.meta?.["source"] === "contract", "meta did not round-trip");
|
|
368
|
+
}),
|
|
369
|
+
define("loadThread returns null for an unknown id", async (a) => {
|
|
370
|
+
const thread = await a.loadThread(mintThreadId());
|
|
371
|
+
assert(thread === null, "loadThread must return null (not throw, not undefined) when absent");
|
|
372
|
+
}),
|
|
373
|
+
define("appendMessage is idempotent on message.id", async (a) => {
|
|
374
|
+
const id = mintThreadId();
|
|
375
|
+
await a.createThread({ id });
|
|
376
|
+
const msg = message("contract-msg-1");
|
|
377
|
+
await a.appendMessage({ threadId: id, message: msg });
|
|
378
|
+
await a.appendMessage({ threadId: id, message: msg });
|
|
379
|
+
const thread = await loadOrThrow(a, id);
|
|
380
|
+
assert(
|
|
381
|
+
thread.messages.length === 1,
|
|
382
|
+
`appending the same message.id twice produced ${thread.messages.length} rows \u2014 the id is the only idempotency key`
|
|
383
|
+
);
|
|
384
|
+
assert(thread.messages[0]?.id === "contract-msg-1", "the retained message has the wrong id");
|
|
385
|
+
}),
|
|
386
|
+
define("appendMessage preserves order for distinct ids", async (a) => {
|
|
387
|
+
const id = mintThreadId();
|
|
388
|
+
await a.createThread({ id });
|
|
389
|
+
await a.appendMessage({ threadId: id, message: message("contract-msg-1") });
|
|
390
|
+
await a.appendMessage({ threadId: id, message: message("contract-msg-2") });
|
|
391
|
+
const thread = await loadOrThrow(a, id);
|
|
392
|
+
assert(
|
|
393
|
+
thread.messages.map((m) => m.id).join(",") === "contract-msg-1,contract-msg-2",
|
|
394
|
+
"messages must read back oldest-first in append order"
|
|
395
|
+
);
|
|
396
|
+
}),
|
|
397
|
+
define("setStatus round-trips", async (a) => {
|
|
398
|
+
const id = mintThreadId();
|
|
399
|
+
await a.createThread({ id });
|
|
400
|
+
await a.setStatus({ threadId: id, status: "streaming" });
|
|
401
|
+
assert((await loadOrThrow(a, id)).status === "streaming", "status did not become 'streaming'");
|
|
402
|
+
await a.setStatus({ threadId: id, status: "error" });
|
|
403
|
+
assert((await loadOrThrow(a, id)).status === "error", "status did not become 'error'");
|
|
404
|
+
}),
|
|
405
|
+
define("setOutcome round-trips", async (a) => {
|
|
406
|
+
const id = mintThreadId();
|
|
407
|
+
await a.createThread({ id });
|
|
408
|
+
const outcome = { title: "Title", summary: "Summary", status: "done" };
|
|
409
|
+
await a.setOutcome({ threadId: id, outcome });
|
|
410
|
+
const thread = await loadOrThrow(a, id);
|
|
411
|
+
assert(thread.outcome?.title === "Title", "outcome.title did not round-trip");
|
|
412
|
+
assert(thread.outcome?.summary === "Summary", "outcome.summary did not round-trip");
|
|
413
|
+
assert(thread.outcome?.status === "done", "outcome.status did not round-trip");
|
|
414
|
+
}),
|
|
415
|
+
define("setResumeToken round-trips and clears with undefined", async (a) => {
|
|
416
|
+
const id = mintThreadId();
|
|
417
|
+
await a.createThread({ id });
|
|
418
|
+
await a.setResumeToken({ threadId: id, token: "tok-1" });
|
|
419
|
+
assert((await loadOrThrow(a, id)).resumeToken === "tok-1", "resumeToken did not round-trip");
|
|
420
|
+
await a.setResumeToken({ threadId: id, token: void 0 });
|
|
421
|
+
assert(
|
|
422
|
+
(await loadOrThrow(a, id)).resumeToken === void 0,
|
|
423
|
+
"setResumeToken(undefined) must CLEAR the token"
|
|
424
|
+
);
|
|
425
|
+
}),
|
|
426
|
+
define("markRead marks the thread read", async (a) => {
|
|
427
|
+
const id = mintThreadId();
|
|
428
|
+
await a.createThread({ id });
|
|
429
|
+
assert((await loadOrThrow(a, id)).read === false, "a new thread must start unread");
|
|
430
|
+
await a.markRead(id);
|
|
431
|
+
assert((await loadOrThrow(a, id)).read === true, "markRead did not set read");
|
|
432
|
+
}),
|
|
433
|
+
define("listThreads reflects a write that has already resolved", async (a) => {
|
|
434
|
+
const id = mintThreadId();
|
|
435
|
+
await a.createThread({ id });
|
|
436
|
+
await a.appendMessage({ threadId: id, message: message("contract-msg-raw") });
|
|
437
|
+
await a.setStatus({ threadId: id, status: "streaming" });
|
|
438
|
+
const found = (await a.listThreads()).find((t) => t.id === id);
|
|
439
|
+
assert(found !== void 0, "listThreads omitted a thread created before the call");
|
|
440
|
+
assert(
|
|
441
|
+
found.status === "streaming",
|
|
442
|
+
"listThreads returned a STALE status \u2014 a list started after a write resolves must reflect it"
|
|
443
|
+
);
|
|
444
|
+
assert(
|
|
445
|
+
found.messages.length === 1,
|
|
446
|
+
`listThreads returned a STALE message list (${found.messages.length} rows) \u2014 a list started after a write resolves must reflect it`
|
|
447
|
+
);
|
|
448
|
+
}),
|
|
449
|
+
define(
|
|
450
|
+
"dependent writes issued straight after createThread resolves are honoured",
|
|
451
|
+
async (a) => {
|
|
452
|
+
const id = mintThreadId();
|
|
453
|
+
await a.createThread({ id });
|
|
454
|
+
await a.markRead(id);
|
|
455
|
+
await a.appendMessage({ threadId: id, message: message("contract-msg-dep") });
|
|
456
|
+
await a.setStatus({ threadId: id, status: "streaming" });
|
|
457
|
+
const thread = await loadOrThrow(a, id);
|
|
458
|
+
assert(thread.read === true, "markRead did not apply to a just-created thread");
|
|
459
|
+
assert(
|
|
460
|
+
thread.messages.length === 1,
|
|
461
|
+
`appendMessage did not apply to a just-created thread (${thread.messages.length} rows)`
|
|
462
|
+
);
|
|
463
|
+
assert(thread.status === "streaming", "setStatus did not apply to a just-created thread");
|
|
464
|
+
}
|
|
465
|
+
),
|
|
466
|
+
define("removeThread removes the thread", async (a) => {
|
|
467
|
+
const id = mintThreadId();
|
|
468
|
+
await a.createThread({ id });
|
|
469
|
+
await a.removeThread(id);
|
|
470
|
+
assert(await a.loadThread(id).then((t) => t === null), "loadThread must be null after remove");
|
|
471
|
+
const threads = await a.listThreads();
|
|
472
|
+
assert(!threads.some((t) => t.id === id), "listThreads still contains the removed thread");
|
|
473
|
+
}),
|
|
474
|
+
define("removeThread of an unknown id does not throw", async (a) => {
|
|
475
|
+
await a.removeThread(mintThreadId());
|
|
476
|
+
})
|
|
477
|
+
];
|
|
478
|
+
}
|
|
479
|
+
export {
|
|
480
|
+
createAdapterThreadsStore,
|
|
481
|
+
threadsStoreAdapterContract
|
|
482
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.test.d.ts","sourceRoot":"","sources":["../../src/agent/adapter.test.ts"],"names":[],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ai-sdk-transport.d.ts","sourceRoot":"","sources":["../../src/agent/ai-sdk-transport.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ai-sdk-transport.d.ts","sourceRoot":"","sources":["../../src/agent/ai-sdk-transport.ts"],"names":[],"mappings":"AAoDA,OAAO,KAAK,EAAE,SAAS,EAAkB,MAAM,SAAS,CAAA;AACxD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAmB1D,MAAM,MAAM,qBAAqB,GAAG;IAClC,kFAAkF;IAClF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAA;IACnD,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,kBAAkB,CAAA;IACzC;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;CACzC,CAAA;AAID,MAAM,MAAM,cAAc,CAAC,KAAK,GAAG,SAAS,IAAI,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,GAAG;IACvF;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,uBAAuB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;CAC/E,CAAA;AAwRD;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,KAAK,GAAG,SAAS,EAC9C,OAAO,EAAE,qBAAqB,GAC7B,cAAc,CAAC,KAAK,CAAC,CAkDvB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertNever } from "../register.js";
|
|
2
|
+
import { mintMessageId, mintThreadId } from "./id.js";
|
|
2
3
|
import { TERMINAL_TOOL_STATES } from "./parts.js";
|
|
3
4
|
function createAiSdkResolver(options) {
|
|
4
5
|
let resolved;
|
|
@@ -161,7 +162,10 @@ function aiSdkTransport(options) {
|
|
|
161
162
|
yield { id: `${chatId}#start`, type: "start", runId: chatId, resumeToken: chatId };
|
|
162
163
|
const { httpTransport, readUIMessageStream: readStream } = await resolveAiSdk();
|
|
163
164
|
const userMessage = {
|
|
164
|
-
id
|
|
165
|
+
// This id is what AI SDK's own backend persistence/dedup keys turns hang off — the same
|
|
166
|
+
// idempotency-key cost as basalt's own ChatMessage.id (see mintMessageId's doc), so
|
|
167
|
+
// mintMessageId, not mintThreadId, is the right helper here.
|
|
168
|
+
id: mintMessageId(),
|
|
165
169
|
role: "user",
|
|
166
170
|
parts: [{ type: "text", text: input }]
|
|
167
171
|
};
|
|
@@ -182,7 +186,7 @@ function aiSdkTransport(options) {
|
|
|
182
186
|
}
|
|
183
187
|
};
|
|
184
188
|
}
|
|
185
|
-
const fixedChatId =
|
|
189
|
+
const fixedChatId = mintThreadId();
|
|
186
190
|
return {
|
|
187
191
|
...makeTransport(fixedChatId),
|
|
188
192
|
forThread: (chatId) => makeTransport(chatId)
|