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
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
41
41
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
|
42
42
|
import type { ChatMessage } from './history'
|
|
43
|
-
import { withPartIds } from './id'
|
|
43
|
+
import { mintMessageId, mintThreadId, withPartIds } from './id'
|
|
44
44
|
import { mergePart } from './merge'
|
|
45
45
|
import type { PartLike } from './merge'
|
|
46
46
|
import type { AgentOutcome, OutcomeResolver } from './outcome'
|
|
@@ -103,18 +103,39 @@ export type UseAgentThreadRunsArgs<TPart = AgentPart> = {
|
|
|
103
103
|
export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
|
|
104
104
|
/** Live stream state per thread id, for threads with a run in progress or just completed. */
|
|
105
105
|
readonly runs: ReadonlyMap<string, ThreadRunState<TPart>>
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Start a new turn on `threadId`. No-op if that thread already has a stream in flight.
|
|
108
|
+
*
|
|
109
|
+
* @throws {Error} synchronously, before any state is touched, on a host with no usable
|
|
110
|
+
* `crypto` at all (see `mintMessageId` in `./id.ts` — the id it mints here is the new user
|
|
111
|
+
* ChatMessage's idempotency key, so it degrades to a throw rather than a silent collision).
|
|
112
|
+
* This throw is a true no-op: it happens before `appendMessage`/`setStatus`/the run's
|
|
113
|
+
* controller are registered, so nothing is left half-started — the thread is exactly as it was
|
|
114
|
+
* before the call. A caller invoking `start` from an event handler on a target that must
|
|
115
|
+
* tolerate this (very old WebViews, non-DOM SSR shims) should wrap the call in its own
|
|
116
|
+
* try/catch; this rung is vanishingly rare in practice (see `mintMessageId`'s own doc).
|
|
117
|
+
*/
|
|
107
118
|
readonly start: (threadId: string, input: string) => void
|
|
108
119
|
/**
|
|
109
120
|
* Replay the last user input sent on `threadId` (same code path as `start`). No-op if
|
|
110
121
|
* `threadId` has never had a turn started, or already has one in flight.
|
|
122
|
+
*
|
|
123
|
+
* @throws {Error} same condition and same true-no-op guarantee as `start` — see its doc.
|
|
111
124
|
*/
|
|
112
125
|
readonly retry: (threadId: string) => void
|
|
113
126
|
/**
|
|
114
|
-
* Abort the in-flight stream for `threadId` (no-op if
|
|
115
|
-
*
|
|
127
|
+
* Abort the in-flight stream for `threadId` (true no-op only if BOTH `controllersRef` and
|
|
128
|
+
* `runs` have nothing for it — i.e. the thread genuinely isn't live). Preserves whatever content
|
|
129
|
+
* had already arrived: if the run had accumulated any parts, they're persisted as an assistant
|
|
116
130
|
* ChatMessage with `finish: 'stopped'` (unless that message already landed — see finalizeStop),
|
|
117
131
|
* then distilled into an outcome and the thread is settled to 'done'.
|
|
132
|
+
*
|
|
133
|
+
* Defense-in-depth: if `runs` reports `threadId` as `'streaming'` but no controller is
|
|
134
|
+
* registered for it (a phantom entry — not reachable via any path in this hook today, since the
|
|
135
|
+
* unmount-cleanup effect and `finalizeStop` both tear down `controllersRef` and `runs` together,
|
|
136
|
+
* but guarded against here so nothing in this file can EVER wedge a thread the UI shows as live
|
|
137
|
+
* with no way to reach a terminal state), `stop()` still settles it instead of silently
|
|
138
|
+
* no-opping forever.
|
|
118
139
|
*/
|
|
119
140
|
readonly stop: (threadId: string) => void
|
|
120
141
|
/** Abort every in-flight stream across all threads, settling each the same way stop() would. */
|
|
@@ -123,9 +144,14 @@ export type UseAgentThreadRunsReturn<TPart = AgentPart> = {
|
|
|
123
144
|
|
|
124
145
|
// ── defaults ──────────────────────────────────────────────────────────────────
|
|
125
146
|
|
|
126
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Default toUserParts: wraps raw input in a single text part. This part's id is a display/merge
|
|
149
|
+
* identity WITHIN a message's own `parts` array — it is never the value `appendMessage` idempotes
|
|
150
|
+
* on (that's the enclosing `ChatMessage.id`, minted separately by the caller) — so `mintThreadId`
|
|
151
|
+
* is the right low-collision-cost helper here, not `mintMessageId`.
|
|
152
|
+
*/
|
|
127
153
|
function defaultToUserParts(input: string): AgentPart[] {
|
|
128
|
-
return [{ id:
|
|
154
|
+
return [{ id: mintThreadId(), type: 'text', text: input }]
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
/**
|
|
@@ -199,6 +225,18 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
|
199
225
|
for await (const part of generator) {
|
|
200
226
|
// Guard: a newer call superseded this stream's controller for this thread, or it
|
|
201
227
|
// was aborted — stop updating state.
|
|
228
|
+
//
|
|
229
|
+
// Deliberately NOT a `runs` teardown site (considered and rejected): `controllersRef`
|
|
230
|
+
// mismatch here is ambiguous between "this run was aborted and nothing replaced it" and "a
|
|
231
|
+
// newer run (a mount-reconcile resume, keyed by the SAME threadId) has already taken over
|
|
232
|
+
// and is live in `runs` right now". Only the first case should ever clear `runs[threadId]`,
|
|
233
|
+
// and only the abort's OWN originator can tell the two apart without a race — the unmount-
|
|
234
|
+
// cleanup effect (see its doc) does that teardown synchronously, in the same tick as the
|
|
235
|
+
// abort, before any newer run could exist to be confused with. If this guard also deleted
|
|
236
|
+
// `runs[threadId]`, a superseding resume's fresh entry would be clobbered out from under it
|
|
237
|
+
// by its OWN predecessor's late-settling loop iteration — reintroducing a wedge instead of
|
|
238
|
+
// fixing one. `stop()`'s own abort path has the same property (finalizeStop's teardown runs
|
|
239
|
+
// synchronously before `stop()` returns, never racing a subsequent call).
|
|
202
240
|
if (controllersRef.current.get(threadId) !== controller) return
|
|
203
241
|
if (controller.signal.aborted) return
|
|
204
242
|
if (isStartPart(part)) {
|
|
@@ -221,7 +259,9 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
|
221
259
|
}
|
|
222
260
|
|
|
223
261
|
const assistantMessage: ChatMessage<TPart> = {
|
|
224
|
-
|
|
262
|
+
// appendMessage's idempotency key — mintMessageId, never mintThreadId (see that helper's
|
|
263
|
+
// doc): a collided id here would silently drop this message, not merge a duplicate thread.
|
|
264
|
+
id: mintMessageId(),
|
|
225
265
|
role: 'assistant',
|
|
226
266
|
parts,
|
|
227
267
|
createdAt: Date.now(),
|
|
@@ -285,11 +325,37 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
|
|
|
285
325
|
* invoking this. `alreadyAppended` is precomputed by the caller (stop()) from `appendedRef` — see
|
|
286
326
|
* that ref's doc for why this is an explicit marker rather than an inferred comparison.
|
|
287
327
|
*
|
|
288
|
-
* Ordering
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
328
|
+
* Ordering / the append-failure guard: the append is attempted FIRST (so a successful stop still
|
|
329
|
+
* reads as 'done' with its partial content intact — this is the common case and the one that
|
|
330
|
+
* matters most), but it is wrapped in its own try/catch that CANNOT prevent the terminal
|
|
331
|
+
* transition below it. This is deliberate, not incidental: by the time this function runs, `stop()`
|
|
332
|
+
* has already deleted `threadId` from `controllersRef` and `appendedRef` (see `stop()`'s own doc),
|
|
333
|
+
* so a second `stop()` call is unconditionally a no-op regardless of what happens here — if the
|
|
334
|
+
* append (or `mintMessageId`, which now also throws on rung 3 — see `./id.ts`) throws and nothing
|
|
335
|
+
* downstream ran, the thread would be wedged at 'streaming' FOREVER with no way for the user to
|
|
336
|
+
* clear it. That is exactly the standing invariant this layer forbids (see this hook's module
|
|
337
|
+
* doc / the render-path degrade rule), so it must not depend on the append succeeding. The
|
|
338
|
+
* alternative ordering — set status/teardown first, append after — was considered and rejected:
|
|
339
|
+
* it would mark the thread 'done' before the content the user is about to lose is even attempted,
|
|
340
|
+
* which is a strictly worse failure mode for the (overwhelmingly common) success path than this
|
|
341
|
+
* try/catch is for the (vanishingly rare) failure path.
|
|
342
|
+
*
|
|
343
|
+
* On an append failure, status becomes 'error' instead of 'done' — mirroring
|
|
344
|
+
* `consumeAndFinalize`'s own catch path (`onFailureStatus`), which is the existing precedent for
|
|
345
|
+
* "the persist step failed, tell the user" in this file — and `resolveOutcome` is skipped
|
|
346
|
+
* entirely, again matching that catch path: there is no complete/consistent snapshot worth
|
|
347
|
+
* distilling into an outcome when the turn's own final message never made it into the store.
|
|
348
|
+
*
|
|
349
|
+
* `setStatus`/`setResumeToken` are guarded for the SAME reason, and this is not belt-and-braces:
|
|
350
|
+
* they are consumer code exactly as `appendMessage` is (a `createThreadsStore` adapter over a
|
|
351
|
+
* remote backend, a store that rejects a threadId removed mid-stream), and guarding only the
|
|
352
|
+
* append leaves the identical wedge one line further down. The single thing that must ALWAYS run
|
|
353
|
+
* is the `setRuns` teardown — that entry is the HOOK'S OWN state, it is what `runs.get(threadId)`
|
|
354
|
+
* reports to the UI as "a turn is in flight", and by this point `stop()` has already deleted the
|
|
355
|
+
* thread from `controllersRef`, so no later `stop()` can ever reach here again to clean it up. A
|
|
356
|
+
* throw above it therefore strands a phantom run for the lifetime of the hook. Each store call
|
|
357
|
+
* gets its OWN try so a failing `setStatus` cannot also skip clearing the resume token — a
|
|
358
|
+
* surviving token is separately harmful (it is what a later resume replays from).
|
|
293
359
|
*/
|
|
294
360
|
async function finalizeStop<TPart extends PartLike>(args: {
|
|
295
361
|
threadId: string
|
|
@@ -301,19 +367,39 @@ async function finalizeStop<TPart extends PartLike>(args: {
|
|
|
301
367
|
}): Promise<void> {
|
|
302
368
|
const { threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns } = args
|
|
303
369
|
|
|
370
|
+
let appendFailed = false
|
|
304
371
|
if (!alreadyAppended && parts.length > 0) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
372
|
+
try {
|
|
373
|
+
const stoppedMessage: ChatMessage<TPart> = {
|
|
374
|
+
// Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId.
|
|
375
|
+
// This can throw on rung 3 (no usable crypto — see ./id.ts) just like appendMessage
|
|
376
|
+
// (consumer code) can throw for its own reasons; both are caught below so neither can
|
|
377
|
+
// wedge the thread — see this function's doc.
|
|
378
|
+
id: mintMessageId(),
|
|
379
|
+
role: 'assistant',
|
|
380
|
+
parts,
|
|
381
|
+
createdAt: Date.now(),
|
|
382
|
+
finish: 'stopped',
|
|
383
|
+
}
|
|
384
|
+
storeRef.current.appendMessage(threadId, stoppedMessage)
|
|
385
|
+
} catch {
|
|
386
|
+
appendFailed = true
|
|
311
387
|
}
|
|
312
|
-
storeRef.current.appendMessage(threadId, stoppedMessage)
|
|
313
388
|
}
|
|
314
389
|
|
|
315
|
-
|
|
316
|
-
|
|
390
|
+
let settleFailed = appendFailed
|
|
391
|
+
try {
|
|
392
|
+
storeRef.current.setStatus(threadId, appendFailed ? 'error' : 'done')
|
|
393
|
+
} catch {
|
|
394
|
+
settleFailed = true
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
storeRef.current.setResumeToken(threadId, undefined)
|
|
398
|
+
} catch {
|
|
399
|
+
settleFailed = true
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Unconditional — the one step that cannot be allowed to be skipped. See this function's doc.
|
|
317
403
|
setRuns((prev) => {
|
|
318
404
|
if (!prev.has(threadId)) return prev
|
|
319
405
|
const next = new Map(prev)
|
|
@@ -321,6 +407,10 @@ async function finalizeStop<TPart extends PartLike>(args: {
|
|
|
321
407
|
return next
|
|
322
408
|
})
|
|
323
409
|
|
|
410
|
+
// No outcome to resolve for a turn whose own terminal message never landed, or whose settle
|
|
411
|
+
// never took — see this function's doc on why this mirrors consumeAndFinalize's catch path.
|
|
412
|
+
if (settleFailed) return
|
|
413
|
+
|
|
324
414
|
const snapshot = storeRef.current.threads.find((thread) => thread.id === threadId)
|
|
325
415
|
if (snapshot === undefined) return
|
|
326
416
|
const outcome: AgentOutcome = await resolveOutcome(snapshot)
|
|
@@ -419,11 +509,40 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
419
509
|
// skipping the orphan-resume path and wedging the thread in 'streaming' forever. Reachable
|
|
420
510
|
// whenever this fiber's effects re-run without the fiber itself unmounting (React 19 StrictMode
|
|
421
511
|
// double-invoke; `<Activity>` hide/show) — see this hook's `@example`-adjacent doc / the F3 note.
|
|
512
|
+
//
|
|
513
|
+
// Also tears down the matching `runs` entries — the F3 fix above stopped this from wedging
|
|
514
|
+
// `controllersRef`/the *persisted* thread status, but left `runs` (this hook's own transient
|
|
515
|
+
// state) untouched, and on a re-run-without-unmount the component (and its `runs` state) survive
|
|
516
|
+
// this cleanup. Without this, the entry the aborted controller was updating stays in `runs`
|
|
517
|
+
// reporting `status: 'streaming'` forever: the mount-reconcile effect below DOES correctly settle
|
|
518
|
+
// the persisted thread (to a resumed run, or to 'interrupted' if not resumable), but a consumer
|
|
519
|
+
// deriving "is this thread live" from `runs.has(id)` — the whole reason `runs` exists — keeps
|
|
520
|
+
// seeing a phantom in-flight turn no controller is driving anymore, and `stop()` on it was a
|
|
521
|
+
// permanent no-op (nothing left in `controllersRef` to abort) — see `stop()`'s own doc. Scoped to
|
|
522
|
+
// exactly the threadIds this pass is aborting (not a blind `runs.clear()`): the mount-reconcile
|
|
523
|
+
// effect below runs AFTER this cleanup completes, in the same synchronous commit (both are
|
|
524
|
+
// `[]`-dep effects on this same fiber, so StrictMode/`<Activity>` destroy-then-recreate both
|
|
525
|
+
// together, in declaration order) — a resumable thread's reconcile pass registers a FRESH
|
|
526
|
+
// controller and a fresh `runs` entry there, and this teardown must never clobber that. It
|
|
527
|
+
// can't: by the time this cleanup runs, nothing has created a NEW entry for these threadIds yet
|
|
528
|
+
// (that only happens in the reconcile effect, still to come), so scoping to the ids this pass is
|
|
529
|
+
// itself aborting is exact, not merely defensive.
|
|
422
530
|
useEffect(
|
|
423
531
|
() => () => {
|
|
532
|
+
const abortedThreadIds = Array.from(controllersRef.current.keys())
|
|
424
533
|
controllersRef.current.forEach((controller) => controller.abort())
|
|
425
534
|
controllersRef.current.clear()
|
|
426
535
|
appendedRef.current.clear()
|
|
536
|
+
if (abortedThreadIds.length === 0) return
|
|
537
|
+
setRuns((prev) => {
|
|
538
|
+
let next: Map<string, ThreadRunState<TPart>> | undefined
|
|
539
|
+
for (const threadId of abortedThreadIds) {
|
|
540
|
+
if (!prev.has(threadId)) continue
|
|
541
|
+
next ??= new Map(prev)
|
|
542
|
+
next.delete(threadId)
|
|
543
|
+
}
|
|
544
|
+
return next ?? prev
|
|
545
|
+
})
|
|
427
546
|
},
|
|
428
547
|
[],
|
|
429
548
|
)
|
|
@@ -500,7 +619,8 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
500
619
|
lastInputRef.current.set(threadId, input)
|
|
501
620
|
|
|
502
621
|
const userMessage: ChatMessage<TPart> = {
|
|
503
|
-
|
|
622
|
+
// Same idempotency-key reasoning as consumeAndFinalize's assistantMessage — mintMessageId.
|
|
623
|
+
id: mintMessageId(),
|
|
504
624
|
role: 'user',
|
|
505
625
|
parts: toUserPartsRef.current(input),
|
|
506
626
|
createdAt: Date.now(),
|
|
@@ -557,7 +677,23 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
|
|
|
557
677
|
// runsRef still holding the PREVIOUS render's (empty) map. Gating on runsRef here would read
|
|
558
678
|
// `undefined`, wrongly treat this as a no-op, leave the controller registered and never
|
|
559
679
|
// abort it — stranding the run in 'streaming' (see the F4 regression test).
|
|
560
|
-
if (controller === undefined)
|
|
680
|
+
if (controller === undefined) {
|
|
681
|
+
// No controller — but if `runs` still reports this thread as live, that's a phantom entry
|
|
682
|
+
// (see this hook's `stop()` JSDoc): nothing is driving it, and with no controller to key
|
|
683
|
+
// off of, this is the only remaining path that can ever settle it. Not reachable via any
|
|
684
|
+
// path in THIS file today (the unmount-cleanup effect and finalizeStop both tear down
|
|
685
|
+
// `controllersRef` and `runs` in the same synchronous step — see their docs), but a true
|
|
686
|
+
// no-op here would mean "the UI shows this thread streaming forever, and Stop can never
|
|
687
|
+
// clear it" the instant that invariant is ever broken by a future change. Settle it via the
|
|
688
|
+
// same finalizeStop() the normal path uses, so there is still exactly one teardown routine
|
|
689
|
+
// for "a run is ending" regardless of how it got here.
|
|
690
|
+
if (!runsRef.current.has(threadId)) return
|
|
691
|
+
const parts = runsRef.current.get(threadId)?.parts ?? []
|
|
692
|
+
const alreadyAppended = appendedRef.current.has(threadId)
|
|
693
|
+
appendedRef.current.delete(threadId)
|
|
694
|
+
void finalizeStop({ threadId, parts, alreadyAppended, storeRef, resolveOutcome, setRuns })
|
|
695
|
+
return
|
|
696
|
+
}
|
|
561
697
|
|
|
562
698
|
controllersRef.current.delete(threadId)
|
|
563
699
|
// Read the accumulated parts straight out of the live run entry (runsRef mirrors `runs` as of
|
|
@@ -48,13 +48,25 @@
|
|
|
48
48
|
* that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
|
|
49
49
|
* asserted too, but as an observed constant with the doubling explained, not as evidence of a
|
|
50
50
|
* single attempt.
|
|
51
|
+
*
|
|
52
|
+
* (d) is a SEPARATE, later-release defect discovered in the same family: the F3 fix above made the
|
|
53
|
+
* PERSISTED thread settle correctly (to a resume, or to 'interrupted'), but left the hook's OWN
|
|
54
|
+
* `runs` state untouched by the unmount-cleanup effect — so on a fiber that survives (StrictMode
|
|
55
|
+
* double-invoke, `<Activity>` hide/show), `runs` kept reporting the aborted run as `'streaming'`
|
|
56
|
+
* forever, even once the persisted thread had already settled. Unlike (b)/(c) (which use a
|
|
57
|
+
* RESUMABLE transport, so a fresh `runs` entry gets created by the surviving resume), (d) uses a
|
|
58
|
+
* NON-resumable transport — the case where nothing ever replaces the phantom entry, and `stop()`
|
|
59
|
+
* on it was a permanent no-op (see this hook's `stop()` JSDoc). Verified manually: reverting the
|
|
60
|
+
* `runs` teardown in the unmount-cleanup effect makes (d) fail — `runs.has(threadId)` stays `true`
|
|
61
|
+
* after the thread settles to `'interrupted'` — while (a)/(b)/(c) are unaffected by that revert.
|
|
51
62
|
*/
|
|
52
63
|
import { describe, expect, test } from 'bun:test'
|
|
64
|
+
import type { JSX } from 'react'
|
|
53
65
|
import { Activity, StrictMode, useState } from 'react'
|
|
54
66
|
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
55
67
|
import { useAgentThreadRuns } from './use-agent-thread-runs'
|
|
56
68
|
import type { AgentThread, ThreadsStore } from './thread'
|
|
57
|
-
import type { AgentTransport } from './transport'
|
|
69
|
+
import type { AgentTransport, ResumableAgentTransport } from './transport'
|
|
58
70
|
import type { AgentPart } from './parts'
|
|
59
71
|
import type { ChatMessage } from './history'
|
|
60
72
|
import type { AgentOutcome } from './outcome'
|
|
@@ -134,6 +146,11 @@ function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): Threads
|
|
|
134
146
|
threads = []
|
|
135
147
|
activeId = null
|
|
136
148
|
},
|
|
149
|
+
// Always-hydrated, never-erroring — mirrors the localStorage-backed store's real values
|
|
150
|
+
// (see ThreadsStore.hydrated/.error doc comments); this double is a synchronous in-memory
|
|
151
|
+
// stand-in with no async load path to fail.
|
|
152
|
+
hydrated: true,
|
|
153
|
+
error: undefined,
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
156
|
|
|
@@ -199,7 +216,7 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
199
216
|
const userMessage: ChatMessage<AgentPart> = {
|
|
200
217
|
id: crypto.randomUUID(),
|
|
201
218
|
role: 'user',
|
|
202
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
219
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
203
220
|
createdAt: Date.now(),
|
|
204
221
|
}
|
|
205
222
|
const thread = makeThread({
|
|
@@ -210,11 +227,11 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
210
227
|
const store = createTestThreadsStore([thread])
|
|
211
228
|
|
|
212
229
|
let resumeCalls = 0
|
|
213
|
-
const transport = {
|
|
230
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
214
231
|
async *stream() {},
|
|
215
232
|
async *resume() {
|
|
216
233
|
resumeCalls++
|
|
217
|
-
yield { type: 'text', text: 'resumed' }
|
|
234
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
218
235
|
},
|
|
219
236
|
idempotentReplay: true as const,
|
|
220
237
|
}
|
|
@@ -235,7 +252,7 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
235
252
|
const userMessage: ChatMessage<AgentPart> = {
|
|
236
253
|
id: crypto.randomUUID(),
|
|
237
254
|
role: 'user',
|
|
238
|
-
parts: [{ type: 'text', text: 'hello' }],
|
|
255
|
+
parts: [{ id: 'seed-user-part', type: 'text', text: 'hello' }],
|
|
239
256
|
createdAt: Date.now(),
|
|
240
257
|
}
|
|
241
258
|
const thread = makeThread({
|
|
@@ -251,12 +268,12 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
251
268
|
const gate = deferred<void>()
|
|
252
269
|
|
|
253
270
|
let resumeCalls = 0
|
|
254
|
-
const transport = {
|
|
271
|
+
const transport: ResumableAgentTransport<AgentPart, string> = {
|
|
255
272
|
async *stream() {},
|
|
256
273
|
async *resume() {
|
|
257
274
|
resumeCalls++
|
|
258
275
|
await gate.promise
|
|
259
|
-
yield { type: 'text', text: 'resumed' }
|
|
276
|
+
yield { id: 'p1', type: 'text', text: 'resumed' }
|
|
260
277
|
},
|
|
261
278
|
idempotentReplay: true as const,
|
|
262
279
|
}
|
|
@@ -298,4 +315,73 @@ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', ()
|
|
|
298
315
|
})
|
|
299
316
|
expect(resumeCalls).toBe(2)
|
|
300
317
|
})
|
|
318
|
+
|
|
319
|
+
test('(d) BLOCKING regression: a non-resumable run aborted by an <Activity> hide/show cycle leaves no phantom `runs` entry, and stop() still reaches a terminal state', async () => {
|
|
320
|
+
const store = createTestThreadsStore()
|
|
321
|
+
|
|
322
|
+
// No `resume` at all — unlike (c), this transport cannot be resumed. Hangs until aborted;
|
|
323
|
+
// never resolves on its own (matching the real playground repro this test pins).
|
|
324
|
+
const transport: AgentTransport<AgentPart, string> = {
|
|
325
|
+
// oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
|
|
326
|
+
async *stream(_input, signal) {
|
|
327
|
+
await new Promise((resolve) => {
|
|
328
|
+
signal?.addEventListener('abort', () => resolve(undefined))
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
|
|
334
|
+
let hookResult: ReturnType<typeof useAgentThreadRuns<AgentPart>> | undefined
|
|
335
|
+
|
|
336
|
+
function Probe(): null {
|
|
337
|
+
hookResult = useAgentThreadRuns({ transport, store, resolveOutcome })
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function Harness(): JSX.Element {
|
|
342
|
+
const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
|
|
343
|
+
externalSetMode = setMode
|
|
344
|
+
return (
|
|
345
|
+
<Activity mode={mode}>
|
|
346
|
+
<Probe />
|
|
347
|
+
</Activity>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await act(async () => {
|
|
352
|
+
render(<Harness />)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
const threadId = store.create()
|
|
356
|
+
act(() => {
|
|
357
|
+
hookResult?.start(threadId, 'hello')
|
|
358
|
+
})
|
|
359
|
+
expect(hookResult?.runs.get(threadId)?.status).toBe('streaming')
|
|
360
|
+
|
|
361
|
+
// Hide then show — the cleanup-without-unmount path. The fiber (and its refs/state) survive;
|
|
362
|
+
// only its effects are destroyed and recreated, exactly as React 19.2's <Activity> does in
|
|
363
|
+
// PRODUCTION (see the file doc above) or as StrictMode does on every dev mount.
|
|
364
|
+
await act(async () => {
|
|
365
|
+
externalSetMode?.('hidden')
|
|
366
|
+
})
|
|
367
|
+
await act(async () => {
|
|
368
|
+
externalSetMode?.('visible')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// The BLOCKING defect this test pins: `runs` must not still report this thread as 'streaming'
|
|
376
|
+
// once the persisted thread has settled to 'interrupted' — that phantom entry is what made
|
|
377
|
+
// stop() a permanent no-op.
|
|
378
|
+
expect(hookResult?.runs.has(threadId)).toBe(false)
|
|
379
|
+
|
|
380
|
+
// stop() on the now-settled thread reaches (stays at) a terminal state — not a wedge.
|
|
381
|
+
act(() => {
|
|
382
|
+
hookResult?.stop(threadId)
|
|
383
|
+
})
|
|
384
|
+
expect(store.threads.find((t) => t.id === threadId)?.status).toBe('interrupted')
|
|
385
|
+
expect(hookResult?.runs.has(threadId)).toBe(false)
|
|
386
|
+
})
|
|
301
387
|
})
|