perf(conversation): fold packed assistant history

This commit is contained in:
imccyu
2026-08-25 20:15:06 +08:00
parent 1ec75c9082
commit f37bb35a97
44 changed files with 1323 additions and 375 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: ea2505d4a72f483a9df6fcd78d7e5c9a96b02f5c
2026-08-09-client-conversation-node-assembly.zh.md: b87f127d753cadf2805ed5cd948fc58ad01830aa
2026-08-09-client-conversation-node-assembly.md: 12069d129227cce13eb5f9f39e636921d4bf9efa
2026-08-09-client-conversation-node-assembly.zh.md: 957c2b291293761ff2417f60093b7962bb75bbdf
@@ -8,13 +8,13 @@ English | [中文](2026-08-09-client-conversation-node-assembly.zh.md)
Client Session owned transport windows, connection state, and pending interactions while also interpreting Assistant, Tool, message, command, compaction, retry, and turn-tail events in a centralized transcript fold. Adding one business node required changes to Session switches, history replay, indexes, caches, and React grouping; business identity, state evolution, and final presentation had no independent owner.
The old path also placed running Assistant and Tool values outside the finalized flow. They entered the log-ordered node list only after settlement, so their React parent changed and remounted them even when the business ID and `key` remained stable. Full history loads, older prepends, live appends, and token streaming used separate update paths, leaving reference stability and local recomputation dependent on specialized caches spread across the client.
Without target-neutral assembly, running Assistant and Tool values sit outside the finalized flow and enter the log-ordered node list only after settlement. Their React parent then changes and remounts them even when the business ID and `key` remain stable. Separate update paths for full history loads, older prepends, live appends, and token streaming also make reference stability and local recomputation depend on specialized caches spread across the client.
Business events also use different correlation models. Tool has call IDs, Assistant correlates by turn and step, Compaction has its own lifecycle and checkpoint, and an Inbox splice represents one instantaneous state in a sequence. Keeping all these distinctions in one fold would make every business change pass through a global lookup and invalidate unrelated caches.
## Decision
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous Event window to the engine and publishes its snapshot instead of interpreting individual conversation businesses.
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
@@ -22,20 +22,20 @@ This Note retains the derivation, business-by-business validation, responsibilit
| Layer | Durable responsibility | Explicitly does not own |
|---|---|---|
| Session | Maintain the contiguous Event window, distinguish replace, prepend, and append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events |
| Session | Maintain the contiguous logical-event window, distinguish replace, prepend, and scalar append, and schedule snapshot notifications | Interpret Tool, Assistant, Compaction, or other business events |
| Event Registry | Retain the unique-`kind` Definitions and sole fallback under Cordis lifecycles | Store one Session's Context or State |
| Assembler | Match Events and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering |
| Assembler | Match standard events or packed runs and maintain Contexts, Locations, dependencies, and the publication dirty set | Interpret business State fields or Chat ordering |
| Node Definition | Define one business object's identity, State transitions, Location data, and target Node | Create Contexts, mutate another business's State, or scan all Contexts |
| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret raw Session Events |
| View Builder | Incrementally organize final target Nodes into that view's snapshot | Reinterpret `SessionEventLike` inputs |
| React renderer | Render renderer-owned data by the final Node's `kind` and read business data from the current Node's Location | Pair business Events, scan global Nodes, or decide business lifecycle state |
Registry contributions are Cordis effects. Removing a Definition causes a low-frequency registry rebuild for existing Sessions; ordinary business Events do not change the Registry or rebuild every business type.
### Overall `ConversationNodeDefinition` contract
Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from `SessionEventLike` inputs to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`.
One input may be claimed by several ordinary Definitions. For example, an Assistant event or packed run updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`.
A Definition holds no mutable business data across Sessions. Each Session's Assembler isolates that Session's Contexts, State, dependencies, and View Builders.
@@ -49,9 +49,9 @@ Each `(kind, id)` has at most one start Match. A second start fails immediately;
#### `match(event)`
`match(event)` reads only the current raw `SessionEvent` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope.
`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A `chunkrow/*` event can only be an update; the Assembler rejects it as a start, and `start()` receives a `ConversationStartMatch` containing a standard `SessionEvent`.
This restriction makes one Event's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
This restriction makes one scalar event or packed run's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
Start, result, resource, checkpoint, and business-owned terminal Events must carry or directly imply the same ID. If one Event cannot yield that ID, its producer extends the Event protocol; the Client does not guess from the "nearest unfinished object."
@@ -59,9 +59,9 @@ The `role` describes the State lifecycle, not visibility. A start may produce a
#### `ConversationMatch`
After a successful match, the Assembler combines the raw Event, optional wire presentation view, `role`, and engine-computed `location` into a read-only `ConversationMatch`.
After a successful match, the Assembler combines the standard or packed event, `role`, and engine-computed `location` into a read-only `ConversationMatch`. A packed run remains one Match and retains its fragment and timestamp-gap arrays.
A Context's `matches` always remain in ascending Event `seq` order, not network arrival or pagination ingestion order. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result.
A Context's `matches` always remain in ascending first-`seq` order, not network arrival or pagination ingestion order. The Session journal has already rejected overlapping logical ranges. If a tail page supplies a result before an older page supplies its call, the final Match order still places the call before the result.
Location can change when prepend fills a boundary or append closes one. The Assembler replaces the affected Matches' read-only Locations and replays the Context; business code does not retain an old Location copy as authority.
@@ -71,8 +71,8 @@ Location can change when prepend fills a boundary or append closes one. The Asse
|---|---|---|
| `key` | Assembler | Stable final identity derived from `kind + id` |
| `kind` / `id` | Definition + Assembler | Current business namespace and business ID |
| `matches` | Assembler | Complete business evidence loaded in the current window and sorted by `seq` |
| `start` | Assembler | Unique start Match, or `undefined` before it loads |
| `matches` | Assembler | Complete scalar and packed business evidence loaded in the current window and sorted by first `seq` |
| `start` | Assembler | Unique scalar start Match, or `undefined` before it loads |
| `state` | Returned by Definition, held by Assembler | Most recent `start`/`update` return value, or `undefined` before initialization |
| `current` | Assembler | Most recently materialized Node or `null` for each target |
@@ -108,7 +108,7 @@ Dependencies point strictly from earlier starts to later starts, so transitive r
#### `update(context, match)`
`update()` handles a post-start Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the Event.
`update()` handles a post-start scalar or packed Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. A Definition that consumes Assistant deltas folds each matching `chunkrow/*` value as one batch without constructing member events.
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
@@ -126,9 +126,9 @@ The Assembler does not use State reference equality to decide publication or pro
| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
| `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
Omitting `publication()` means `immediate`. Assistant token deltas use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
Every delta within a frame still executes update. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no tokens are lost.
Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost.
#### `buildLocationData(context, scope)`
@@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing
### Location is a first-class engine fact
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`.
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps standard events and packed runs to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`. All members of a row share its turn, step, block index, and delta kind, so the row needs one Location entry at its first `seq`.
Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events.
@@ -168,31 +168,31 @@ Each Turn and Step also carries a reference-stable Location data store. A Defini
`unresolved` means the current history window lacks sufficient preceding boundaries; it does not mean session-level. When older prepend supplies those boundaries, the index corrects Match Locations and replays only Contexts that own those seqs.
An appended ordinary Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the expanded contiguous window, but reference-stability logic retains unchanged Turn and Step objects.
An appended standard Event only inherits current coordinates, while an appended boundary recalculates only its owning Turn. Prepend rebuilds Location facts from the contiguous `SessionEventLikeEntry` window, but reference-stability logic retains unchanged Turn and Step objects.
The Assembler also passes a reference-stable timeline to each View Builder. Businesses do not separately maintain turn order, step lists, last-step values, or boundary Maps.
## Three Event-window paths
## Three input-window paths
"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. Regardless of history API order or page-loading direction, the Assembler canonicalizes each current window and each fresh page in ascending `seq` order.
"Backward history scanning" describes the UI loading pages from the newest tail toward the Session beginning; it does not mean a Definition executes `update()` in reverse. The Session journal validates each record's logical range before publication. Regardless of page-loading direction, the Assembler orders every accepted standard event or packed run by its first `seq`.
| Scenario | Input range | Context and State handling | View Builder |
|---|---|---|---|
| Initial history tail or resync | Current complete contiguous window | Clear and rebuild all Contexts in ascending `seq` order | `replace()` |
| Load one older-history page | Only deduplicated fresh Events before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` |
| Initial history tail or resync | Current complete contiguous logical window | Clear and rebuild all Contexts in ascending first-`seq` order | `replace()` |
| Load one older-history page | Only range-validated fresh standard events or packed runs before the window | Retain existing Context identity, then add Matches, Locations, dependencies, and local replays | `apply(upserts)` |
| Live append | One contiguous tail Event | Match Definitions and update only the exact IDs; boundaries affect only their owning Turn | `apply(upserts)` |
### Initial history tail and logical backward scanning
1. `Session.open()` loads the latest tail page and passes its contiguous History Entries to `replaceWindow(entries, hasMore)`.
1. `Session.open()` loads the latest tail page and passes its contiguous `SessionEventLike` entries to `replaceWindow(entries, hasMore)`.
2. `replaceWindow` clears old Contexts, start-seq indexes, seq reverse indexes, Reader dependencies, and the input Map.
3. It sorts every entry by Event `seq` and stores the resulting current window.
3. It sorts every entry by its first logical `seq` and stores the resulting current window.
4. LocationIndex rebuilds Turn and Step facts for that window.
5. The Assembler visits Events in ascending order and invokes every ordinary Definition's `match(event)`.
5. The Assembler visits standard events and packed runs in ascending order and invokes every ordinary Definition's `match(event)`.
6. Each result gets or creates its `(kind, id)` Context and enters that Context's ordered Match array.
7. A start runs `start()`; a tail update on initialized State runs `update()` directly.
8. If the page contains only a result or resource and omits its start, the ID still creates a Context and collects Matches, while State remains `undefined`.
9. After matching all Events, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them.
9. After matching all inputs, the Assembler rechecks Reader dependencies so earlier instantaneous states in the same window stabilize before later consumers read them.
10. Every Context becomes dirty, and the next flush fully rebuilds Location data in Step→Turn order before invoking `buildViewNode()` for every target.
11. Some businesses return `null` without a start; Compaction, Command, Tool result, and Turn Error can construct fallback Nodes from sufficient update evidence.
12. Each View Builder receives the complete Node set and timeline and establishes the initial snapshot through `replace()`.
@@ -206,12 +206,12 @@ If an update with the same ID is genuinely earlier than the start in log order,
### Prepending a newly loaded older page
1. `Session.loadOlder()` requests the immediately preceding page using the current `baseSeq` and first verifies continuity between the page tail and current window.
2. Session prepends the raw Event and view arrays to its own window and passes only that page to `assembler.prepend(entries, hasMore)`.
3. The Assembler removes seqs that overlap the current window, then sorts the fresh page internally in ascending order.
2. Session prepends the accepted standard or packed entries to its own window and passes only that page to `assembler.prepend(entries, hasMore)`.
3. The journal has already removed complete duplicate ranges and rejected partial overlaps; the Assembler sorts the fresh page by first `seq`.
4. Existing Contexts, State, current Nodes, and View Builder instances remain intact.
5. LocationIndex rebuilds facts over the expanded complete input and reports seqs whose Location identity actually changed.
5. LocationIndex rebuilds facts over the extended complete input and reports seqs whose Location identity actually changed.
6. Contexts owning those seqs update their Match Locations and replay from start; unrelated Contexts do not join Location replay.
7. Fresh Events run Definition matchers and enter existing or new Contexts by stable ID.
7. Fresh standard events and packed runs enter existing or new Contexts through the same Definition matcher and stable ID.
8. If the new page supplies a pending Context's start, that Context initializes from the start and then applies every already-collected update in ascending order.
9. If the page establishes a nearer Reader predecessor, changes a predecessor revision, or removes a window gap, the consumer recomputes from `start()`.
10. Reader dependencies propagate replay toward later start seqs; no Event is applied in reverse within the propagation batch.
@@ -226,7 +226,7 @@ Reader gap repair is the largest algorithmic difference between prepend and ordi
### Forward live append
1. Session accepts only a live Event immediately after the current tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap.
1. Session accepts only a standard live Event immediately after the current logical tail seq; it deduplicates overlap and runs tail-page repair before accepting a gap.
2. A non-boundary Event enters the current Turn and Step coordinates incrementally; a boundary Event updates Location facts for its owning Turn.
3. The Assembler invokes `match()` once on every ordinary Definition for this Event and scans no Definition's Context set.
4. Each successful result directly locates one Context through `(kind, id)`.
@@ -249,7 +249,7 @@ All three paths preserve the same invariants: Context Matches are seq-ordered, S
`replaceWindow` is the low-frequency complete replacement for initial open, resync, gap repair, and registry changes; it does not implement ordinary load older. Both `prepend` and `append` retain existing Builder and Context identity.
Page size, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for an equal Event window.
Page size, record packing, the number of history loads, and RAF coalescing affect only when evidence arrives or publishes. They do not change final Context State and Nodes for equal logical evidence.
## How built-in businesses use Definitions
@@ -261,7 +261,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
| Automatic Compaction / `compaction` | Compaction ID | `compaction/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
@@ -278,7 +278,7 @@ Page size, the number of history loads, and RAF coalescing affect only when evid
| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices |
| Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key |
| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor |
| Assistant | RAF for chunks, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Matches support fallback without `step/start`; Location close produces interruption presentation |
| Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation |
| Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key |
| Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key |
| Compaction | Immediate by default | `compaction` marker | A checkpoint may render before start; an older start triggers forward replay |
@@ -326,20 +326,20 @@ Slot-level contextual Hooks and entry-owned `inject.hooks` remain independent pa
The standard `useSession` remains available to every session-scoped slot renderer. `useTurnData()` narrows the common read path rather than acting as a permission sandbox. Whole-window statistics or arbitrary object indexes may still read the Session snapshot explicitly, but they are not modeled as current-Node Turn data.
Assistant streaming to final and Tool running to settled update only one Seat's data and necessary ordering properties. They no longer move from a tail running container into finalized flow, so settlement does not reset component-local State.
Assistant streaming to final and Tool running to settled stay in one Seat while updating its data and necessary ordering properties. Settlement therefore does not reset component-local State through a parent move.
When business logic deliberately changes a materialized Node to hidden, it leaves visible order and remounts when visible again. This is explicit business withdrawal of presentation, distinct from the stable-Seat guarantee for running→settled.
The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot.
Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
Trajectory registers its own target and business Definitions against the same Assembler and `SessionEventLikeEntry` window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. Chat and Trajectory keep independent scalar and packed Assistant reducers; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
The target-specific Trajectory Definitions, retained stage model, Steering adaptation, complexity bounds, and presentation hot paths are owned by the [Trajectory Context assembly decision](2026-08-11-trajectory-conversation-context-assembly.md).
## Runtime and render path
```text
Session Event window
SessionEventLike window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
@@ -361,7 +361,7 @@ Slot type/runtime tests pin required parent-provided common inject, the `hookCon
Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout.
History-path tests cover complete replace, non-overlapping prepend, overlapping-seq deduplication, empty-page `hasMore` convergence, and live append. Equal Event windows ingested through different paths produce equal business State and final Nodes.
History-path tests cover complete replace, non-overlapping prepend, complete-range deduplication, partial-overlap rejection, empty-page `hasMore` convergence, and scalar live append. Scalar and packed representations of the same Assistant history produce equal Chat and Trajectory State, timing boundaries, and final Nodes; one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
## Alternatives considered
@@ -377,6 +377,8 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping-
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
**Add a separate chunk-run matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. `ChunkRowEvent` uses the existing `match(event)` and `update(context, match)` lifecycle while making packed handling explicit through its `chunkrow/*` discriminant.
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
**Register specialized query methods for cross-business reads.** Rejected: consumers would still depend on provider APIs, and each new relationship would expand a central interface. Reader exposes a named kind's read-only predecessor Context; the provider writes useful State and the consumer interprets it.
@@ -399,14 +401,14 @@ A new business node can register its matcher, State transitions, optional Locati
Host business packages declaration-merge their durable Event members into `@deepseek-ai/dsh-session/types`, while Client Definitions type-only import the corresponding business package `/types` subpaths. Augmenting the declaring interface rather than a re-export barrel gives the independent Host and Client TypeScript programs the same Event narrowing without pulling Host runtime into the Client graph.
Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and high-frequency deltas are explicit engine states and require no direction-specific business cache.
Initial tail, older prepend, and live append share one set of Context invariants. Missing starts, Reader window gaps, unknown Locations, and packed high-frequency deltas are explicit engine states and require no direction-specific business cache.
Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
Separating State updates from publication cadence folds every Assistant delta while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
Steps and Turns become stable homes for cross-business aggregates. Turn Tail and Deliverables no longer depend on renderers scanning global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates.
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
@@ -8,13 +8,13 @@ Status: implemented
Client Session 既维护传输窗口、连接状态和待处理交互,也在中心化 transcript fold 中解释 Assistant、Tool、消息、命令、压缩、重试及 turn tail 等业务事件。每增加一种业务节点,都要修改 Session 的 switch、历史 replay、索引、缓存和 React 分组;业务 identity、状态演进与最终展示没有独立所有者。
旧链路还把运行中的 Assistant 和 Tool 放在 finalized flow 之外。它们结算后才进入按日志排序的节点列表,因此 React parent 改变,即使业务 ID 和 `key` 不变也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 分别走不同更新路径,使引用稳定和局部重算只能各处特化缓存维持
缺少 target-neutral assembly 时,运行中的 Assistant 和 Tool 会位于 finalized flow 之外结算后才进入按日志排序的节点列表React parent 因而改变,即使业务 ID 和 `key` 稳定也会重新挂载。全量历史加载、older prepend、实时 append 与 token streaming 分别走不同更新路径,引用稳定和局部重算只能依赖各处特化缓存。
业务事件之间的关联方式并不统一。Tool 有 call IDAssistant 以 turn/step 关联,Compaction 有独立生命周期和 checkpointInbox splice 则表示一个连续状态的瞬间。把这些差异继续塞进统一 fold,会让任一业务变化都经过全局查表并使无关缓存失效。
## 决策
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builderSession 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不解释具体 conversation 业务。
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builderSession 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
@@ -22,20 +22,20 @@ Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务
| 层 | 长期职责 | 明确不负责 |
|---|---|---|
| Session | 维护连续 Event 窗口,区分 replace、prependappend,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 |
| Session | 维护连续逻辑 event window,区分 replace、prepend 与 scalar append,调度 snapshot 通知 | 解释 Tool、Assistant、Compaction 等业务事件 |
| Event Registry | 按 Cordis 生命周期保存唯一 `kind` 的 Definition 和唯一 fallback | 保存某个 Session 的 Context 或 State |
| Assembler | 匹配 Event,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 |
| Assembler | 匹配标准 event 或 packed run,维护 Context、Location、依赖和发布脏集 | 理解业务 State 字段或 Chat 排序 |
| Node Definition | 定义一个业务对象的 identity、State 演进、Location data 和 target Node | 创建 Context、修改别的业务 State 或扫描全部 Context |
| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释原始 Session Event |
| View Builder | 把最终 target Node 增量整理成该视图的 snapshot | 重新解释 `SessionEventLike` input |
| React renderer | 按最终 Node 的 `kind` 展示 renderer-owned data,并读取当前 Node 所属 Location 的只读业务 data | 配对业务 Event、扫描全局 Nodes 或决定业务生命周期 |
Registry 注册是 Cordis effectDefinition 卸载会触发现有 Session 的低频 registry rebuild。普通业务 Event 不改变 Registry,也不会因此重建全部业务类型。
### `ConversationNodeDefinition` 总体契约
每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 `SessionEventLike` input 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
同一个 input 可以被多个普通 Definition 认领。例如一条 Assistant event 或 packed run 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
Definition 不持有跨 Session 的可变业务数据。每个 Session 的 Context、State、依赖和 View Builder 都由该 Session 的 Assembler 隔离持有。
@@ -49,9 +49,9 @@ Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同
#### `match(event)`
`match(event)` 只读取当前原始 `SessionEvent`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。
`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }``null`。它拿不到 Context、历史、Reader、Location 或 view envelope。`chunkrow/*` event 只能作为 updateAssembler 会拒绝 packed start`start()` 接收的 `ConversationStartMatch` 只包含标准 `SessionEvent`
这项限制使单条 Event 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
这项限制使单条 scalar event 或 packed run 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
start、result、resource、checkpoint 及业务自有终止 Event 必须携带或可直接推导同一 ID。若单个 Event 不能算出 ID,生产 Event 的协议负责补足关联字段,Client 不通过“最近一个未完成对象”猜测。
@@ -59,9 +59,9 @@ start、result、resource、checkpoint 及业务自有终止 Event 必须携带
#### `ConversationMatch`
匹配成功后,Assembler 把原始 Event、可选的 wire presentation view`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`
匹配成功后,Assembler 把标准或 packed event`role` 和引擎计算的 `location` 组成只读 `ConversationMatch`一个 packed run 始终只占一个 Match,并保留 fragment 与 timestamp-gap 数组。
Context 的 `matches` 永远按 Event `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。
Context 的 `matches` 永远按 `seq` 升序保存,而不是按网络到达或分页摄入顺序保存。Session journal 已经拒绝逻辑 range 重叠。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。
Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assembler 替换受影响 Match 的只读 Location 并 replay Context;业务不把旧 Location 副本当权威保存。
@@ -71,8 +71,8 @@ Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assemb
|---|---|---|
| `key` | Assembler | `kind + id` 的稳定最终 identity |
| `kind` / `id` | Definition + Assembler | 当前业务命名空间和业务 ID |
| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整业务证据 |
| `start` | Assembler | 唯一 start Match;尚未加载时为 `undefined` |
| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整 scalar 与 packed 业务证据 |
| `start` | Assembler | 唯一 scalar start Match;尚未加载时为 `undefined` |
| `state` | Definition 返回、Assembler 持有 | 最近一次 `start`/`update` 返回值;未初始化时为 `undefined` |
| `current` | Assembler | 各 target 最近一次 materialize 的 Node 或 `null` |
@@ -108,7 +108,7 @@ Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前
#### `update(context, match)`
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start Match。它不判断 Event 属于哪个 Context。
`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start scalar 或 packed Match。它不判断 input 属于哪个 Context。消费 Assistant delta 的 Definition 会把每个匹配的 `chunkrow/*` 值作为一个 batch fold,而不构造成员 event。
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
@@ -126,9 +126,9 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成
| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
| `none` | 本 Match 不主动安排 flushState 和 dirty 标记仍被保留 |
省略 `publication()` 等于 `immediate`。Assistant token delta 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
一帧内的每条 delta 仍执行 update;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 token。
一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 fragment
#### `buildLocationData(context, scope)`
@@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定
### Location 是一级引擎事实
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start``step/start`、显式 turn/step payload、`step/end``turn/end` 建立 Event 到 Location 的映射。
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start``step/start`、显式 turn/step payload、`step/end``turn/end` 建立标准 event 与 packed run 到 Location 的映射。同一 row 的成员共享 turn、step、block index 与 delta kind,因此只需以首 `seq` 建立一条 Location entry。
Location 有 `session``turn``step``unresolved` 四种形状。Turn/Step 各自带 `open``closed``unknown` 状态,以及已加载的 start/end Event。
@@ -168,31 +168,31 @@ Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/St
`unresolved` 表示当前历史窗口缺少足够前序边界,不等于 session-level。older prepend 补入边界后,索引修正 Match Location,并只 replay 拥有这些 seq 的 Context。
Append 普通 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于扩展后的完整连续窗口重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。
Append 标准 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于连续 `SessionEventLikeEntry` window 重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。
Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复维护 turn order、step list、last step 或边界 Map。
## 三种事件窗口链路
## 三种 input window 链路
“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`无论历史 API 返回顺序或页面加载方向如何,Assembler 对每个当前窗口和每个 fresh page 都按 `seq` 升序 canonicalize
“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 `update()`Session journal 会在发布前校验每条 record 的逻辑 range;无论分页加载方向如何,Assembler 都按每个已接受标准 event 或 packed run 的首 `seq` 排序
| 场景 | 输入范围 | Context/State 处理 | View Builder |
|---|---|---|---|
| 初始历史尾页或 resync | 当前完整连续窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` |
| 加载一页 older history | 只传更早且去重后的 fresh Events | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` |
| 初始历史尾页或 resync | 当前完整连续逻辑窗口 | 清空并按 `seq` 正序重建全部 Context | `replace()` |
| 加载一页 older history | 只传通过 range 校验的更早标准 event 或 packed run | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` |
| 实时 append | 一条连续尾部 Event | 只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turn | `apply(upserts)` |
### 初始历史尾页与逻辑反扫
1. `Session.open()` 拉取最新 tail page,并把连续 History Entries 交给 `replaceWindow(entries, hasMore)`
1. `Session.open()` 拉取最新 tail page,并把连续 `SessionEventLike` entry 交给 `replaceWindow(entries, hasMore)`
2. `replaceWindow` 清空旧 Context、start-seq 索引、seq 反向索引、Reader 依赖和输入 Map。
3. 全部 entries 按 Event `seq` 升序排序并写入当前窗口。
3. 全部 entry 按首个逻辑 `seq` 升序排序并写入当前窗口。
4. LocationIndex 对这个窗口重建 Turn/Step facts。
5. Assembler 按升序 Event 逐条调用每个普通 Definition 的 `match(event)`
5. Assembler 按升序访问标准 event 与 packed run,并逐条调用每个普通 Definition 的 `match(event)`
6. 每个命中结果按 `(kind, id)` 取得或创建 Context,并把 Match 插入该 Context 的有序数组。
7. 遇到 start 时执行 `start()`;已有 State 的尾部 update 直接执行 `update()`
8. 当前页只含 result/resource 而缺 start 时,Context 仍会按 ID 创建并收集 Matches,但 State 保持 `undefined`
9. 全部 Event 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
9. 全部 input 匹配后,Assembler 复查 Reader 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
10. 所有 Context 标记 dirty,下一次 flush 先按 Step→Turn 完整重建 Location data,再对每个 target 调用 `buildViewNode()`
11. 某些业务在缺 start 时返回 `null`Compaction、Command、Tool result 或 Turn Error 等可根据充分 update 证据构造 fallback Node。
12. 每个 View Builder 收到完整 Node 集和 timeline,通过 `replace()` 建立初始 snapshot。
@@ -206,12 +206,12 @@ Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复
### 新 older 分页的 prepend
1. `Session.loadOlder()` 以当前 `baseSeq` 拉取紧邻前页,并先验证页尾与当前窗口连续。
2. Session 把 raw Event/view 数组 prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`
3. Assembler 按 seq 去掉与当前窗口重叠的 Events,再把 fresh page 内部升序排列
2. Session 把已接受的标准或 packed entry prepend 到自己的窗口,只把这一页传给 `assembler.prepend(entries, hasMore)`
3. Journal 已经丢弃完整重复 range 并拒绝部分重叠;Assembler 再按首 `seq` 排列 fresh page。
4. 已存在的 Context、State、current Nodes 和 View Builder 实例不清空。
5. LocationIndex 用扩展后的完整输入重建 facts,并报告 Location identity 真正变化的 seq。
6. 拥有这些 seq 的 Context 更新 Match Location,并从 start replay;无关 Context 不参与 Location replay。
7. fresh Events 逐条执行 Definition matcher,并按稳定 ID 入已有或新 Context 的有序 Matches
7. fresh 标准 event 与 packed run 通过同一 Definition matcher稳定 ID 入已有或新 Context。
8. 新页补出 pending Context 的 start 时,该 Context 从 start 初始化,再正序应用已经收集的所有 updates。
9. 新页建立更近的 Reader predecessor、改变 predecessor revision 或消除 window gap 时,消费者从 `start()` 重算。
10. Reader 依赖沿 start seq 向后传递 replay;同一传播批次不会把 Event 逆序应用。
@@ -226,7 +226,7 @@ Reader gap 修复是 prepend 与普通 append 最大的算法差异。新页不
### 正向实时 append
1. Session 只接受紧邻当前 tail seq 的 live Event;重叠 seq 去重,出现 gap 时先走 tail-page repair。
1. Session 只接受紧邻当前逻辑 tail seq 的标准 live Event;重叠去重,出现 gap 时先走 tail-page repair。
2. 非边界 Event 增量写入当前 Turn/Step 坐标;边界 Event 更新所属 Turn 的 Location facts。
3. Assembler 对这一个 Event 的每个普通 Definition 调用一次 `match()`,不会遍历任何 Definition 的 Context 集合。
4. 每个命中结果通过 `(kind, id)` 直接定位一个 Context。
@@ -249,7 +249,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
`replaceWindow` 是初始打开、resync、gap repair 和 registry 变化的低频完整替换,不用于实现普通 load older。`prepend``append` 都保留现有 Builder 和 Context identity。
分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。
分页页宽、record packing、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变逻辑证据相同时的最终 Context State 与 Node。
## 内建业务如何使用 Definition
@@ -261,7 +261,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
| Assistant / `assistant-step` | `turn:step` | `step/start` | `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent MapDispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compaction/start` | summary、end、replacement checkpoint | 聚合 summary/checkpointcheckpoint 足够时可在缺 start 下 fallback |
@@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 |
| Message | 默认 immediate | `user``steering``context` | window gap 修复可让同一 message key 重新分类 |
| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 |
| Assistant | chunk 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Assistant | scalar chunk 与 packed run 为 RAFfinal immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;`step/start` 可先用 Matches fallbackLocation close 生成中断表现 |
| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallbackrunning→settled 保持 key |
| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key |
| Compaction | 默认 immediate | `compaction` marker | checkpoint 可先展示,older 补 start 后正序 replay |
@@ -326,20 +326,20 @@ Slot-level contextual Hook 与 entry-owned `inject.hooks` 是两条独立路径
标准 `useSession` 仍属于所有 session-scoped slot renderer 的公开能力,`useTurnData()` 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。
Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat data 和必要的排序属性,不再从末尾 running container 移入 finalized flow,因此组件内部 State 不因结算自动归零
Assistant streaming 到 final、Tool running 到 settled 始终留在同一个 Seat,只更新 data 和必要的排序属性。结算不会因跨 parent 移动而重置组件内部 State。
业务主动把已发布 Node 改成 hidden 时,它会退出 visible order,恢复 visible 时会重新 mount。这是明确的业务撤显语义,与 running→settled 的稳定 Seat 保证不同。
具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.zh.md) 约束。Tool Definition 只交付递归 root/subcall data`ui-tool` 再按 Tool name keyed slot 分发具体表现。
Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slicetarget 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
Trajectory 针对与 Chat 相同的 Assembler 和 `SessionEventLikeEntry` window 注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat 与 Trajectory 分别维护独立的 scalar 和 packed Assistant reducertarget 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
target 专属 Trajectory Definition、保留的 stage model、Steering 适配、复杂度上界与表现层热点由 [Trajectory Context 组装决策](2026-08-11-trajectory-conversation-context-assembly.zh.md)负责。
## 运行时与渲染链路
```text
Session Event window
SessionEventLike window
-> ConversationNodeAssembler
-> Definition.match(event) -> (kind, id, start/update)
-> Context matches + State + Location
@@ -361,7 +361,7 @@ Slot type/runtime tests 固定父注册必须提供声明的 common inject、`ho
Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。
历史链路验证同时覆盖完整 replace、非重叠 prepend、重叠 seq 去重、空页 `hasMore` 收敛和 live append。相同 Event 窗口通过不同摄入路径得到相同业务 State 与最终 Node
历史链路验证同时覆盖完整 replace、非重叠 prepend、完整 range 去重、部分重叠拒绝、空页 `hasMore` 收敛和 scalar live append。相同 Assistant 历史的 scalar 与 packed 表示产生相同 Chat/Trajectory State、timing boundary 与最终 Node;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match
## 考虑过的替代方案
@@ -377,6 +377,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
**增加独立的 chunk-run matcher 与 update lifecycle。** 拒绝:第二条 Definition 路径会重复 dispatch、replay、publication 与 Context 类型。`ChunkRowEvent` 使用既有 `match(event)``update(context, match)` lifecycle,并通过 `chunkrow/*` discriminator 明确标记 packed 处理。
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
**给跨业务查询注册特化 query method。** 拒绝:消费者仍要依赖提供方 API,新增关系会扩张中心接口。Reader 暴露指定 kind 的只读前序 Context,由提供方写好 State、消费者读懂 State。
@@ -395,18 +397,18 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
## 后果
新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer无需修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。
Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deepseek-ai/dsh-session/types`Client Definition 则通过对应业务 package 的 `/types` 子路径进行 type-only import。增强实际声明接口而不是重导出 barrel,使 Host 和 Client 的独立 TypeScript Program 都能获得相同的 Event narrowing,同时不把 Host runtime 带入 Client 图。
初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
初始尾页、older prepend 和 live append 共享一套 Context 不变量。缺 start、Reader window gap、Location unknown 以及 packed 高频 delta 都是引擎明确表达的状态,不需要业务另建方向相关 cache。
Append 不扫描历史 Contextprepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
State 更新与发布频率分离后,Assistant 每条 delta 被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
State 更新与发布频率分离后,Assistant 每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 不再依赖 renderer 扫描全局 NodesSlot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
Step/Turn 业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuildChat Builder 继续为 StatsLine 和顶层公共字段维护 legacy sliceTrajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
2026-08-15-packed-session-history-transport.md: 155dedd8846894119fd62e0cc3aa28dcc0aaa466
2026-08-15-packed-session-history-transport.zh.md: 6069e66537e1b22144f35983ea1e3f85e07eaaa9
2026-08-15-packed-session-history-transport.md: 01e36509b7ad2c878ae4ea04c3a10f029e1b8f3d
2026-08-15-packed-session-history-transport.zh.md: 590385dcfac901ab01e472ee75e766e51bf4b001
@@ -6,19 +6,21 @@ English | [中文](2026-08-15-packed-session-history-transport.zh.md)
## Problem
`session.page` serves a bounded logical Session-event interval to remote clients. Provider streams can place hundreds of thousands of token-sized `assistant/chunk` events in one incomplete tail. Expanding every persisted row and then serializing every logical event repeats the same envelope on the wire and makes browser parsing and validation process that repetition before conversation replay can begin.
`session.page` and the opening `session.follow` snapshot serve a bounded logical Session-event interval to remote clients. Provider streams can place hundreds of thousands of token-sized `assistant/chunk` events in one incomplete tail. Expanding every persisted row and then serializing every logical event repeats the same envelope on the wire. Expanding a packed response at the Client boundary recreates the same event objects, journal entries, Location indexing, Definition matches, and State updates before conversation replay can finish.
The transport must remain lossless. Session sequence numbers are pagination and reconnect evidence; exact token boundaries remain useful to diagnostics and non-UI API consumers; live streaming, durable export, replay, and model-history derivation continue to require the canonical event stream. A server-side transcript projection that discards completed-step chunks would make the API's evidence depend on one UI policy.
The transport must remain lossless. Session sequence numbers are pagination and reconnect evidence; exact fragment boundaries and timestamps remain useful to diagnostics and non-UI API consumers; live streaming, durable export, replay, and model-history derivation continue to require the canonical event stream. Browser presentation does not require one allocated event object and one Definition callback per historical fragment when a Definition can fold the lossless run directly.
## Decision
`session.page` returns `records: SessionHistoryRecord[]`. An ordinary record carries `{event}`. Consecutive same-block Assistant delta events carry `{chunks: ChunkRow}` using the shared lossless codec from [the packed JSONL decision](2026-07-26-packed-chunk-rows-by-default.md). The page is selected from logical events before packing, so message-aligned pagination remains independent of physical persistence layout.
History pages and follow opening snapshots carry `records: SessionHistoryRecord[]`. An ordinary record is `{ type: 'event', event: SessionWireEvent }`; consecutive same-block Assistant delta events use `{ type: 'chunks', event: ChunkRowEvent }` and the shared lossless codec from [the packed JSONL decision](2026-07-26-packed-chunk-rows-by-default.md). The Host constructs the event-shaped value once when it packs the selected page. Its `type` is `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`; `seq` and `time` identify the first member, while `data` retains the original fragment and timestamp-gap arrays. The explicit outer discriminator selects the record class without interpreting that detailed chunk kind. The page is selected from logical events before packing, so message-aligned pagination remains independent of physical persistence layout.
The generated Remote decoder validates the response fields, and the shared row decoder rejects malformed rows and unsafe sequence or timestamp reconstruction. `SessionEventStream` expands the records before passing them to `RemoteJournalStream`; the journal therefore checks page continuity, pagination joins, reconnect repair, and live-event deduplication against the original event sequence numbers. The durable address in the page request selects either an ordinary Session or an authorized direct subagent child without a second history protocol.
The generated Remote decoder validates the response fields. `SessionEventStream` passes the original wire records to `RemoteJournalStream` and supplies each record's inclusive logical sequence range: an event covers `[event.seq, event.seq]`, while a row covers `[event.seq, event.seq + memberCount - 1]`. The journal checks page continuity, pagination joins, reconnect repair, complete duplicates, partial overlaps, and live-event deduplication before publishing records. The durable address in the page request selects either an ordinary Session or an authorized direct subagent child without a second history protocol.
The Client adapter calls the shared `decodeStorageRecord()` codec before publishing a page to the Session object layer. Every packed member becomes its exact original `assistant/chunk` event, including `seq`, timestamp, chunk type, block index, text or argument fragment, call identity, and optional-name presence. A registered `ConversationNodeDefinition` therefore receives one `match()` call per historical delta and folds accepted matches with the same start/update sequence it observes for live events. Packing changes transport encoding without changing the public Definition replay semantics.
The Client narrows the accepted `SessionHistoryRecord[]` to `SessionEventLikeEntry[]` without allocating replacement entries. The outer `type` remains available to the journal, Session, and assembler; both variants carry an inner value with aligned `type`, `seq`, `time`, and `data` fields. `ChunkRowEvent` is Client history data, not a durable Session event: it is absent from `SessionEventMap`, `Session.events`, and `session/event`.
Live `session.follow` frames remain individual events. Session persistence, raw export, replay, model-history derivation, and the canonical in-memory log are unchanged.
Conversation accepts the same `{ type, event }` entries retained by Session. Definitions receive the inner `SessionEventLike`: `match()` and `update()` accept standard or packed values, while `start()` accepts only a standard `SessionEvent`; the assembler uses the outer discriminator to reject a packed start. Chat Assistant, Turn Tail, and Trajectory Assistant handle the three packed tags in their existing reducers. One row therefore remains one Client entry, Conversation input, and Match, while those reducers preserve scalar replay's final blocks, tool-call fields, first-token time, first-visible boundary, retry behavior, and interruption state.
Live `session.follow` frames remain individual events and use the scalar path, so visible streaming cadence is unchanged. Session persistence, raw export, replay, model-history derivation, and the canonical in-memory log are unchanged.
## Measured result
@@ -32,15 +34,19 @@ A production-sized private session sample was measured without retaining or comm
Packing reduced uncompressed JSON by 90.8% relative to raw logical events and by 83.4% relative to the lossy completed-step projection candidate. Brotli output was 73.2% smaller than raw and 44.8% smaller than that projection candidate. These figures describe this sample rather than a protocol guarantee; savings scale with the length and regularity of delta runs.
The opt-in `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports the median of five exact decodes for 10,000-, 20,000-, and 40,000-member runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, projection work, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/decoding/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, exact decoded event count, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture.
One-to-one Client retention keeps the same sample at 696 history entries and Conversation inputs instead of restoring 416,756 event entries. A local synthetic benchmark run measured Client parse, validation, retention, and two-Definition fold at 4,682.11 ms for scalar input and 276.10 ms for packed input, with sampled additional V8 heap peaks of 612,523,344 and 199,436,928 bytes respectively. These machine-dependent values are observations rather than thresholds.
The opt-in `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports batch-fold medians for 10,000-, 20,000-, and 40,000-member whitespace-prefix runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/retention/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, one Client input per wire record, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture.
## Alternatives considered
**Discard completed-step chunks on the Host.** This lowers logical event count but makes transport semantics depend on the current transcript policy, removes exact evidence from all consumers, and still sends every retained incomplete-step token as a separate envelope. The measured packed response is smaller while remaining lossless.
**Coalesce a packed run before registered Definitions see it.** This reduces browser event objects and fold calls, but an open `ConversationNodeDefinition` may count deltas, inspect their individual `seq` or timestamps, or derive state from fragment boundaries. Equal accumulated text does not make those state machines equivalent, so the transport cannot change their replay input cardinality.
**Expand each packed row before the Session object layer.** This preserves one callback per historical delta but recreates the browser allocation, indexing, and fold costs that packed transport can avoid. Consumers that require scalar events can still call `decodeStorageRecord()` explicitly.
**Rely on HTTP content encoding.** gzip and Brotli reduce bytes on the network but do not remove repeated JSON parsing and validation. Packed rows remain substantially smaller after both encodings in the measured sample, while exact browser replay retains the required allocation and fold work.
**Put the raw row under a distinct `.chunks` payload.** This forces downstream consumers either to retain two payload field names or to allocate an aligned wrapper before assembly. The shared `.event` field preserves fast outer classification and one inner Definition path.
**Rely on HTTP content encoding.** gzip and Brotli reduce bytes on the network but do not remove repeated JSON parsing, validation, allocation, indexing, and fold work.
**Page directly over physical persistence rows.** This could also avoid logical expansion in a cold Host read, but page cuts depend on append-origin messages and replacement provenance rather than backend row boundaries. The current decision keeps the API independent of JSONL, SQLite, and future persistence layouts.
@@ -48,8 +54,8 @@ The opt-in `packages/client/ui-conversation/tests/history-transport.perf.client.
## Consequences
History responses preserve every logical event while reducing wire bytes, Host response serialization and heap, and browser JSON parsing and validation for long delta runs. The journal validates continuity after exact decoding, so packed transport records do not create false gaps. `SessionEventStream` consumers continue to receive ordinary event entries; direct `session.page` consumers must read the `SessionHistoryRecord` union and decode packed rows before event-level processing.
History responses preserve every logical event while reducing wire bytes, Host response serialization and heap, browser JSON parsing and validation, Client entry allocation, and Conversation dispatch for long delta runs. The journal validates logical ranges before publication, so packed records neither create false gaps nor hide partial overlap. Direct `session.page` consumers must switch on `SessionHistoryRecord.type` and explicitly expand `record.event.data` when they require one event per member.
Cold persisted history is still decoded into the complete logical `SessionEvent[]` before the Host selects and repacks a page. This decision therefore improves transport and browser work, not the Host's cold-read decode memory. Eliminating that expansion requires a persistence-neutral message-boundary index or a separate streaming page reader and remains a distinct optimization.
Browser history replay still allocates and folds one event per original token, so this decision does not reduce Definition match/update count or settled-history heap and may add a small decode-time peak while packed records and expanded events coexist. History installs as one batch rather than animating old tokens; live streaming behavior is unchanged.
The default Client history path exposes `SessionEventLike`, so consumers that require only canonical durable events must remain on Host `Session.events`, `session/event`, or an explicit decode path. A Definition that consumes Assistant deltas maintains equivalent scalar and packed branches. Scalar deltas already received live remain scalar in the current window; online replacement with a packed row is separate work, while reopen and reconnect install packed history.
@@ -6,19 +6,21 @@ Status: implemented
## 问题
`session.page` 会向远程客户端提供一段有界的逻辑会话事件区间。提供方流可能在一个未完成尾部中产生数十万个 token 大小的 `assistant/chunk` 事件。先展开每条持久化行,再序列化每个逻辑事件,会在协议中重复相同信封,并让浏览器在 conversation 回放开始前解析和校验这些重复内容
`session.page` `session.follow` opening snapshot 会向远程 Client 提供一段有界的逻辑 Session event 区间。提供方流可能在一个未完成尾部中产生数十万个 token 大小的 `assistant/chunk` 事件。先展开每条持久化行,再序列化每个逻辑事件,会在协议中重复相同 envelope。在 Client 边界展开 packed response 还会重新创建同样数量的 event object、journal entry、Location index、Definition match 和 State update,拖慢 conversation replay
传输必须保持无损。会话序号是分页与重连证据;精确 token 边界对诊断非 UI API 消费方仍然有用;实时流式传输、持久导出、回放与模型历史派生仍然需要规范事件流。如果由服务端 transcript 投影丢弃已完成步骤的分片,API 证据就会取决于一项 UI 策略
传输必须保持无损。Session seq 是分页与重连证据;精确 fragment 边界和时间戳对诊断非 UI API 消费方仍然有用;实时流式传输、持久导出、回放与模型历史派生仍然需要规范事件流。当 Definition 可以直接 fold 无损 run 时,浏览器表现并不需要为每个历史 fragment 分配一个 event object 并执行一次 Definition callback
## 决策
`session.page` 返回 `records: SessionHistoryRecord[]`。普通记录携带 `{event}`连续且属于同一的 Assistant delta 事件使用[打包 JSONL 决策](2026-07-26-packed-chunk-rows-by-default.zh.md)中的共享无损编解码器,携带 `{chunks: ChunkRow}`。系统先从逻辑事件中选择页面,再执行打包,因此按消息对齐的分页不依赖物理持久化布局。
历史页与 follow opening snapshot 携带 `records: SessionHistoryRecord[]`。普通 record 为 `{ type: 'event', event: SessionWireEvent }`连续且属于同一 block 的 Assistant delta event 使用[打包 JSONL 决策](2026-07-26-packed-chunk-rows-by-default.zh.md)中的共享无损 codec,表示为 `{ type: 'chunks', event: ChunkRowEvent }`。Host 在打包已选页面时只构造一次 event-shaped value。其 `type``chunkrow/text-chunks``chunkrow/reasoning-chunks``chunkrow/tool-call-chunks``seq``time` 表示首成员,`data` 保留原 fragment 与 timestamp-gap 数组。显式外层 discriminator 无需解释详细 chunk kind 即可选择 record 类别。系统先从逻辑 event 中选择页面,再执行打包,因此按消息对齐的分页不依赖物理持久化布局。
生成的 Remote decoder 会校验响应字段,共享的行 decoder 会拒绝格式错误的行,以及不安全的序号或时间戳重建`SessionEventStream` 会先展开记录,再将其交给 `RemoteJournalStream`;因此 journal 会依据原始事件序号检查页面连续性、分页拼接、重连修复和实时事件去重。页面请求中的 durable address 既可选择普通 Session,也可选择已授权的 direct subagent child,无需第二套历史协议。
生成的 Remote decoder 会校验响应字段。`SessionEventStream` 把原始 wire record 交给 `RemoteJournalStream`,并提供每条 record 的逻辑 seq 闭区间:event 覆盖 `[event.seq, event.seq]`row 覆盖 `[event.seq, event.seq + memberCount - 1]`。Journal 在发布 record 前检查页面连续性、分页拼接、重连修复、完整重复、部分重叠和实时 event 去重。页面请求中的 durable address 既可选择普通 Session,也可选择已授权的 direct subagent child,无需第二套历史协议。
Client adapter 会先调用共享的 `decodeStorageRecord()` 编解码器,再向 Session 对象层发布页面。每个打包成员都会还原为完全一致的原始 `assistant/chunk` 事件,包括 `seq`、时间戳、chunk 类型、block 索引、文本或参数片段、调用身份,以及可选名称是否存在。因此,已注册的 `ConversationNodeDefinition` 会对每个历史 delta 收到一次 `match()` 调用,并按实时事件所具有的同一 start/update 顺序折叠已接受的 match。打包只改变传输编码,不改变公共 Definition 的回放语义
Client 不分配替换 entry,直接把已接受的 `SessionHistoryRecord[]` 收窄为 `SessionEventLikeEntry[]`。外层 `type` 会一直保留到 journal、Session 与 assembler;两个分支都携带字段对齐的内部值,其中包含 `type``seq``time``data``ChunkRowEvent` 是 Client 历史数据,不是持久 Session event:它不会进入 `SessionEventMap``Session.events``session/event`
实时 `session.follow` 帧仍是单个事件。会话持久化、原始导出、回放、模型历史派生与规范内存日志均不改变
Conversation 接受 Session 保留的同一组 `{ type, event }` entry。Definition 接收内部 `SessionEventLike``match()``update()` 接受标准或 packed value`start()` 只接受标准 `SessionEvent`assembler 使用外层 discriminator 拒绝 packed start。Chat Assistant、Turn Tail 和 Trajectory Assistant 在既有 reducer 中处理三种 packed tag。一条 row 因此始终只对应一个 Client entry、Conversation input 与 Match,而这些 reducer 会保留 scalar replay 的最终 block、tool-call 字段、首 token 时间、首个可见边界、retry 行为和 interruption 状态
实时 `session.follow` frame 仍是单个 event 并走 scalar 路径,因此可见 streaming cadence 不变。Session persistence、原始导出、回放、模型历史派生与规范内存日志均不改变。
## 测量结果
@@ -32,15 +34,19 @@ Client adapter 会先调用共享的 `decodeStorageRecord()` 编解码器,再
与原始逻辑事件相比,打包使未压缩 JSON 减少 90.8%;与有损的已完成步骤投影候选相比减少 83.4%。Brotli 输出相对原始形式减少 73.2%,相对该投影候选减少 44.8%。这些数字描述该样本,并非协议保证;收益随 delta run 的长度与规律性变化。
可选运行的 `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样额外 V8 堆峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员 run 各五次精确解码的中位数。组合计时从内存事件数组开始,不包含冷持久化读取、projection 工作、生产 API bridge 与 RPC 信封,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/解码/折叠各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、精确解码事件数,以及双消费方 Assistant 折叠 fixture 的一致最终状态,包括 delta 数量与末个 delta 序号
一对一 Client 保留使同一规模样本保持为 696 个 history entry 与 Conversation input,而不会恢复成 416,756 个 event entry。一次本地合成 benchmark 观测到:Client parse、validation、retention 与双 Definition fold 在 scalar input 下耗时 4,682.11 ms,在 packed input 下耗时 276.10 ms采样额外 V8 heap 峰值分别为 612,523,344 与 199,436,928 字节。这些依赖机器的数值是观测结果,不是门槛
可选运行的 `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑 event 数、普通 event 数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告 wire 体积、Host/Client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样的额外 V8 heap 峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员 whitespace-prefix run 的 batch fold 中位数。组合计时从内存 event 数组开始,不包含冷持久化读取、生产 API bridge 与 RPC envelope,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。Heap 测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/保留/fold 各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 规模、每条 wire record 对应一个 Client input,以及双消费方 Assistant fold fixture 的一致最终状态,包括 delta 数量与末个 delta seq。
## 曾考虑的替代方案
**在 Host 丢弃已完成步骤的分片。** 这会减少逻辑事件数,但会让传输语义取决于当前 transcript 策略,从所有消费方移除精确证据,同时仍把保留的未完成步骤 token 逐个装入信封。实测打包响应在保持无损的同时更小。
**在已注册 Definition 看到打包 run 前先进行合并。** 这会减少浏览器事件对象与折叠调用,但开放的 `ConversationNodeDefinition` 可能统计 delta、检查各自的 `seq` 或时间戳,或者根据片段边界派生状态。累计文本相同不代表这些状态机等价,因此传输不能改变其回放输入数量
**在进入 Session 对象层前展开每条 packed row。** 这会保留每个历史 delta 一次 callback 的语义,但也会重新产生 packed transport 原本可以避免的浏览器分配、索引和 fold 成本。确实需要 scalar event 的消费方仍可显式调用 `decodeStorageRecord()`
**只依赖 HTTP 内容编码。** gzip 与 Brotli 会减少网络字节,但不会移除重复的 JSON 解析与校验。在实测样本中,打包行经过这两种编码后仍然显著更小;精确浏览器回放则保留契约要求的分配与折叠工作
**把原始 row 放在独立的 `.chunks` payload 下。** 这会迫使下游消费方保留两种 payload 字段名,或在进入 assembly 前分配字段对齐的包装层。共享 `.event` 字段既保留快速外层分类,也保留一条内部 Definition 路径
**只依赖 HTTP 内容编码。** gzip 与 Brotli 会减少网络字节,但不会移除重复的 JSON 解析、校验、分配、索引与 fold 工作。
**直接按物理持久化行分页。** 这还可以避免冷 Host 读取时的逻辑展开,但页面切分取决于追加来源消息与替换 provenance,而不是后端行边界。当前决策让 API 保持对 JSONL、SQLite 与未来持久化布局的独立性。
@@ -48,8 +54,8 @@ Client adapter 会先调用共享的 `decodeStorageRecord()` 编解码器,再
## 后果
历史响应保留每个逻辑事件,同时减少长 delta run 的协议字节、Host 响应序列化与堆占用,以及浏览器 JSON 解析与校验工作。Journal 会在精确展开后校验连续性,因此打包传输记录不会产生伪间隙。`SessionEventStream` 消费方继续收到普通事件条目;直接调用 `session.page` 的消费方必须读取 `SessionHistoryRecord` 联合,并在逐事件处理前解码打包行
历史响应保留每个逻辑 event,同时减少长 delta run 的 wire 字节、Host 响应序列化与 heap、浏览器 JSON 解析与校验、Client entry 分配,以及 Conversation dispatch。Journal 在发布前校验逻辑 range,因此 packed record 既不会产生伪 gap,也不会隐藏部分重叠。直接调用 `session.page` 的消费方必须 `SessionHistoryRecord.type` 分支;需要逐 member event 时再显式展开 `record.event.data`
冷持久历史仍会先解码成完整的逻辑 `SessionEvent[]`,Host 再选择页面并重新打包。因此,本决策改善的是传输与浏览器工作,不是 Host 冷读取的解码内存。消除该展开需要提供方无关的消息边界索引或单独的流式页面读取器,属于另一项优化。
浏览器历史回放仍会为每个原始 token 分配和折叠一个事件,因此本决策不会减少 Definition 的 matchupdate 次数或 settled history 堆占用;打包记录与展开事件同时存在时,还可能增加少量解码期峰值。历史仍作为一个批次安装,而不会为旧 token 播放动画;实时流式行为不变
默认 Client 历史路径公开 `SessionEventLike`,因此只接受规范持久 event 的消费方必须继续使用 Host `Session.events``session/event` 或显式 decode 路径。消费 Assistant delta 的 Definition 需要维护等价的 scalar 与 packed 分支。当前窗口已经实时接收的 scalar delta 仍保持 scalar;在线替换为 packed row 属于另一项工作,reopen 与 reconnect 则安装 packed 历史
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
conversation.md: d26abf73292faacdf3a4186819c4738d1de0270e
conversation.zh.md: 7fff9e0433b0022c75a35d3885398f241818a21d
conversation.md: 28a7b3d497182f2f560f2b54f89fcfd3091af673
conversation.zh.md: 7cf98fe2b18d9fd53e5f49f48330a5585e04c54d
+14 -11
View File
@@ -2,18 +2,18 @@
English | [中文](conversation.zh.md)
Conversation is the target-neutral assembly layer between a Client Session event window and browser views. [`ui-conversation`](../../packages/client/ui-conversation/README.md) owns the event and view registries, one identity-stable binding per `SessionBinding`, Turn/Step locations, incremental Context assembly, target sources, the shared shell, and input orchestration. Target packages such as [`ui-chat`](../../packages/client/ui-chat/README.md) and [`ui-trajectory`](../../packages/client/ui-trajectory/README.md) own their Definitions, final snapshots, and rendering.
Conversation is the target-neutral assembly layer between a Client `SessionEventLikeEntry` window and browser views. [`ui-conversation`](../../packages/client/ui-conversation/README.md) owns the event and view registries, one identity-stable binding per `SessionBinding`, Turn/Step locations, incremental Context assembly, target sources, the shared shell, and input orchestration. Target packages such as [`ui-chat`](../../packages/client/ui-chat/README.md) and [`ui-trajectory`](../../packages/client/ui-trajectory/README.md) own their Definitions, final snapshots, and rendering.
This page defines the data model and the extension path for a business-owned Conversation node. The [Web Client architecture](web-client.md) places the subsystem between Client models and Slots; the [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns its rationale.
## Data model and ownership
The Session Controller owns the contiguous loaded event window. `ui-conversation` observes that existing source and converts each entry to `{ event, view? }`; it never opens a second history stream. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
| Concept | Owner and purpose |
|---|---|
| Event Definition | A business package matches one event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Update-only evidence may remain pending until pagination supplies its unique start. |
| Event Definition | A business package matches one standard event or packed Assistant run at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A packed run occupies one update Match; update-only evidence may remain pending until pagination supplies its unique scalar start. |
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
@@ -36,6 +36,8 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
Historical runs of consecutive same-block `assistant/chunk` deltas arrive as `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`. Their top-level `seq` and `time` identify the first logical member, and their `data` retains each fragment and timestamp gap. These Client-only events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant deltas handles the relevant packed tags in the same `match()` and `update()` methods, while other Definitions return `null` without expanding the run.
## Definition and typed Chat payload
The example keeps the producer declarations and client contribution in one block so the complete relationship is visible. In a package family, keep the branded id and `SessionEventMap` declaration with the event producer, and keep the Definition, Chat data merge, and renderer in the client plugin.
@@ -208,7 +210,7 @@ export function apply(ctx: ClientContext): void {
}
```
`match(event)` is an identity extractor, not a fold: it receives only the current event and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once or `update` with the current State. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once for a standard event or `update` for a standard or packed event. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics.
`buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`.
@@ -222,17 +224,17 @@ The assembler records that dependency. If an older prepend later supplies a near
## Window update paths
History may be requested from the tail backward one page at a time, but every accepted page is normalized into ascending `seq` before State replay.
History may be requested from the tail backward one page at a time. The Session journal validates non-overlapping logical sequence ranges first; the Assembler then orders accepted inputs by their first `seq` before State replay.
| Path | Engine work | Definition-visible behavior |
|---|---|---|
| Replace on open, resync, or gap repair | Rebuild the loaded window, match every event once per Definition, then replay each started Context | `start`, followed by its updates in ascending `seq`; pending update-only Contexts remain without State |
| Prepend one older page | Match only fresh older events, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found start activates its collected updates; a changed Location or predecessor may rerun the Context |
| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One `update` and one requested publication for a matching post-start event; no existing Context scan |
| Replace on open, resync, or gap repair | Rebuild the loaded window, match every standard event or packed run once per Definition, then replay each started Context | `start`, followed by its updates in ascending logical `seq`; pending update-only Contexts remain without State |
| Prepend one older page | Match only fresh older inputs, merge them into Contexts by `(kind, id)`, preserve existing keyed nodes, and replay only affected Contexts and dependencies | A newly found scalar start activates its collected scalar and packed updates; a changed Location or predecessor may rerun the Context |
| Append one live event | Call each Definition's `match` once, look up the matched Context by key, and update only that Context | One scalar `update` and one requested publication for a matching post-start event; no existing Context scan |
With `D` registered Definitions, one incoming event performs `D` current-event matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies.
With `D` registered Definitions, one incoming scalar event or packed run performs `D` current-input matches and constant-time Context-key lookup after a match. Definition code must preserve that property: do not traverse the complete event window, every Context, `context.matches`, or the rendered Node collection on the normal append path. Use State for accumulated facts, Location data for same-Turn/Step sharing, and `reader.previous()` for indexed predecessor dependencies.
`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine still applies every update in log order; cadence only coalesces view publication.
`publication` controls when changed State is materialized. Use `immediate` for structural or terminal changes, `animation-frame` for high-frequency visible deltas, and `none` when the State change feeds only a later publication. The engine applies every scalar update in log order and every packed run in one batch update; cadence only coalesces view publication.
## Verification obligations
@@ -244,5 +246,6 @@ Add focused tests that establish these outcomes:
4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change.
5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested.
6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes.
7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild.
Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node.
+14 -11
View File
@@ -2,18 +2,18 @@
[English](conversation.md) | 中文
Conversation 是 Client Session event window 与浏览器 view 之间的 target-neutral assembly 层。[`ui-conversation`](../../packages/client/ui-conversation/README.zh.md)拥有 event 与 view registry、每个 `SessionBinding` 对应的 identity-stable binding、Turn/Step Location、增量 Context assembly、target source、共享 shell 与输入编排。[`ui-chat`](../../packages/client/ui-chat/README.zh.md)和 [`ui-trajectory`](../../packages/client/ui-trajectory/README.zh.md)等 target 包拥有各自的 Definition、最终 snapshot 与渲染。
Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间的 target-neutral assembly 层。[`ui-conversation`](../../packages/client/ui-conversation/README.zh.md)拥有 event 与 view registry、每个 `SessionBinding` 对应的 identity-stable binding、Turn/Step Location、增量 Context assembly、target source、共享 shell 与输入编排。[`ui-chat`](../../packages/client/ui-chat/README.zh.md)和 [`ui-trajectory`](../../packages/client/ui-trajectory/README.zh.md)等 target 包拥有各自的 Definition、最终 snapshot 与渲染。
本文定义数据模型与业务自有 Conversation node 的扩展路径。[Web Client 架构](web-client.zh.md)说明该子系统在 Client model 与 Slots 之间的位置;[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md)记录其设计理由。
## 数据模型与所有权
Session Controller 拥有连续的已加载 event window。`ui-conversation` 观察这一个现有 source,并把每个 entry 转换为 `{ event, view? }`;它绝不另开一条 history stream。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }``{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type``seq``time``data``ui-conversation` 把这些 entry 直接交给 assembler不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
| 概念 | Owner 与用途 |
|---|---|
| Event Definition | 业务包一次匹配一条 event,以稳定 `(kind, id)` 关联事件、折叠确定性 State,并可选择 materialize 一个 target node。 |
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。只有 update 的证据可以保持 pending,直到分页补齐其唯一 start。 |
| Event Definition | 业务包一次匹配一条标准 event 或一个 packed Assistant run,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 |
| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个 packed run 只占一个 update Match只有 update 的证据可以保持 pending,直到分页补齐其唯一 scalar start。 |
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
@@ -36,6 +36,8 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
连续且属于同一 block 的历史 `assistant/chunk` delta 会以 `chunkrow/text-chunks``chunkrow/reasoning-chunks``chunkrow/tool-call-chunks` 到达。顶层 `seq``time` 表示首个逻辑成员,`data` 保留每个 fragment 与 timestamp gap。这些 Client-only event 只能充当 update`start()` 只接收标准 `SessionEvent`。消费 Assistant delta 的 Definition 在同一组 `match()``update()` 方法里处理相关 packed tag,其他 Definition 直接返回 `null`,无需展开该 run。
## Definition 与类型化 Chat payload
为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。
@@ -208,7 +210,7 @@ export function apply(ctx: ClientContext): void {
}
```
`match(event)` 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context,再调用一次 `start`,或把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;标准 event 可触发一次 `start`,标准或 packed event 可把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
`buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。
@@ -222,17 +224,17 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
## Window 更新路径
历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `seq` 升序归一化,再进入 State 回放。
历史可能从尾部开始一页一页向前请求。Session journal 先校验互不重叠的逻辑 seq range,Assembler 再按每个已接受 input 的首 `seq` 排序并进入 State 回放。
| 路径 | 引擎工作 | Definition 可观察到的行为 |
|---|---|---|
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条事件对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早事件,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 start 会激活已收集 updateLocation 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 `update` 并请求一次发布;不扫描已有 Context |
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条标准 event 或 packed run 对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 `start`,再按逻辑 `seq` 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早 input,按 `(kind, id)` 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 scalar start 会激活已收集的 scalar 与 packed updateLocation 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 scalar `update` 并请求一次发布;不扫描已有 Context |
注册 `D` 个 Definition 时,一条新事件会进行 `D` 次仅当前事件匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`。
注册 `D` 个 Definition 时,一条新 scalar event 或 packed run 会进行 `D` 次仅当前 input 匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、`context.matches` 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 `reader.previous()`。
`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。
`publication` 控制发生 State 变更后何时物化。结构或 terminal 变化使用 `immediate`,高频可见 delta 使用 `animation-frame`,只为后续发布积累 State 时使用 `none`。引擎按日志顺序应用每条 scalar update,并用一次 batch update 应用一个 packed run;该选项只合并视图发布频率。
## 验证要求
@@ -244,5 +246,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的
4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。
5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。
6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。
流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-client.md
web-client.md: a06f6aaf45a482437b0509e65b6ee8332ec35a44
web-client.zh.md: 09f25bc45369a34ebf32c7a0b2b995c6732c9b29
web-client.md: e67da4980af881e426f2bc031c6ec49cc1df2857
web-client.zh.md: c5e9904226de66381a54bcb8a4783a062d70750f
+7 -7
View File
@@ -2,7 +2,7 @@
English | [中文](web-client.zh.md)
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session event window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
## Layers and ownership
@@ -12,7 +12,7 @@ The Web Client is a browser-side Cordis application assembled from independently
| Transport and API assembly | `client/connection`, `api/gateway`, `api/remotes` | Establish a Client generation, expose generated `ctx.remote` methods and streams, forward selected Cordis events, and carry cancellation and results. |
| Client models | `api/session-controller/client`, `api/workspace-controller/client` | Maintain React-free mirrors of Host state, resolve stream/unary races, own object identities and subscriptions, and expose narrow command services. |
| UI adapters | `client/ui-session`, `client/ui-workspace` | Convert model observables into root or Session-scoped standard Slot sources without taking ownership of business state. |
| Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble durable Session events into independent target snapshots and own the shared conversation shell and input flow. |
| Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble standard events and compact historical Assistant runs into independent target snapshots and own the shared conversation shell and input flow. |
| Composition and rendering | `client/ui-slots`, `client/ui-renderer`, `client/ui-layout`, feature UI packages | Declare extension locations, derive component props, bind observables to React hooks, and mount the final tree. |
The dependency direction is Host state → Remote transport → Client model → UI adapter → Conversation or presentation → Slots → React. User actions travel back through callbacks that close over an injected Client service or generated Remote namespace. A presentation component never receives Cordis `ctx`, a transport object, or another feature plugin's implementation.
@@ -41,9 +41,9 @@ Each API controller package owns a paired Host and Client face. The Host side ow
- `ClientSessions` provides `ctx.sessions`, owns Session scopes and stable `SessionBinding` objects, and projects the selected list state.
- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
- Each `Session` owns one contiguous event window, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
- Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. Each physical generation atomically replaces the retained window from that snapshot; live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
### Workspaces
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
`ui-session` installs the `session` scope adapter and publishes `useSessions`, `useSession`, `sessionId`, and `useProjection`. Domain adapters add further standard sources without putting React hooks on the model objects.
`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates raw durable events into stable business Contexts, and its view registry materializes target snapshots. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates standard events and Client-only `chunkrow/*` history events into stable business Contexts, and its view registry materializes target snapshots. Packed runs stay single inputs and Matches through replay; Chat Assistant, Trajectory Assistant, and Turn Tail are the built-in Definitions that interpret them. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
`ui-slots` provides the typed registry and lifecycle ledger; `ui-renderer` is the only package that binds bare observables through `useSyncExternalStore`, owns React contexts, and renders the root tree. Feature components receive framework hooks, owner props, store actions, and explicit injection through their derived props. [Web Client Slots](slots.md) lists those inputs, extension APIs, and the current Slot hierarchy.
@@ -63,7 +63,7 @@ This pairing is not a second source of business truth. Host controllers decide d
| Path | Sequence |
|---|---|
| durable Session display | Host Session log → Remote `follow` plus `page` → Client `Session` event window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React |
| durable Session display | Host Session log → packed Remote `follow`/`page` history → Client `SessionEventLikeEntry` window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React |
| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components |
| Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel``ctx.workspaces.list``useWorkspaces` → sidebar, hero, and navigation entries |
| scoped interaction | Host Cordis waterfall → API Remotes `$events``ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` |
@@ -75,7 +75,7 @@ Physical and logical recovery are separate. Gateway mux restores the physical We
Recovery follows the data's semantics:
- A durable Session journal replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later sequence gap.
- A durable Session journal validates logical sequence ranges and replaces its window from every generation's opening snapshot; `page()` supplies older history and repairs any later range gap.
- Session control and Workspace streams retain the last published value while disconnected, then atomically replace it from a fresh opening baseline.
- Ordinary forwarded notifications are not replayed. Stateful domains need a baseline, cursor, or explicit query; scoped waterfalls retain their own request lifetime.
+7 -7
View File
@@ -2,7 +2,7 @@
[English](web-client.md) | 中文
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI[Conversation](conversation.zh.md) 把 Session 事件窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
## 分层与所有权
@@ -12,7 +12,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。
| 传输与 API assembly | `client/connection``api/gateway``api/remotes` | 建立 Client generation,公开生成的 `ctx.remote` method 与 stream,转发选定的 Cordis event,并承载取消和结果。 |
| Client model | `api/session-controller/client``api/workspace-controller/client` | 维护不依赖 React 的 Host 状态镜像,处理 stream/unary 竞态,拥有对象 identity 与订阅,并公开收窄的 command service。 |
| UI adapter | `client/ui-session``client/ui-workspace` | 把 model observable 转换为 root 或 Session scope 的标准 Slot source,不接管业务状态所有权。 |
| Conversation 数据 | `client/ui-conversation``ui-chat``ui-trajectory` 等 target package | 把持久 Session event 组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 |
| Conversation 数据 | `client/ui-conversation``ui-chat``ui-trajectory` 等 target package | 把标准 event 与紧凑的 Assistant 历史批次组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 |
| 组合与渲染 | `client/ui-slots``client/ui-renderer``client/ui-layout`、各 UI 功能包 | 声明扩展位置、推导组件 props、把 observable 绑定成 React hook,并挂载最终组件树。 |
依赖方向是 Host 状态 → Remote 传输 → Client model → UI adapter → Conversation 或 presentation → Slots → React。用户操作通过 callback 反向进入注入的 Client service 或生成的 Remote namespace。Presentation component 绝不接收 Cordis `ctx`、transport object 或其他功能插件的实现。
@@ -41,9 +41,9 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
- `ClientSessions` 提供 `ctx.sessions`,拥有 Session scope 与稳定的 `SessionBinding` object,并投影选中的 list state。
- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
- 每个 `Session` 拥有一段连续 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
- 每个 `Session` 拥有一段`SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event``chunks` 判别字段和字段对齐的内部 `event`journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
### Workspaces
@@ -55,7 +55,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
`ui-session` 安装 `session` scope adapter,并提供 `useSessions``useSession``sessionId``useProjection`。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把原始持久 event 关联成稳定的业务 Contextview registry 则 materialize target snapshot。`ui-chat``ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把标准 event 与 Client-only `chunkrow/*` 历史 event 关联成稳定的业务 Contextview registry 则 materialize target snapshot。packed run 在 replay 全程保持为单个 input 与 MatchChat Assistant、Trajectory Assistant 和 Turn Tail 是解释它的三个内建 Definition。`ui-chat``ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
`ui-slots` 提供类型化 registry 与 lifecycle ledger`ui-renderer` 是唯一通过 `useSyncExternalStore` 绑定裸 observable、拥有 React context 并渲染 root tree 的包。功能 component 通过推导出的 props 接收 framework hook、owner prop、store action 与显式 injection。[Web Client Slots](slots.zh.md)列出这些输入、扩展 API 与当前 Slot 层级。
@@ -63,7 +63,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
| 路径 | 顺序 |
|---|---|
| 持久 Session 展示 | Host Session log → Remote `follow``page` → Client `Session` event window → Conversation Context → target snapshot`chat``trajectory` 或其他已注册 target)→ Slot view → React |
| 持久 Session 展示 | Host Session log → packed Remote `follow`/`page` 历史 → Client `SessionEventLikeEntry` window → Conversation Context → target snapshot`chat``trajectory` 或其他已注册 target)→ Slot view → React |
| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component |
| Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel``ctx.workspaces.list``useWorkspaces` → sidebar、hero 与 navigation entry |
| scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` |
@@ -75,7 +75,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、Host desc
恢复方式由数据语义决定:
- 持久 Session journal 根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 seq gap。
- 持久 Session journal 校验逻辑 seq range,并根据每个 generation 的 opening snapshot 替换窗口;`page()` 提供更早历史并修复后续 range gap。
- Session control 与 Workspace stream 在断开期间保留最后一次发布的值,再用新的 opening baseline 原子替换。
- 普通 forwarded notification 不会 replay。需要可靠恢复的 stateful domain 必须提供 baseline、cursor 或显式 queryscoped waterfall 保留自身的 request lifetime。
+1 -1
View File
@@ -99,7 +99,7 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent
## Conversation Node discipline
- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation reference](../../docs/subsystems/conversation.md).
- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`.
- `match(event)` reads only the current `SessionEventLike`. Every scalar event or packed Assistant run in a multi-input Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by logical log `seq`. Packed rows are update-only, and a Definition that consumes Assistant deltas implements both scalar and `chunkrow/*` branches without expanding members.
- The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks.
## Directory regime (plugin packages)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: cc79de10289069ef94105397bd77a5194b4e6808
README.zh.md: 3d4eb91492a497ff4544bd6378ae212810342c64
README.md: 551824caf707c38fced5b813d82a5b9c746e7b82
README.zh.md: a0ab1807d244db6e9ac9f7ac50847e5872c54f44
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`).
The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members.
## System prompt row
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。
Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。
## 系统提示词行
@@ -1,4 +1,5 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -29,6 +30,7 @@ interface AssistantState {
readonly turn: number
readonly step: number
readonly blocks: readonly (AssistantBlock | undefined)[]
readonly visibleBlocks: number
readonly firstVisibleSeq: number | undefined
readonly firstVisibleTime: number | undefined
readonly firstTokenTime: number | undefined
@@ -37,11 +39,18 @@ interface AssistantState {
readonly usage: unknown
}
function isChunkRunEvent(event: ConversationMatch['event']): event is ChunkRowEvent {
return event.type === 'chunkrow/text-chunks'
|| event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks'
}
function initialState(turn: number, step: number): AssistantState {
return {
turn,
step,
blocks: [],
visibleBlocks: 0,
firstVisibleSeq: undefined,
firstVisibleTime: undefined,
firstTokenTime: undefined,
@@ -55,12 +64,20 @@ function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): Assista
return blocks.filter((block): block is AssistantBlock => block !== undefined)
}
function blockIsVisible(block: AssistantBlock | undefined): boolean {
if (block === undefined || block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
}
function countVisibleBlocks(blocks: readonly AssistantBlock[]): number {
let count = 0
for (const block of blocks) if (blockIsVisible(block)) count++
return count
}
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
return blocks.some(blockIsVisible)
}
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
@@ -82,22 +99,32 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
if (match.event.type !== 'assistant/chunk') return state
const chunk = match.event.data.chunk
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
switch (chunk.type) {
case 'block-start':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
break
case 'text-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text }
break
}
case 'reasoning-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text }
break
}
case 'tool-call-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
@@ -110,6 +137,8 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
break
}
case 'block-end':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
blocks[chunk.index] = toAssistantBlock(chunk.block)
break
case 'usage':
@@ -117,13 +146,16 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
default:
return state
}
const visible = hasVisibleContent(compactBlocks(blocks))
const visibleBlocks = state.visibleBlocks
- Number(previousVisible)
+ Number(blockIsVisible(blocks[changedIndex]))
const firstToken = isTokenDelta(chunk)
return {
...state,
blocks,
hidden: visible ? false : state.hidden,
...visible && state.firstVisibleSeq === undefined
visibleBlocks,
hidden: visibleBlocks > 0 ? false : state.hidden,
...visibleBlocks > 0 && state.firstVisibleSeq === undefined
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
: {},
...firstToken && state.firstTokenTime === undefined
@@ -132,6 +164,88 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
}
}
interface ChunkRunBoundaries {
readonly firstTokenTime: number | undefined
readonly firstVisible: { readonly seq: number; readonly time: number } | undefined
}
function chunkRunBoundaries(
event: ChunkRowEvent,
needsToken: boolean,
needsVisible: boolean,
visibleFromStart: boolean,
): ChunkRunBoundaries {
const fragments = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
const nameStartsToken = event.type === 'chunkrow/tool-call-chunks'
&& Object.hasOwn(event.data, 'name')
let firstTokenTime: number | undefined
let firstVisible: ChunkRunBoundaries['firstVisible']
let time = event.time
for (let index = 0; index < fragments.length; index++) {
const fragment = fragments[index] as string
if (needsToken && firstTokenTime === undefined && (nameStartsToken || fragment !== '')) {
firstTokenTime = time
}
if (needsVisible && firstVisible === undefined
&& (visibleFromStart
|| (event.type !== 'chunkrow/tool-call-chunks' && fragment.trim() !== ''))) {
firstVisible = { seq: event.seq + index, time }
}
if ((!needsToken || firstTokenTime !== undefined)
&& (!needsVisible || firstVisible !== undefined)) break
time += event.data.dt[index] ?? 0
}
return { firstTokenTime, firstVisible }
}
function updateChunkRun(state: AssistantState, event: ChunkRowEvent): AssistantState {
const blocks = [...state.blocks]
const previous = blocks[event.data.index]
const previousVisible = blockIsVisible(previous)
let visibleFromStart = state.visibleBlocks - Number(previousVisible) > 0
if (event.type === 'chunkrow/text-chunks') {
const text = previous?.kind === 'text' ? previous.text : ''
visibleFromStart ||= text.trim() !== ''
blocks[event.data.index] = { kind: 'text', text: text + event.data.texts.join('') }
} else if (event.type === 'chunkrow/reasoning-chunks') {
const text = previous?.kind === 'reasoning' ? previous.text : ''
visibleFromStart ||= text.trim() !== ''
blocks[event.data.index] = { kind: 'reasoning', text: text + event.data.texts.join('') }
} else {
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
blocks[event.data.index] = {
kind: 'tool-call',
callId: base.callId || String(event.data.id),
name: Object.hasOwn(event.data, 'name') ? event.data.name as string : base.name,
argsRaw: base.argsRaw + event.data.args.join(''),
}
}
const boundaries = chunkRunBoundaries(
event,
state.firstTokenTime === undefined,
state.firstVisibleSeq === undefined,
visibleFromStart,
)
const visibleBlocks = state.visibleBlocks
- Number(previousVisible)
+ Number(blockIsVisible(blocks[event.data.index]))
return {
...state,
blocks,
visibleBlocks,
hidden: visibleBlocks > 0 ? false : state.hidden,
...(boundaries.firstVisible === undefined ? {} : {
firstVisibleSeq: boundaries.firstVisible.seq,
firstVisibleTime: boundaries.firstVisible.time,
}),
...(boundaries.firstTokenTime === undefined ? {} : {
firstTokenTime: boundaries.firstTokenTime,
}),
}
}
function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined {
if (location.kind === 'step' && location.step.status === 'closed' && location.step.end !== undefined) {
return location.step.end
@@ -169,8 +283,9 @@ function finalNode(
}
const location = context.start?.location ?? context.matches.at(-1)?.location
const boundary = location === undefined ? undefined : closedBoundary(location)
if (boundary === undefined) return undefined
const blocks = compactBlocks(state.blocks)
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
if (!hasInterruptionEvidence(blocks)) return undefined
return {
kind: 'assistant',
seq: boundary.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant,
@@ -185,6 +300,11 @@ function finalNode(
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
if (isChunkRunEvent(match.event)) {
state ??= initialState(match.event.data.turn, match.event.data.step)
state = updateChunkRun(state, match.event)
continue
}
if (match.event.type === 'assistant/chunk') {
state ??= initialState(match.event.data.turn, match.event.data.step)
state = updateChunk(state, match)
@@ -192,9 +312,11 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
}
if (match.event.type === 'assistant/message') {
state ??= initialState(match.event.data.turn, match.event.data.step)
const blocks = toAssistantBlocks(match.event.data.message.content)
state = {
...state,
blocks: toAssistantBlocks(match.event.data.message.content),
blocks,
visibleBlocks: countVisibleBlocks(blocks),
hidden: false,
final: match,
usage: match.event.data.usage,
@@ -220,7 +342,7 @@ function projectAssistant(context: ConversationNodeContext<AssistantState>): Ass
if (state === undefined) return undefined
const settled = finalNode(state, context)
const blocks = settled?.blocks ?? compactBlocks(state.blocks)
const visible = hasVisibleContent(blocks)
const visible = settled === undefined ? state.visibleBlocks > 0 : hasVisibleContent(blocks)
const status = settled?.interrupted === true
? 'interrupted'
: settled === undefined ? 'running' : 'settled'
@@ -252,6 +374,9 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|| (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
if (isChunkRunEvent(event)) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
if (event.type === 'llm/retry') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
@@ -262,11 +387,16 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
return initialState(match.event.data.turn, match.event.data.step)
},
update: (context, match) => {
if (isChunkRunEvent(match.event)) {
return updateChunkRun(context.state, match.event)
}
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
if (match.event.type === 'assistant/message') {
const blocks = toAssistantBlocks(match.event.data.message.content)
return {
...context.state,
blocks: toAssistantBlocks(match.event.data.message.content),
blocks,
visibleBlocks: countVisibleBlocks(blocks),
hidden: false,
final: match,
usage: match.event.data.usage,
@@ -279,6 +409,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
if (isChunkRunEvent(match.event)) return 'animation-frame'
if (match.event.type !== 'assistant/chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
@@ -15,9 +15,12 @@ declare module '../contract/chat-nodes.ts' {
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
kind: 'unknown-surface',
target: 'chat',
match: event => isAppendSurfaceEvent(event)
? { id: String(event.seq), role: 'start' }
: null,
match: (event) => {
if (event.type === 'chunkrow/text-chunks'
|| event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks') return null
return isAppendSurfaceEvent(event) ? { id: String(event.seq), role: 'start' } : null
},
start: (_context, match) => ({
kind: 'unknown',
seq: match.event.seq,
@@ -1,6 +1,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationLocation, ConversationNodeDefinition,
ConversationLocation, ConversationMatch, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { RetryChatData } from '../contract/chat-nodes.ts'
@@ -21,7 +21,7 @@ export interface RetryState {
readonly attempts: readonly ModelRetryNode[]
}
function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]): ModelRetryNode | undefined {
function scheduledNode(match: ConversationMatch): ModelRetryNode | undefined {
if (match.event.type !== 'llm/retry') return undefined
return {
kind: 'model-retry',
@@ -44,6 +44,11 @@ function hasTextAssistant(event: Parameters<ConversationNodeDefinition['match']>
}
function chunkHasText(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
if (event.type === 'chunkrow/text-chunks') {
return event.data.texts.some(text => text.trim() !== '')
}
if (event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks') return false
if (event.type !== 'assistant/chunk') return false
const chunk = event.data.chunk
if (chunk.type === 'text-delta') return chunk.text.trim() !== ''
@@ -59,6 +64,9 @@ function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[
if (event.type === 'assistant/message'
|| event.type === 'assistant/chunk'
|| event.type === 'step/start'
|| event.type === 'chunkrow/text-chunks'
|| event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks'
|| event.type === 'step/end') {
return { turn: event.data.turn, step: event.data.step }
}
@@ -80,7 +88,10 @@ function closingAnchor(context: ConversationNodeContext<TurnTailState>): number
const coordinates = turnCoordinates(event)
if (coordinates?.step === undefined) continue
const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false }
if (event.type === 'assistant/chunk') {
if (event.type === 'assistant/chunk'
|| event.type === 'chunkrow/text-chunks'
|| event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks') {
steps.set(coordinates.step, {
...previous,
streamedText: previous.streamedText || chunkHasText(event),
@@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest'
import type {
ChatConversationViewNode, ChatSnapshot,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ChunkRowEvent,
} from '@deepseek-ai/dsh-api-session-controller/types'
import {
ConversationNodeAssembler,
type ConversationEventInput,
type ConversationNodeDefinition,
type ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
@@ -62,19 +69,38 @@ function at(
type: string,
data: unknown,
extra: Record<string, unknown> = {},
): ConversationEventInput {
): SessionLiveEventEntry {
return {
type: 'event',
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
data,
...extra,
} as unknown as ConversationEventInput['event'],
} as unknown as SessionEvent,
}
}
function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler {
function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
return {
type: 'chunks',
event: {
type: `chunkrow/${row.type}`,
seq: row.seq0,
time: row.time0,
data: row.data,
} as ChunkRowEvent,
}
}
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
})
}
function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
@@ -311,6 +337,139 @@ describe('built-in conversation node Definitions', () => {
})
})
it('folds packed Assistant runs to the same Chat and Turn Tail state as scalar deltas', () => {
const runningHistory = [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 1_000 }),
at(4, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 1_000 }),
at(5, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\t' },
}, { time: 995 }),
at(6, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
}, { time: 1_004 }),
at(7, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
}),
at(8, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
}),
at(9, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
}),
at(10, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
}),
at(11, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
}),
at(12, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
}),
]
const scalar = assembler(runningHistory)
const packedHistory = packedInputs(runningHistory)
expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
const packed = assembler(packedHistory)
expect(snapshot(packed)).toEqual(snapshot(scalar))
const running = node(snapshot(packed), 'assistant-step')
expect(running).toMatchObject({ anchorSeq: 6 })
expect(running?.data).toMatchObject({
time: 1_004,
blocks: [
{ kind: 'text', text: ' \tanswer' },
{ kind: 'reasoning', text: 'thinking' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
],
})
for (const value of [scalar, packed]) {
value.append(at(13, 'step/end', { turn: 1, step: 1 }))
value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
value.flush()
}
expect(snapshot(packed)).toEqual(snapshot(scalar))
expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
const partialHistory = [
...runningHistory.slice(2),
at(13, 'step/end', { turn: 1, step: 1 }),
at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
]
const partialScalar = snapshot(assembler(partialHistory, true))
const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
expect(partialPacked).toEqual(partialScalar)
expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
const finalizedHistory = [
at(20, 'turn/start', { turn: 2 }),
at(21, 'step/start', { turn: 2, step: 1 }),
at(22, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 2_000 }),
at(23, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 1_999 }),
at(24, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
}, { time: 2_000 }),
at(25, 'llm/retry', {
retryId: 'packed-retry', turn: 2, step: 1, provider: 'fake', mode: 'normal',
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}),
at(26, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
at(27, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
}),
at(28, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
}),
at(29, 'assistant/message', {
turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
}, { surfaceOp: 'append' }),
]
const finalizedScalar = snapshot(assembler(finalizedHistory))
const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
expect(finalizedPacked).toEqual(finalizedScalar)
const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
const namedToolHistory = [
at(40, 'turn/start', { turn: 3 }),
at(41, 'step/start', { turn: 3, step: 1 }),
...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
turn: 3, step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
}, { time: 4_000 + seq - 42 })),
at(45, 'assistant/message', {
turn: 3,
step: 1,
message: {
...assistantMessage('named-tool-final', ''),
content: [{ type: 'tool-call', id: 'call-2', name: 'read', arguments: '' }],
},
}, { surfaceOp: 'append' }),
]
const namedToolScalar = snapshot(assembler(namedToolHistory))
const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
expect(namedToolPacked).toEqual(namedToolScalar)
const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
})
it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 8506cc9e2d2935151003ad11e34d056dda70e0ff
README.zh.md: bf6643112878f6537d3db1d1686db046202fada5
README.md: 95eba59baa637e32b7bec596bc1cd3b1a44e789c
README.zh.md: 17dfd95b6f3928ed74abafd08689bc245fd1c16f
+2 -2
View File
@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
## Conversation assembly
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
The adapter converts each `SessionEventEntry` to a `{ event }` `ConversationEventInput` and preserves the raw Session event, including tool-result metadata. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
+2 -2
View File
@@ -2,13 +2,13 @@
[English](README.md) | 中文
`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation``useInput``inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation``useInput``inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。
## Conversation 组装
`UiConversation.events` 是 event Definition 的唯一 registry`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
adapter 每个 `SessionEventEntry` 转换成 `{ event }` 形式的 `ConversationEventInput`,并保留原始 Session event,包括工具结果 metadata。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`
adapter 每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type``seq``time``data`Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match``update` 方法,`start` 则只接收标准 eventassembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`
target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)``ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
@@ -1,14 +1,6 @@
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
* The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
* installed business packages supply their concrete keys in consuming Client programs. */
/** One raw Session log event consumed by Conversation assembly. */
export interface ConversationEventInput {
readonly event: SessionEvent
}
/** Definition-local identity and lifecycle role extracted from one event. */
export interface ConversationMatchResult {
readonly id: string
@@ -93,12 +85,23 @@ export type ConversationLocation =
| { readonly kind: 'step'; readonly turn: TurnLocation; readonly step: StepLocation }
| { readonly kind: 'unresolved' }
/** One event accepted by a Definition, with its current resolved Location. */
export interface ConversationMatch extends ConversationEventInput {
readonly role: 'start' | 'update'
interface ConversationMatchOf<
Event extends SessionEventLike,
Role extends ConversationMatchResult['role'],
> {
readonly event: Event
readonly role: Role
readonly location: ConversationLocation
}
/** One scalar event accepted as a Context's unique start. */
export type ConversationStartMatch = ConversationMatchOf<SessionEvent, 'start'>
/** One event accepted by a Definition, with its lifecycle role and resolved Location. */
export type ConversationMatch =
| ConversationStartMatch
| ConversationMatchOf<SessionEventLike, 'update'>
/** Target-neutral identity returned by a business Definition. */
export interface ConversationViewNode {
readonly key: string
@@ -125,7 +128,7 @@ export interface ConversationNodeContext<State = unknown> {
readonly kind: string
readonly id: string
readonly matches: readonly ConversationMatch[]
readonly start: ConversationMatch | undefined
readonly start: ConversationStartMatch | undefined
readonly state: State | undefined
readonly current: ReadonlyMap<string, ConversationViewNode | null>
}
@@ -164,10 +167,10 @@ export interface ConversationNodeDefinition<State = unknown> {
readonly target?: string
/**
* Extract this Definition's stable business identity from one event.
* @param event - raw Session event; no Context or history access is available.
* @param event - standard or compact Client history event; no Context or history access is available.
* @returns identity and lifecycle role, or null when unrelated.
*/
match(event: SessionEvent): ConversationMatchResult | null
match(event: SessionEventLike): ConversationMatchResult | null
/**
* Create State from the unique start Match.
* @param context - complete evidence currently collected for the Context.
@@ -177,7 +180,7 @@ export interface ConversationNodeDefinition<State = unknown> {
*/
start(
context: ConversationNodeContext<State>,
match: ConversationMatch,
match: ConversationStartMatch,
reader: ConversationContextReader,
): State
/**
@@ -1,7 +1,11 @@
import type {
ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch,
SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ConversationContextReader, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
ConversationStartMatch,
ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
@@ -23,7 +27,7 @@ interface InternalContext {
readonly id: string
readonly definition: ConversationNodeDefinition
startSeq: number | undefined
start: ConversationMatch | undefined
start: ConversationStartMatch | undefined
matches: ConversationMatch[]
state: unknown
revision: number
@@ -117,6 +121,21 @@ function mergeMatches(
return merged
}
function conversationMatch(
key: string,
input: SessionEventLikeEntry,
role: ConversationMatch['role'],
location: ConversationMatch['location'],
): ConversationMatch {
if (role === 'start') {
if (input.type === 'chunks') {
throw new Error(`conversation Context ${key} received a packed start Match`)
}
return { event: input.event, role, location }
}
return { event: input.event, role, location }
}
/** Event Registry subset consumed by a Session-owned Assembler. */
export interface ConversationEventDefinitions {
/** @returns ordinary Definitions in registration order. */
@@ -139,7 +158,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
private readonly contexts = new Map<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
private readonly inputs = new Map<number, ConversationEventInput>()
private readonly inputs = new Map<number, SessionEventLikeEntry>()
private readonly locationIndex = new ConversationLocationIndex()
private readonly dirty = new Set<InternalContext>()
private readonly revised = new Set<InternalContext>()
@@ -166,7 +185,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
* @param hasMore - whether older history remains outside the window.
* @returns immediate publication request.
*/
replaceWindow(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication {
replaceWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): ConversationPublication {
this.contexts.clear()
this.contextsByKind.clear()
this.contextsBySeq.clear()
@@ -189,17 +208,18 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
/**
* Add one contiguous live tail event without scanning existing Contexts.
* @param input - appended Session event.
* @param record - appended Session event entry.
* @returns highest requested publication cadence.
*/
append(input: ConversationEventInput): ConversationPublication {
if (this.inputs.has(input.event.seq)) return 'none'
append(record: SessionLiveEventEntry): ConversationPublication {
const event = record.event
if (this.inputs.has(event.seq)) return 'none'
this.revised.clear()
this.inputs.set(input.event.seq, input)
this.inputs.set(event.seq, record)
let publication: ConversationPublication = 'none'
if (isLocationBoundary(input.event.type)) {
if (isLocationBoundary(event.type)) {
const previousTimeline = this.locationIndex.snapshot()
const changed = this.locationIndex.appendBoundary(input.event)
const changed = this.locationIndex.appendBoundary(event)
if (this.locationIndex.snapshot() !== previousTimeline) {
this.timelineDirty = true
publication = 'immediate'
@@ -207,9 +227,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
this.replayContexts(this.refreshMatchLocations(changed))
if (changed.size > 0) publication = 'immediate'
} else {
this.locationIndex.appendNonBoundary(input.event)
this.locationIndex.appendNonBoundary(event)
}
publication = maximumPublication(publication, this.matchInput(input))
publication = maximumPublication(publication, this.matchInput(record))
if (this.replayRevisedDependents()) publication = 'immediate'
this.revised.clear()
return publication
@@ -221,7 +241,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
* @param hasMore - whether history still precedes the expanded window.
* @returns highest requested publication cadence.
*/
prepend(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication {
prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): ConversationPublication {
this.revised.clear()
let publication: ConversationPublication = 'none'
const previousHasMore = this.hasMore
@@ -343,26 +363,27 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
return active
}
private sortedInputs(): ConversationEventInput[] {
private sortedInputs(): SessionEventLikeEntry[] {
return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq)
}
private matchInput(input: ConversationEventInput): ConversationPublication {
private matchInput(input: SessionEventLikeEntry): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) =>
this.acceptMatch(definition, id, role, input))
}
private collectInput(
input: ConversationEventInput,
input: SessionEventLikeEntry,
pending: Map<string, PendingMatch[]>,
): ConversationPublication {
return this.dispatchInput(input, (definition, id, role) => {
const key = conversationContextKey(definition.kind, id)
const match: ConversationMatch = {
...input,
const match = conversationMatch(
key,
input,
role,
location: this.locationIndex.locationOf(input.event),
}
this.locationIndex.locationOf(input.event),
)
const matches = pending.get(key) ?? []
matches.push({ definition, id, match })
pending.set(key, matches)
@@ -371,17 +392,18 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
private dispatchInput(
input: ConversationEventInput,
input: SessionEventLikeEntry,
accept: (
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
) => ConversationPublication,
): ConversationPublication {
const event = input.event
const matchedTargets = new Set<string>()
let publication: ConversationPublication = 'none'
for (const definition of this.eventDefinitions.entries()) {
const result = definition.match(input.event)
const result = definition.match(event)
if (result === null) continue
if (definition.target !== undefined) matchedTargets.add(definition.target)
publication = maximumPublication(publication, accept(definition, result.id, result.role))
@@ -389,7 +411,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
const fallback = this.eventDefinitions.fallbackEntry()
const target = fallback?.target
if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) {
const result = fallback.match(input.event)
const result = fallback.match(event)
if (result !== null) {
publication = maximumPublication(publication, accept(fallback, result.id, result.role))
}
@@ -401,7 +423,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
definition: ConversationNodeDefinition,
id: string,
role: ConversationMatch['role'],
input: ConversationEventInput,
input: SessionEventLikeEntry,
): ConversationPublication {
const key = conversationContextKey(definition.kind, id)
let context = this.contexts.get(key)
@@ -425,11 +447,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
this.contexts.set(key, context)
}
const match: ConversationMatch = {
...input,
const match = conversationMatch(
key,
input,
role,
location: this.locationIndex.locationOf(input.event),
}
this.locationIndex.locationOf(input.event),
)
const previous = context.matches.at(-1)
if (previous !== undefined && previous.event.seq >= input.event.seq) {
throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`)
@@ -438,7 +461,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
throw new Error(`conversation Context ${key} received an update before its start Match`)
}
context.matches.push(match)
if (role === 'start') {
if (match.role === 'start') {
context.startSeq = input.event.seq
context.start = match
this.indexStartedContext(context)
@@ -447,7 +470,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
owners.add(context)
this.contextsBySeq.set(input.event.seq, owners)
if (role === 'start') {
if (match.role === 'start') {
this.replayContext(context)
} else if (context.state !== undefined) {
const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown }
@@ -485,7 +508,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
}
this.contexts.set(key, context)
}
let discoveredStart: ConversationMatch | undefined
let discoveredStart: ConversationStartMatch | undefined
const additions = entries
.map((entry) => {
if (entry.definition !== context.definition || entry.id !== context.id) {
@@ -707,9 +730,15 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
let start = context.start
const matches = context.matches.map((match): ConversationMatch => {
if (!changedSeqs.has(match.event.seq)) return match
const refreshed = { ...match, location: this.locationIndex.locationOf(match.event) }
if (match === start) start = refreshed
return refreshed
if (match.role === 'start') {
const refreshed: ConversationStartMatch = {
...match,
location: this.locationIndex.locationOf(match.event),
}
if (match === start) start = refreshed
return refreshed
}
return { ...match, location: this.locationIndex.locationOf(match.event) }
})
context.matches = matches
context.start = start
@@ -4,13 +4,12 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
ISessions, SessionBinding, SessionEventSource, SessionEventWindow,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import {
createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type {
ConversationEventInput, ConversationPublication, ConversationViewSnapshotMap,
ConversationPublication, ConversationViewSnapshotMap,
ConversationViewSnapshotStore,
} from '../contract/conversation.ts'
import type { ConversationSnapshot } from '../contract/snapshot.ts'
@@ -81,7 +80,7 @@ class BoundConversation implements ConversationBinding {
private replace(window: SessionEventWindow): void {
this.revision = window.revision
this.publish(this.assembler.replaceWindow(window.entries.map(conversationInput), window.hasMore))
this.publish(this.assembler.replaceWindow(window.entries, window.hasMore))
}
private accept(window: SessionEventWindow): void {
@@ -93,12 +92,12 @@ class BoundConversation implements ConversationBinding {
this.revision = window.revision
switch (window.change.kind) {
case 'prepend':
this.publish(this.assembler.prepend(window.change.entries.map(conversationInput), window.hasMore))
this.publish(this.assembler.prepend(window.change.entries, window.hasMore))
return
case 'append': {
let publication: ConversationPublication = 'none'
for (const entry of window.change.entries) {
const next = this.assembler.append(conversationInput(entry))
for (const event of window.change.entries) {
const next = this.assembler.append(event)
if (next === 'immediate' || publication === 'none') publication = next
}
this.publish(publication)
@@ -131,10 +130,6 @@ class BoundConversation implements ConversationBinding {
}
}
function conversationInput(entry: SessionEventEntry): ConversationEventInput {
return { event: entry.event as unknown as SessionEvent }
}
interface BindingRecord {
readonly source: SessionBinding
readonly binding: BoundConversation
@@ -1,6 +1,9 @@
import {
type SessionEventLike, type SessionEventLikeEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationEventInput, ConversationLocation, ConversationLocationData,
ConversationLocation, ConversationLocationData,
ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot,
ConversationTurnDataMap, StepLocation, TurnLocation,
} from '../contract/conversation.ts'
@@ -82,7 +85,7 @@ interface TurnDraft {
const SESSION_LOCATION = { kind: 'session' } as const
const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const
function payloadCoordinates(event: SessionEvent): Coordinates {
function payloadCoordinates(event: SessionEventLike): Coordinates {
const data = event.data as unknown as { turn?: unknown; step?: unknown }
if (data.turn === null) return { session: true }
const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0
@@ -194,7 +197,7 @@ export class ConversationLocationIndex {
* @param event - event already ingested into this index.
* @returns current Location, falling back to session when it has no Turn/Step affinity.
*/
locationOf(event: SessionEvent): ConversationLocation {
locationOf(event: SessionEventLike): ConversationLocation {
return this.locations.get(event.seq) ?? SESSION_LOCATION
}
@@ -203,7 +206,7 @@ export class ConversationLocationIndex {
* @param entries - complete current window in ascending seq order.
* @returns seqs whose resolved Location changed.
*/
rebuild(entries: readonly ConversationEventInput[]): ReadonlySet<number> {
rebuild(entries: readonly SessionEventLikeEntry[]): ReadonlySet<number> {
const previousLocations = this.locations
const turns = new Map<number, TurnDraft>()
const coordinates = new Map<number, Coordinates>()
@@ -5,10 +5,11 @@ export type { ConversationBinding } from './conversation/assembly.ts'
export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
export type { IConversation } from './service.ts'
export type {
ConversationContextReader, ConversationEventInput, ConversationLocation,
ConversationContextReader, ConversationLocation,
ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
ConversationMatch, ConversationMatchResult, ConversationNodeContext,
ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication,
ConversationStartMatch,
ConversationStepDataMap, ConversationTimelineSnapshot, ConversationTurnDataMap,
ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode,
ConversationViewSnapshotMap, ConversationViewSnapshotStore, StepLocation, TurnLocation,
@@ -1,8 +1,13 @@
import { describe, expect, it, vi } from 'vitest'
import type {
SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationEventInput, ConversationMatch, ConversationNodeContext,
ConversationMatch, ConversationNodeContext,
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -91,8 +96,18 @@ function at(seq: number, type: string, data: unknown): SessionEvent {
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
}
function input(event: SessionEvent): ConversationEventInput {
return { event }
function input(event: SessionEvent): SessionLiveEventEntry {
return { type: 'event', event }
}
function chunkInput(row: ChunkRow): SessionEventLikeEntry {
const event = {
type: `chunkrow/${row.type}`,
seq: row.seq0,
time: row.time0,
data: row.data,
} as ChunkRowEvent
return { type: 'chunks', event }
}
function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
@@ -215,6 +230,215 @@ describe('ConversationNodeAssembler', () => {
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
})
it('keeps one packed Match through replace, Location replay, and Registry rebuild', () => {
interface State {
readonly updates: readonly string[]
readonly packedStatus: string | undefined
}
const matches = vi.fn((event: SessionEventLike) => {
if (event.type === 'step/start') return { id: '2:3', role: 'start' as const }
if ((event.type as string) === 'probe/update'
|| event.type === 'chunkrow/text-chunks') {
return { id: '2:3', role: 'update' as const }
}
return null
})
const passiveMatches = vi.fn(() => null)
const updates = vi.fn((
context: ConversationNodeContext<State> & { readonly state: State },
match: ConversationMatch,
): State => {
if (match.event.type === 'chunkrow/text-chunks') {
return {
...context.state,
updates: [
...context.state.updates,
`packed:${String(match.event.seq)}-${String(match.event.seq + match.event.data.texts.length - 1)}`,
],
packedStatus: match.location.kind === 'step'
? match.location.step.status
: match.location.kind,
}
}
return {
...context.state,
updates: [...context.state.updates, `event:${String(match.event.seq)}`],
}
})
const definition: ConversationNodeDefinition<State> = {
kind: 'packed-probe',
match: matches,
start: () => ({ updates: [], packedStatus: undefined }),
update: updates,
target: 'test',
buildViewNode: context => context.state === undefined
? null
: node(context, {
...context.state,
matches: context.matches.map(match => ({
type: match.event.type,
seq: match.event.seq,
})),
}),
}
const passive: ConversationNodeDefinition<null> = {
kind: 'packed-passive',
match: passiveMatches,
start: () => null,
update: context => context.state,
}
const run = chunkInput({
type: 'text-chunks',
seq0: 12,
time0: 1_700_000_000_012,
data: { turn: 2, step: 3, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] },
})
const inputs: SessionEventLikeEntry[] = [
input(at(10, 'step/start', { turn: 2, step: 3 })),
input(at(11, 'probe/update', { turn: 2, step: 3 })),
run,
input(at(15, 'probe/update', { turn: 2, step: 3 })),
]
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition, passive]),
new TestViewDefinitions([testView()]),
)
assembler.replaceWindow(inputs, false)
assembler.flush()
expect(matches).toHaveBeenCalledTimes(4)
expect(passiveMatches).toHaveBeenCalledTimes(4)
expect(updates).toHaveBeenCalledTimes(3)
expect(updates.mock.calls.filter(([, match]) => (
match.event.type === 'chunkrow/text-chunks'
))).toHaveLength(1)
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
updates: ['event:11', 'packed:12-14', 'event:15'],
packedStatus: 'open',
matches: [
{ type: 'step/start', seq: 10 },
{ type: 'probe/update', seq: 11 },
{ type: 'chunkrow/text-chunks', seq: 12 },
{ type: 'probe/update', seq: 15 },
],
})
assembler.append(input(at(16, 'step/end', { turn: 2, step: 3 })))
assembler.flush()
expect(updates.mock.calls.filter(([, match]) => (
match.event.type === 'chunkrow/text-chunks'
))).toHaveLength(2)
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toMatchObject({
updates: ['event:11', 'packed:12-14', 'event:15'],
packedStatus: 'closed',
})
matches.mockClear()
passiveMatches.mockClear()
updates.mockClear()
assembler.rebuildRegistry()
assembler.flush()
expect(matches).toHaveBeenCalledTimes(5)
expect(passiveMatches).toHaveBeenCalledTimes(5)
expect(updates).toHaveBeenCalledTimes(3)
expect(updates.mock.calls.filter(([, match]) => (
match.event.type === 'chunkrow/text-chunks'
))).toHaveLength(1)
})
it('replays one pending packed Match after prepend supplies its scalar start', () => {
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
const updates = vi.fn((
context: ConversationNodeContext<{ batches: number; status: string }> & {
readonly state: { batches: number; status: string }
},
match: ConversationMatch,
) => ({
batches: context.state.batches + 1,
status: match.location.kind === 'step' ? match.location.step.status : match.location.kind,
}))
const definition: ConversationNodeDefinition<{ batches: number; status: string }> = {
kind: 'packed-pending',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
}
if (event.type === 'chunkrow/reasoning-chunks') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
return null
},
start: starts,
update: updates,
target: 'test',
buildViewNode: context => context.state === undefined
? null
: node(context, {
...context.state,
matches: context.matches.map(match => [match.event.type, match.event.seq]),
}),
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
const run = chunkInput({
type: 'reasoning-chunks',
seq0: 21,
time0: 1_700_000_000_021,
data: { turn: 4, step: 5, index: 0, dt: [0, -1], texts: ['', ' ', 'x'] },
})
assembler.replaceWindow([run], true)
assembler.flush()
expect(starts).not.toHaveBeenCalled()
expect(updates).not.toHaveBeenCalled()
expect(testSnapshot(assembler)?.order).toEqual([])
assembler.prepend([
input(at(20, 'step/start', { turn: 4, step: 5 })),
], false)
assembler.flush()
expect(starts).toHaveBeenCalledOnce()
expect(updates).toHaveBeenCalledOnce()
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
batches: 1,
status: 'open',
matches: [['step/start', 20], ['chunkrow/reasoning-chunks', 21]],
})
})
it('rejects a packed event classified as a Context start', () => {
const definition: ConversationNodeDefinition<null> = {
kind: 'invalid-packed-start',
match: event => event.type === 'chunkrow/text-chunks'
? { id: 'one', role: 'start' }
: null,
start: () => null,
update: context => context.state,
}
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
const run = chunkInput({
type: 'text-chunks',
seq0: 1,
time0: 1_700_000_000_001,
data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] },
})
expect(() => assembler.replaceWindow([run], false)).toThrow(
'conversation Context 20:invalid-packed-startone received a packed start Match',
)
})
it('merges an older page and replays its affected Context once', () => {
const starts = vi.fn(() => 0)
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
@@ -11,14 +11,15 @@ import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows'
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session/types'
import type {
ChunkRowEvent,
SessionEventEntry,
SessionHistoryRecord,
SessionWireEvent,
} from '@deepseek-ai/dsh-api-session-controller/types'
import { historyEntries } from '@deepseek-ai/dsh-api-session-controller/src/client/sessions/history-records.ts'
import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationEventInput,
ConversationNodeDefinition,
ConversationViewDefinition,
ConversationViewNode,
@@ -88,26 +89,29 @@ const sessionWireEventSchema = z.object({
sourceEventSeqs: z.array(safeIntegerSchema).optional(),
surfaceOp: z.json().optional(),
}).strict()
const historyEntrySchema = z.object({ event: sessionWireEventSchema }).strict()
const historyEntrySchema = z.object({
type: z.literal('event'),
event: sessionWireEventSchema,
}).strict()
const chunkRunBaseSchema = {
turn: z.number(),
step: z.number(),
index: z.number(),
dt: z.array(safeIntegerSchema),
}
const textChunkRowSchema = z.object({
type: z.enum(['text-chunks', 'reasoning-chunks']),
seq0: safeIntegerSchema.nonnegative(),
time0: safeIntegerSchema,
const textChunkEventSchema = z.object({
type: z.enum(['chunkrow/text-chunks', 'chunkrow/reasoning-chunks']),
seq: safeIntegerSchema.nonnegative(),
time: safeIntegerSchema,
data: z.object({
...chunkRunBaseSchema,
texts: z.array(z.string()).min(1),
}).strict(),
}).strict()
const toolCallChunkRowSchema = z.object({
type: z.literal('tool-call-chunks'),
seq0: safeIntegerSchema.nonnegative(),
time0: safeIntegerSchema,
const toolCallChunkEventSchema = z.object({
type: z.literal('chunkrow/tool-call-chunks'),
seq: safeIntegerSchema.nonnegative(),
time: safeIntegerSchema,
data: z.object({
...chunkRunBaseSchema,
id: z.string(),
@@ -115,24 +119,24 @@ const toolCallChunkRowSchema = z.object({
args: z.array(z.string()).min(1),
}).strict(),
}).strict()
const chunkRowSchema: z.ZodType<ChunkRow> = z.discriminatedUnion('type', [
textChunkRowSchema,
toolCallChunkRowSchema,
]).superRefine((row, context) => {
const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
if (row.data.dt.length !== members.length - 1) {
const chunkEventSchema: z.ZodType<ChunkRowEvent> = z.discriminatedUnion('type', [
textChunkEventSchema,
toolCallChunkEventSchema,
]).superRefine((event, context) => {
const members = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
if (event.data.dt.length !== members.length - 1) {
context.addIssue({
code: 'custom',
message: 'packed chunk dt length must be one less than member count',
path: ['data', 'dt'],
})
}
if (members.length - 1 > Number.MAX_SAFE_INTEGER - row.seq0) {
context.addIssue({ code: 'custom', message: 'packed chunk seqs must stay safe integers', path: ['seq0'] })
if (members.length - 1 > Number.MAX_SAFE_INTEGER - event.seq) {
context.addIssue({ code: 'custom', message: 'packed chunk seqs must stay safe integers', path: ['seq'] })
}
let time = row.time0
for (let index = 0; index < row.data.dt.length; index++) {
time += row.data.dt[index] as number
let time = event.time
for (let index = 0; index < event.data.dt.length; index++) {
time += event.data.dt[index] as number
if (Number.isSafeInteger(time)) continue
context.addIssue({
code: 'custom',
@@ -141,11 +145,11 @@ const chunkRowSchema: z.ZodType<ChunkRow> = z.discriminatedUnion('type', [
})
break
}
}) as z.ZodType<ChunkRow>
}) as z.ZodType<ChunkRowEvent>
const packedHistoryValueSchema: z.ZodType<PackedHistoryValue> = z.object({
records: z.array(z.union([
historyEntrySchema,
z.object({ chunks: chunkRowSchema }).strict(),
z.object({ type: z.literal('chunks'), event: chunkEventSchema }).strict(),
])),
hasMore: z.boolean(),
}) as z.ZodType<PackedHistoryValue>
@@ -318,6 +322,12 @@ function buildEvents(): SessionEvent[] {
return events
}
function memberTime(event: ChunkRowEvent, index: number): number {
let time = event.time
for (let cursor = 0; cursor < index; cursor++) time += event.data.dt[cursor] as number
return time
}
function foldDefinition(kind: string, target: string): ConversationNodeDefinition<FoldState> {
return {
kind,
@@ -327,10 +337,35 @@ function foldDefinition(kind: string, target: string): ConversationNodeDefinitio
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'reasoning-delta') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
if (event.type === 'chunkrow/reasoning-chunks') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
return null
},
start: () => ({ blocks: [], deltaCount: 0 }),
update: (context, match) => {
if (match.event.type === 'chunkrow/reasoning-chunks') {
const event = match.event
const blocks = [...context.state.blocks]
blocks[event.data.index] = (blocks[event.data.index] ?? '') + event.data.texts.join('')
const firstToken = event.data.texts.findIndex(text => text !== '')
const firstVisible = event.data.texts.findIndex(text => text.trim() !== '')
return {
...context.state,
blocks,
deltaCount: context.state.deltaCount + event.data.texts.length,
lastDeltaSeq: event.seq + event.data.texts.length - 1,
...context.state.firstTokenTime === undefined && firstToken >= 0
? { firstTokenTime: memberTime(event, firstToken) }
: {},
...context.state.firstVisibleSeq === undefined && firstVisible >= 0
? {
firstVisibleSeq: event.seq + firstVisible,
firstVisibleTime: memberTime(event, firstVisible),
}
: {},
}
}
if (match.event.type !== 'assistant/chunk' || match.event.data.chunk.type !== 'reasoning-delta') {
return context.state
}
@@ -372,23 +407,31 @@ function viewDefinition(target: string): ConversationViewDefinition<Conversation
}
}
function conversationInputs(entries: readonly SessionEventEntry[]): ConversationEventInput[] {
return entries.map(entry => ({ event: entry.event as SessionEvent }))
}
function wireEntry(event: SessionEvent): SessionEventEntry {
return { event: event as unknown as SessionWireEvent }
return { type: 'event', event: event as unknown as SessionWireEvent }
}
function wireEntries(events: readonly SessionEvent[]): SessionEventEntry[] {
return events.map(wireEntry)
}
function historyRecord(record: SessionEvent | ChunkRow): SessionHistoryRecord {
return isChunkRow(record) ? { chunks: record } : wireEntry(record)
function chunkEntry(row: ChunkRow): SessionHistoryRecord {
return {
type: 'chunks',
event: {
type: `chunkrow/${row.type}`,
seq: row.seq0,
time: row.time0,
data: row.data,
} as ChunkRowEvent,
}
}
function assemble(entries: readonly ConversationEventInput[]): FoldSnapshots {
function historyRecord(record: SessionEvent | ChunkRow): SessionHistoryRecord {
return isChunkRow(record) ? chunkEntry(record) : wireEntry(record)
}
function assemble(entries: readonly SessionEventLikeEntry[]): FoldSnapshots {
const definitions = [
foldDefinition('benchmark-chat-assistant', 'chat'),
foldDefinition('benchmark-trajectory-assistant', 'trajectory'),
@@ -412,7 +455,7 @@ function digest(value: unknown): string {
it('reports packed history transport and exact replay costs', async () => {
const fixture = timed(buildEvents)
assemble(conversationInputs(wireEntries(fixture.value.slice(0, 1_000))))
assemble(historyEntries(wireEntries(fixture.value.slice(0, 1_000))))
const rawHostHeap = sampledPeakHeap((sample) => {
const entries = wireEntries(fixture.value)
sample()
@@ -456,7 +499,7 @@ it('reports packed history transport and exact replay costs', async () => {
sample()
const parsed = rawSessionHistoryValueSchema.parse(wire)
sample()
const prepared = conversationInputs(parsed.events)
const prepared = historyEntries(parsed.events)
sample()
const folded = assemble(prepared)
sample()
@@ -467,7 +510,7 @@ it('reports packed history transport and exact replay costs', async () => {
sample()
const parsed = packedHistoryValueSchema.parse(wire)
sample()
const prepared = conversationInputs(historyEntries(parsed.records))
const prepared = historyEntries(parsed.records)
sample()
const folded = assemble(prepared)
sample()
@@ -478,8 +521,8 @@ it('reports packed history transport and exact replay costs', async () => {
const parsedPacked = timed((): unknown => JSON.parse(packedJson.value))
const rawValidation = timed(() => rawSessionHistoryValueSchema.parse(parsedRaw.value))
const packedValidation = timed(() => packedHistoryValueSchema.parse(parsedPacked.value))
const rawPreparation = timed(() => conversationInputs(rawValidation.value.events))
const packedPreparation = timed(() => conversationInputs(historyEntries(packedValidation.value.records)))
const rawPreparation = timed(() => historyEntries(rawValidation.value.events))
const packedPreparation = timed(() => historyEntries(packedValidation.value.records))
assemble(rawPreparation.value.slice(0, 1_000))
assemble(packedPreparation.value)
@@ -493,7 +536,7 @@ it('reports packed history transport and exact replay costs', async () => {
expect(fixture.value.filter(event => event.type !== 'assistant/chunk')).toHaveLength(ORDINARY_EVENTS)
expect(packedRows).toHaveLength(DELTA_RUNS)
expect(packed.value).toHaveLength(696)
expect(packedPreparation.value).toHaveLength(LOGICAL_EVENTS)
expect(packedPreparation.value).toHaveLength(696)
expect(digest(packedFold.value)).toBe(digest(rawFold.value))
expect(packedClientHeap.value).toBe(rawClientHeap.value)
expect(rawHostHeap.value).toBe(rawBytes)
@@ -516,7 +559,7 @@ it('reports packed history transport and exact replay costs', async () => {
deltaEvents: DELTA_EVENTS,
deltaRuns: packedRows.length,
packedRecords: packed.value.length,
decodedEvents: packedPreparation.value.length,
conversationInputs: packedPreparation.value.length,
},
bytes: {
rawJson: rawBytes,
@@ -600,21 +643,23 @@ it('reports packed history transport and exact replay costs', async () => {
})}\n`)
}, 600_000)
it('reports exact decoding cost for long whitespace-prefix runs', () => {
it('reports compact folding cost for long whitespace-prefix runs', () => {
historyEntries([{
chunks: {
type: 'reasoning-chunks',
seq0: 0,
time0: TIME_ZERO,
type: 'chunks',
event: {
type: 'chunkrow/reasoning-chunks',
seq: 0,
time: TIME_ZERO,
data: { turn: 1, step: 1, index: 0, dt: [], texts: ['x'] },
},
}])
const results = [10_000, 20_000, 40_000].map((members) => {
const record: SessionHistoryRecord = {
chunks: {
type: 'reasoning-chunks',
seq0: 0,
time0: TIME_ZERO,
type: 'chunks',
event: {
type: 'chunkrow/reasoning-chunks',
seq: 1,
time: TIME_ZERO + 1,
data: {
turn: 1,
step: 1,
@@ -624,13 +669,20 @@ it('reports exact decoding cost for long whitespace-prefix runs', () => {
},
},
}
const decoded = historyEntries([record])
const samplesMs = Array.from({ length: 5 }, () => timed(() => historyEntries([record])).ms)
expect(decoded).toHaveLength(members)
expect(decoded.at(-1)?.event).toMatchObject({
seq: members - 1,
time: TIME_ZERO + members - 1,
data: { chunk: { type: 'reasoning-delta', text: 'x' } },
const start = wireEntry({
type: 'step/start',
seq: 0,
time: TIME_ZERO,
data: { turn: 1, step: 1 },
})
const inputs = historyEntries([start, record])
const folded = assemble(inputs)
const samplesMs = Array.from({ length: 5 }, () => timed(() => assemble(inputs)).ms)
expect((folded.chat as readonly { readonly data: FoldState }[])[0]?.data).toMatchObject({
deltaCount: members,
lastDeltaSeq: members,
firstVisibleSeq: members,
firstVisibleTime: TIME_ZERO + members,
})
return {
members,
@@ -8,12 +8,13 @@
import { Context } from '@deepseek-ai/cordis'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import {
ConversationNodeAssembler, UiConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
ConversationStartMatch, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
ConversationViewNode, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -30,6 +31,7 @@ import {
import { apply, inject } from '../src/client/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
import { en, zh } from '../src/client/locales.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
@@ -109,17 +111,20 @@ function at(
seq: number,
type: string,
data: unknown,
): ConversationEventInput {
): SessionLiveEventEntry {
return {
type: 'event',
event: {
seq, time: seq * 1_000, type, data,
...(type === 'tool/result' ? { surfaceOp: 'append' } : {}),
} as ConversationEventInput['event'],
} as SessionEvent,
}
}
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
function matched(input: SessionLiveEventEntry, role: 'start'): ConversationStartMatch
function matched(input: SessionLiveEventEntry, role: 'update'): ConversationMatch
function matched(input: SessionLiveEventEntry, role: ConversationMatch['role']): ConversationMatch {
return { event: input.event, role, location: { kind: 'unresolved' } }
}
function call(
@@ -128,7 +133,7 @@ function call(
name: string,
args: Readonly<Record<string, unknown>>,
turn = 1,
): ConversationEventInput {
): SessionLiveEventEntry {
return rawCall(seq, callId, name, JSON.stringify(args), turn)
}
@@ -138,7 +143,7 @@ function rawCall(
name: string,
argsRaw: string,
turn = 1,
): ConversationEventInput {
): SessionLiveEventEntry {
return at(
seq,
'tool/call',
@@ -146,7 +151,7 @@ function rawCall(
)
}
function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput {
function result(seq: number, callId: string, isError = false, turn = 1): SessionLiveEventEntry {
return at(seq, 'tool/result', {
turn,
step: 1,
@@ -157,7 +162,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers
})
}
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
function assembler(entries: readonly SessionLiveEventEntry[], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
@@ -330,7 +335,7 @@ describe('produced-file Turn data', () => {
event: {
...replacement.event,
surfaceOp: { op: 'replace', start: 1, end: 1 },
} as ConversationEventInput['event'],
} as SessionEvent,
},
at(26, 'turn/end', { turn: 1, reason: { kind: 'interrupted' } }),
])
@@ -354,7 +359,11 @@ describe('produced-file Turn data', () => {
const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update')
const context: Parameters<typeof deliverablesDefinition.update>[0] = { ...emptyContext, state }
expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader))
expect(() => deliverablesDefinition.start(
emptyContext,
unrelated as ConversationStartMatch,
reader,
))
.toThrow('deliverables start requires turn/start')
expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
})
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { cleanup, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
@@ -37,13 +38,14 @@ class TestViewDefinitions {
}
}
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry {
return {
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
type: 'event',
event: { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent,
}
}
function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
function snapshot(entries: readonly SessionLiveEventEntry[], hasMore = false): ChatSnapshot {
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
assembler.replaceWindow(entries, hasMore)
assembler.flush()
@@ -1,6 +1,6 @@
/** Test adapter for the production conversation.details.tool registration. */
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
@@ -68,10 +68,11 @@ export function toolChatSnapshot(
}
/** Build the Session event window that projects settled root Tool calls into Chat. */
export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionEventEntry[] {
export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionLiveEventEntry[] {
const firstTime = nodes[0]?.callTime ?? nodes[0]?.time ?? 0
const entries: SessionEventEntry[] = [
const entries: SessionLiveEventEntry[] = [
{
type: 'event',
event: {
seq: 1,
time: firstTime - 2,
@@ -80,6 +81,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
},
},
{
type: 'event',
event: {
seq: 2,
time: firstTime - 1,
@@ -91,7 +93,8 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
for (const [index, node] of nodes.entries()) {
if (node.call === null) throw new Error(`tool fixture "${node.callId}" requires its call event`)
const callSeq = 3 + index * 2
const callEntry: SessionEventEntry = {
const callEntry: SessionLiveEventEntry = {
type: 'event',
event: {
seq: callSeq,
time: node.callTime ?? node.time - 1,
@@ -103,10 +106,11 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
name: node.call.name,
arguments: node.call.argsRaw,
},
},
} as unknown as SessionLiveEventEntry['event'],
}
entries.push(callEntry)
const resultEntry: SessionEventEntry = {
const resultEntry: SessionLiveEventEntry = {
type: 'event',
event: {
seq: callSeq + 1,
time: node.time,
@@ -129,7 +133,7 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
...(node.meta === undefined ? {} : { meta: node.meta }),
}),
surfaceOp: 'append',
},
} as unknown as SessionLiveEventEntry['event'],
}
entries.push(resultEntry)
}
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943
README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7
README.md: c29dc1c819f6dc422f979b64da6c06cd4d6cc5a1
README.zh.md: b4f279ad217fa393601c6361d0dc19d582a1d4a8
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Durable image attachments in user input, assistant output, and tool results render through the `conversation.trajectory.images` gallery slot: a record without text labels its row with the image count, the inspector shows each image with the shared loading, retry, and lightbox behavior, and image URLs come from the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Durable image attachments in user input, assistant output, and tool results render through the `conversation.trajectory.images` gallery slot: a record without text labels its row with the image count, the inspector shows each image with the shared loading, retry, and lightbox behavior, and image URLs come from the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps standard events and packed historical Assistant runs. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window and fold packed runs without member expansion, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data.
## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。用户输入、助手输出和工具结果中的持久化图片附件通过 `conversation.trajectory.images` 画廊 slot 渲染:没有文本的记录行以图片数量标注,检查器内展示每张图片并复用共享的加载、重试与灯箱行为,图片 URL 来自 Conversation 持有的按会话缓存,因此 Chat 与 Trajectory 对同一附件共享一次经会话授权的读取。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。用户输入、助手输出和工具结果中的持久化图片附件通过 `conversation.trajectory.images` 画廊 slot 渲染:没有文本的记录行以图片数量标注,检查器内展示每张图片并复用共享的加载、重试与灯箱行为,图片 URL 来自 Conversation 持有的按会话缓存,因此 Chat 与 Trajectory 对同一附件共享一次经会话授权的读取。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留标准 event 与 packed Assistant 历史 run。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,并直接 fold packed run 而不展开 member因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。
## 模型体验
@@ -1,4 +1,5 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
@@ -36,6 +37,7 @@ interface AssistantState {
readonly started: boolean
readonly sawChunk: boolean
readonly blocks: readonly (AssistantBlock | undefined)[]
readonly visibleBlocks: number
readonly firstVisibleSeq: number | undefined
readonly firstVisibleTime: number | undefined
readonly firstTokenTime: number | undefined
@@ -45,6 +47,12 @@ interface AssistantState {
readonly stepEnd: ConversationMatch | undefined
}
function isChunkRunEvent(event: ConversationMatch['event']): event is ChunkRowEvent {
return event.type === 'chunkrow/text-chunks'
|| event.type === 'chunkrow/reasoning-chunks'
|| event.type === 'chunkrow/tool-call-chunks'
}
function initialState(
turn: number,
step: number,
@@ -60,6 +68,7 @@ function initialState(
started,
sawChunk: false,
blocks: [],
visibleBlocks: 0,
firstVisibleSeq: undefined,
firstVisibleTime: undefined,
firstTokenTime: undefined,
@@ -74,12 +83,16 @@ function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): Assista
return blocks.filter((block): block is AssistantBlock => block !== undefined)
}
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
return blocks.some((block) => {
if (block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
})
function blockIsVisible(block: AssistantBlock | undefined): boolean {
if (block === undefined || block.kind === 'tool-call') return false
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
return true
}
function countVisibleBlocks(blocks: readonly AssistantBlock[]): number {
let count = 0
for (const block of blocks) if (blockIsVisible(block)) count++
return count
}
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
@@ -112,12 +125,18 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) }
}
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
switch (chunk.type) {
case 'block-start':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
break
case 'text-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
blocks[chunk.index] = {
kind: 'text',
text: (previous?.kind === 'text' ? previous.text : '') + chunk.text,
@@ -126,6 +145,8 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
}
case 'reasoning-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
blocks[chunk.index] = {
kind: 'reasoning',
text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text,
@@ -134,6 +155,8 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
}
case 'tool-call-delta': {
const previous = blocks[chunk.index]
changedIndex = chunk.index
previousVisible = blockIsVisible(previous)
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
@@ -146,17 +169,22 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
break
}
case 'block-end':
changedIndex = chunk.index
previousVisible = blockIsVisible(blocks[chunk.index])
blocks[chunk.index] = toAssistantBlock(chunk.block)
break
default:
return { ...state, sawChunk: true }
}
const visible = hasVisibleContent(compactBlocks(blocks))
const visibleBlocks = state.visibleBlocks
- Number(previousVisible)
+ Number(blockIsVisible(blocks[changedIndex]))
return {
...state,
sawChunk: true,
blocks,
...(visible && state.firstVisibleSeq === undefined
visibleBlocks,
...(visibleBlocks > 0 && state.firstVisibleSeq === undefined
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
: {}),
...(isTokenDelta(chunk) && state.firstTokenTime === undefined
@@ -165,6 +193,88 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
}
}
interface ChunkRunBoundaries {
readonly firstTokenTime: number | undefined
readonly firstVisible: { readonly seq: number; readonly time: number } | undefined
}
function chunkRunBoundaries(
event: ChunkRowEvent,
needsToken: boolean,
needsVisible: boolean,
visibleFromStart: boolean,
): ChunkRunBoundaries {
const fragments = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
const nameStartsToken = event.type === 'chunkrow/tool-call-chunks'
&& Object.hasOwn(event.data, 'name')
let firstTokenTime: number | undefined
let firstVisible: ChunkRunBoundaries['firstVisible']
let time = event.time
for (let index = 0; index < fragments.length; index++) {
const fragment = fragments[index] as string
if (needsToken && firstTokenTime === undefined && (nameStartsToken || fragment !== '')) {
firstTokenTime = time
}
if (needsVisible && firstVisible === undefined
&& (visibleFromStart
|| (event.type !== 'chunkrow/tool-call-chunks' && fragment.trim() !== ''))) {
firstVisible = { seq: event.seq + index, time }
}
if ((!needsToken || firstTokenTime !== undefined)
&& (!needsVisible || firstVisible !== undefined)) break
time += event.data.dt[index] ?? 0
}
return { firstTokenTime, firstVisible }
}
function updateChunkRun(state: AssistantState, event: ChunkRowEvent): AssistantState {
const blocks = [...state.blocks]
const previous = blocks[event.data.index]
const previousVisible = blockIsVisible(previous)
let visibleFromStart = state.visibleBlocks - Number(previousVisible) > 0
if (event.type === 'chunkrow/text-chunks') {
const text = previous?.kind === 'text' ? previous.text : ''
visibleFromStart ||= text.trim() !== ''
blocks[event.data.index] = { kind: 'text', text: text + event.data.texts.join('') }
} else if (event.type === 'chunkrow/reasoning-chunks') {
const text = previous?.kind === 'reasoning' ? previous.text : ''
visibleFromStart ||= text.trim() !== ''
blocks[event.data.index] = { kind: 'reasoning', text: text + event.data.texts.join('') }
} else {
const base = previous?.kind === 'tool-call'
? previous
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
blocks[event.data.index] = {
kind: 'tool-call',
callId: base.callId || String(event.data.id),
name: Object.hasOwn(event.data, 'name') ? event.data.name as string : base.name,
argsRaw: base.argsRaw + event.data.args.join(''),
}
}
const boundaries = chunkRunBoundaries(
event,
state.firstTokenTime === undefined,
state.firstVisibleSeq === undefined,
visibleFromStart,
)
const visibleBlocks = state.visibleBlocks
- Number(previousVisible)
+ Number(blockIsVisible(blocks[event.data.index]))
return {
...state,
sawChunk: true,
blocks,
visibleBlocks,
...(boundaries.firstVisible === undefined ? {} : {
firstVisibleSeq: boundaries.firstVisible.seq,
firstVisibleTime: boundaries.firstVisible.time,
}),
...(boundaries.firstTokenTime === undefined ? {} : {
firstTokenTime: boundaries.firstTokenTime,
}),
}
}
function closedBoundary(
context: ConversationNodeContext<AssistantState>,
): { seq: number; time: number } | undefined {
@@ -180,15 +290,28 @@ function closedBoundary(
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
if (isChunkRunEvent(match.event)) {
state ??= initialState(
match.event.data.turn,
match.event.data.step,
match.event.seq,
match.event.time,
false,
)
state = updateChunkRun(state, match.event)
continue
}
const event = match.event
if (event.type === 'assistant/chunk') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
state = updateChunk(state, match)
} else if (event.type === 'assistant/message') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
const blocks = toAssistantBlocks(event.data.message.content)
state = {
...state,
blocks: toAssistantBlocks(event.data.message.content),
blocks,
visibleBlocks: countVisibleBlocks(blocks),
final: match,
usage: state.usage ?? event.data.usage,
}
@@ -228,8 +351,9 @@ function finalNode(
}
}
const boundary = closedBoundary(context)
if (boundary === undefined) return undefined
const blocks = compactBlocks(state.blocks)
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
if (!hasInterruptionEvidence(blocks)) return undefined
return {
kind: 'assistant',
seq: boundary.seq - 0.9,
@@ -291,6 +415,9 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
|| event.type === 'step/end') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
if (isChunkRunEvent(event)) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
return null
},
start: (_context, match) => {
@@ -306,11 +433,14 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
)
},
update: (context, match) => {
if (isChunkRunEvent(match.event)) return updateChunkRun(context.state, match.event)
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
if (match.event.type === 'assistant/message') {
const blocks = toAssistantBlocks(match.event.data.message.content)
return {
...context.state,
blocks: toAssistantBlocks(match.event.data.message.content),
blocks,
visibleBlocks: countVisibleBlocks(blocks),
final: match,
usage: context.state.usage ?? match.event.data.usage,
}
@@ -340,6 +470,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
if (isChunkRunEvent(match.event)) return 'animation-frame'
if (match.event.type !== 'assistant/chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
@@ -1,9 +1,17 @@
import type { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type {
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ChunkRowEvent,
} from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
@@ -52,19 +60,38 @@ function at(
type: string,
data: unknown,
extra: Record<string, unknown> = {},
): ConversationEventInput {
): SessionLiveEventEntry {
return {
type: 'event',
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
data,
...extra,
} as unknown as ConversationEventInput['event'],
} as unknown as SessionEvent,
}
}
function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
return {
type: 'chunks',
event: {
type: `chunkrow/${row.type}`,
seq: row.seq0,
time: row.time0,
data: row.data,
} as ChunkRowEvent,
}
}
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
})
}
function assembler(events: readonly SessionEventLikeEntry[]): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(
new TestEventDefinitions(),
new TestViewDefinitions(),
@@ -153,6 +180,139 @@ describe('Trajectory conversation Definitions', () => {
}])
})
it('folds packed Assistant runs to the same Trajectory state as scalar deltas', () => {
const runningHistory = [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
at(4, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}),
at(5, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
}),
at(6, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
}),
at(7, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
}),
at(8, 'assistant/chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
}),
at(9, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
}),
at(10, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
}),
at(11, 'assistant/chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
}),
]
const runningScalar = snapshot(assembler(runningHistory))
const packedHistory = packedInputs(runningHistory)
expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
const runningPacked = snapshot(assembler(packedHistory))
expect(runningPacked).toEqual(runningScalar)
expect(runningPacked.partial?.blocks).toEqual([
{ kind: 'text', text: ' answer' },
{ kind: 'reasoning', text: 'thinking' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
])
const partialHistory = [
...runningHistory.slice(2),
at(12, 'step/end', { turn: 1, step: 1 }),
]
const partialScalar = snapshot(assembler(partialHistory))
const partialPacked = snapshot(assembler(packedInputs(partialHistory)))
expect(partialPacked).toEqual(partialScalar)
expect(partialPacked.eventNodes).toMatchObject([{
kind: 'assistant',
interrupted: true,
blocks: [
{ kind: 'text', text: ' answer' },
{ kind: 'reasoning', text: 'thinking' },
{ kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
],
}])
const finalizedHistory = [
at(20, 'turn/start', { turn: 2 }),
at(21, 'step/start', { turn: 2, step: 1 }),
at(22, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 3_000 }),
at(23, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 3_000 }),
at(24, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
}, { time: 2_998 }),
at(25, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
}),
at(26, 'llm/retry', {
retryId: 'packed-retry', turn: 2, step: 1, provider: 'test', mode: 'normal',
policyKey: 'test-normal', retry: 1, maxRetries: 2, delayMs: 25,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}),
at(27, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
at(28, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
}),
at(29, 'assistant/chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
}),
at(30, 'assistant/message', {
turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
}),
at(31, 'step/end', { turn: 2, step: 1 }),
]
const finalizedScalar = snapshot(assembler(finalizedHistory))
const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
expect(finalizedPacked).toEqual(finalizedScalar)
expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
timing: { firstTokenTime: 3_000 },
})
expect(finalizedPacked.requests).toMatchObject([{
purpose: 'assistant',
usage: { inputTokens: 10, outputTokens: 3 },
retry: 1,
}])
const namedToolHistory = [
at(40, 'turn/start', { turn: 3 }),
at(41, 'step/start', { turn: 3, step: 1 }),
...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
turn: 3, step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
}, { time: 4_000 + seq - 42 })),
at(45, 'assistant/message', {
turn: 3,
step: 1,
message: {
...assistantMessage('named-tool-final', ''),
content: [{ type: 'tool-call', id: 'call-2', name: 'read', arguments: '' }],
},
}),
]
const namedToolScalar = snapshot(assembler(namedToolHistory))
const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
expect(namedToolPacked).toEqual(namedToolScalar)
expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
timing: { firstTokenTime: 4_000 },
})
})
it('classifies a cancellation-finalized prefix as an interrupted request result', () => {
const current = snapshot(assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -6,12 +6,15 @@ import {
ConversationNodeAssembler, UiConversation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ConversationEventInput, ConversationMatch, ConversationNodeDefinition, ConversationViewDefinition,
ConversationMatch, ConversationNodeDefinition, ConversationStartMatch,
ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
SessionListState, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import {
chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot,
@@ -84,15 +87,17 @@ const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, C
},
}
function at(seq: number, type: string, data: unknown): ConversationEventInput {
return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'] }
function at(seq: number, type: string, data: unknown): SessionLiveEventEntry {
return { type: 'event', event: { seq, time: seq * 100, type, data } as SessionEvent }
}
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
function matched(input: SessionLiveEventEntry, role: 'start'): ConversationStartMatch
function matched(input: SessionLiveEventEntry, role: 'update'): ConversationMatch
function matched(input: SessionLiveEventEntry, role: ConversationMatch['role']): ConversationMatch {
return { event: input.event, role, location: { kind: 'unresolved' } }
}
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
function assembler(entries: readonly SessionLiveEventEntry[], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
@@ -104,7 +109,7 @@ function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | u
return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
}
function completeEvents(): ConversationEventInput[] {
function completeEvents(): SessionLiveEventEntry[] {
return [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -445,6 +445,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ChildrenDecl',
declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec<SlotMap[P]>;\n};',
},
{
name: 'ChunkRowEvent',
declaration: 'export type ChunkRowEvent = {\n [Kind in ChunkRow[\'type\']]: {\n readonly type: `chunkrow/${Kind}`;\n readonly seq: number;\n readonly time: number;\n readonly data: Extract<ChunkRow, {\n readonly type: Kind;\n }>[\'data\'];\n };\n}[ChunkRow[\'type\']];',
},
{
name: 'ClientConnectionRpc',
declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<ConnectionRpcResult<unknown>>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable<unknown>;\n}',
@@ -643,11 +647,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventChange',
declaration: 'export type SessionEventChange = {\n readonly kind: \'replace\';\n readonly entries: readonly SessionEventEntry[];\n} | {\n readonly kind: \'prepend\';\n readonly entries: readonly SessionEventEntry[];\n} | {\n readonly kind: \'append\';\n readonly entries: readonly SessionEventEntry[];\n};',
declaration: 'export type SessionEventChange = {\n readonly kind: \'replace\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'prepend\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'append\';\n readonly entries: readonly SessionLiveEventEntry[];\n};',
},
{
name: 'SessionEventEntry',
declaration: 'export interface SessionEventEntry {\n readonly event: SessionWireEvent;\n}',
name: 'SessionEventLikeEntry',
declaration: 'export type SessionEventLikeEntry = {\n readonly type: \'event\';\n readonly event: SessionEvent;\n} | {\n readonly type: \'chunks\';\n readonly event: ChunkRowEvent;\n};',
},
{
name: 'SessionEventSource',
@@ -655,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventWindow',
declaration: 'export interface SessionEventWindow {\n readonly entries: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly revision: number;\n readonly change: SessionEventChange;\n}',
declaration: 'export interface SessionEventWindow {\n readonly entries: readonly SessionEventLikeEntry[];\n readonly hasMore: boolean;\n readonly revision: number;\n readonly change: SessionEventChange;\n}',
},
{
name: 'SessionFace',
@@ -665,6 +669,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionIdOf',
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
},
{
name: 'SessionLiveEventEntry',
declaration: 'export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, {\n readonly type: \'event\';\n}>;',
},
{
name: 'SessionMaybeStandardProps',
declaration: 'export interface SessionMaybeStandardProps {\n}',
@@ -685,10 +693,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionStandardProps',
declaration: 'export interface SessionStandardProps {\n}',
},
{
name: 'SessionWireEvent',
declaration: 'export interface SessionWireEvent {\n readonly type: string;\n readonly seq: number;\n readonly time: number;\n readonly data: JsonValue;\n readonly ignorable?: true;\n readonly sourceEventSeqs?: number[];\n readonly surfaceOp?: SurfaceOp;\n}',
},
{
name: 'SlotComponent',
declaration: 'export type SlotComponent<P> = (props: P) => ReactNode;',
@@ -1,8 +1,7 @@
/** Controller and UI-domain fixture shapes for the client test runtime. */
import type {
ISession, SessionSnapshot, SessionSummary,
ISession, SessionEventLikeEntry, SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
@@ -53,7 +52,7 @@ export interface SessionFixture {
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
session?: SessionBehaviorOverrides
/** Initial contiguous event window consumed by Conversation assembly. */
events?: readonly SessionEventEntry[]
events?: readonly SessionEventLikeEntry[]
/** Whether the initial event window has an older page. */
hasMore?: boolean
}
@@ -6,9 +6,9 @@ import {
} from '@deepseek-ai/dsh-api-session-controller/client'
import type {
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
SessionSearchResultItem, SessionSnapshot, SessionSummary,
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -259,7 +259,7 @@ export class TestSessions implements ISessions {
*/
async replaceEvents(
id: string,
entries: readonly SessionEventEntry[],
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.replace(entries, hasMore) })
@@ -273,7 +273,7 @@ export class TestSessions implements ISessions {
*/
async prependEvents(
id: string,
entries: readonly SessionEventEntry[],
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.prepend(entries, hasMore) })
@@ -284,7 +284,7 @@ export class TestSessions implements ISessions {
* @param id - Session identity.
* @param entry - live event entry.
*/
async appendEvent(id: string, entry: SessionEventEntry): Promise<void> {
async appendEvent(id: string, entry: SessionLiveEventEntry): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.append(entry) })
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react'
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -24,15 +24,16 @@ afterAll(() => {
expect(navigator.language).toBe(originalLanguage)
})
function entry(seq: number): SessionEventEntry {
function entry(seq: number): SessionLiveEventEntry {
return {
type: 'event',
event: {
type: 'fixture/event',
seq,
time: seq,
data: { seq },
ignorable: true,
},
} as SessionLiveEventEntry['event'],
}
}