DSH / Atlas
2026-08-09implementedarchitecture

Client Conversation business-node assembly and keyed Chat snapshots

Client Conversation 业务节点组装与 Chat keyed snapshot

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

English

Problem

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.

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.

This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.

Responsibility layers

LayerDurable responsibilityExplicitly does not own
SessionMaintain the contiguous Event window, distinguish replace, prepend, and append, and schedule snapshot notificationsInterpret Tool, Assistant, Compaction, or other business events
Event RegistryRetain the unique-kind Definitions and sole fallback under Cordis lifecyclesStore one Session's Context or State
AssemblerMatch Events and maintain Contexts, Locations, dependencies, and the publication dirty setInterpret business State fields or Chat ordering
Node DefinitionDefine one business object's identity, State transitions, Location data, and target NodeCreate Contexts, mutate another business's State, or scan all Contexts
View BuilderIncrementally organize final target Nodes into that view's snapshotReinterpret raw Session Events
React rendererRender renderer-owned data by the final Node's kind and read business data from the current Node's LocationPair 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 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.

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.

A Definition holds no mutable business data across Sessions. Each Session's Assembler isolates that Session's Contexts, State, dependencies, and View Builders.

kind, business ID, and Context key

The id returned by match() only needs to be stable within its Definition. A Tool ID can be a call ID, an Assistant ID can be turn:step, and an Inbox ID can be the splice Event seq.

The Assembler uses conversationContextKey(kind, id) to make a collision-free key. Definitions that return the same id still do not share a Context. The final view Node must retain this engine-owned key and cannot use seq or render position as identity.

Each (kind, id) has at most one start Match. A second start fails immediately; a Definition must return a new ID to represent a new lifecycle.

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.

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.

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."

The role describes the State lifecycle, not visibility. A start may produce a terminal Node immediately, while an update may enter a pending Context before its start has loaded.

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.

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.

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.

ConversationNodeContext

FieldOwnerSemantics visible to the Definition
keyAssemblerStable final identity derived from kind + id
kind / idDefinition + AssemblerCurrent business namespace and business ID
matchesAssemblerComplete business evidence loaded in the current window and sorted by seq
startAssemblerUnique start Match, or undefined before it loads
stateReturned by Definition, held by AssemblerMost recent start/update return value, or undefined before initialization
currentAssemblerMost recently materialized Node or null for each target

Read-only Context fields do not require deeply immutable business State. A Definition may return a new object or mutate the old object in place and return the same reference.

The Assembler adopts only the returned value. Returning undefined from start() or update() is a contract error and fails immediately; mutating an object without returning it is likewise invalid.

A Definition may inspect all matches to help construct State or a fallback Node, but it cannot add or remove Matches, replace Context fields, or mutate another Context.

start(context, match, reader)

start() is the sole State initialization entry point. The Assembler invokes it when the unique start first appears and adopts its returned State.

When an older page changes Match order, the Reader's predecessor answer, or Location facts, the Assembler recomputes from start() instead of applying a reverse-direction patch to old State.

The Context may already contain updates after the start when start() runs. After start() returns initial State, the Assembler still invokes update() for every post-start Match in ascending log order, so ingestion direction cannot change the final fold.

The reader is available only in start(). Initialization can read the nearest active Context of a specified kind strictly before the current start seq, but business code receives no general interface for scanning internal engine Maps.

Each new start() invocation replaces the Reader dependencies recorded by the prior invocation, so a Definition that changes its query branch retains no stale edges.

reader.previous(kind)

reader.previous(kind) finds the nearest Context whose candidate.startSeq < current.startSeq and whose State is initialized. It never returns a Context at the same seq, a future Context, or a pending Context without State.

The result contains the predecessor's key, kind, ID, start seq, read-only State, and Matches. The consumer interprets that State itself; the provider only maintains its State correctly and need not register a specialized query method.

Each Reader query records a { key, revision, windowGap } dependency. A matched predecessor's revision change replays the consumer; a miss while older history remains records a window gap for a later prepend.

When the window already reaches the Session beginning, a miss is a definitive undefined. When hasMore is true, the Definition sees the same undefined, but the Assembler remembers that the result is provisional.

Dependencies point strictly from earlier starts to later starts, so transitive replay cannot form a temporal cycle. Both the Inbox instantaneous-state chain and Message reads of Inbox use this constraint.

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.

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().

When no business data changes, update() returns the existing State. When data changes, it may return an immutable replacement or mutate the existing object and return that object.

The Assembler does not use State reference equality to decide publication or propagation. Every accepted update increments the Context revision, marks it dirty, and causes direct or transitive Reader consumers to be reevaluated.

publication(match)

publication() controls when the latest State materializes as a view Node; it does not delay the synchronous execution of match(), start(), or update().

Return valueBehavior
immediateRequest a notification and flush in the current microtask
animation-frameCoalesce high-frequency updates into materialization on the next frame
noneDo 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.

Every delta within a frame still executes update. Only buildViewNode(), View Builder work, and React snapshot notification are coalesced; no tokens are lost.

buildLocationData(context, scope)

buildLocationData() lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes step before turn, so Turn-level aggregation can read Step data updated in the same flush; it calls buildViewNode() only after all Location data is ready.

A Definition receives the step and turn scopes separately and may return one value or null in either phase. A value must identify the exact turn/step coordinates and use the Definition's kind as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key.

ConversationStepDataMap and ConversationTurnDataMap use declaration merging to constrain keys and values. A Location exposes only a stable data.get(key) reader; consumers cannot obtain the provider Context or mutate its State.

buildViewNode(context, target)

buildViewNode() reads the latest Context during publication and directly produces the final business Node for the named target. The Assembler adds no generic activity, tail-candidate, or layout business layer afterward.

null means this Context has not yet materialized for the target. On the ordinary incremental path, a Context that has returned a non-null Node cannot later return null; temporary absence retains the same-key Node and uses the target's visibility representation.

The Assembler verifies node.key === context.key and node.target === target. Business code may change anchorSeq, data, Location, or visibility, but cannot change identity within one lifecycle.

current lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry suppression uses it to avoid illegal Node withdrawal.

A Definition owns at most one view target; state-only Definitions omit both target and buildViewNode(). Chat and Trajectory register separate business Definitions even when they recognize the same durable Event family, while the shared Assembler supplies the same matching, replay, Location, and publication mechanics to both targets.

No generic end()

The engine exposes no fixed end() lifecycle. A single-Event business completes in start(), a multi-Event business records completion in its own update, and a long-lived instantaneous-state business creates a new Context for every Event.

Step and Turn closure are external Location facts and do not mutate business State. A boundary change replays and builds affected Contexts; each business combines its own completion State with whether its Location is closed to produce normal, running, or interrupted presentation.

IDs are never reused. Completed Contexts remain in the current window, providing stable render identity and possible predecessor evidence for later Readers.

Location is a first-class engine fact

ConversationLocationIndex maps Events to Locations from turn/start, step/start, explicit turn and step payloads, step/end, and turn/end.

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.

Each Turn and Step also carries a reference-stable Location data store. A Definition update replaces only its owned key; the same store identity can acquire new values through append or prepend, allowing Contexts, View Builders, and React renderers to share resolved hierarchy-level business facts without copying or scanning the global Node array.

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.

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

"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.

ScenarioInput rangeContext and State handlingView Builder
Initial history tail or resyncCurrent complete contiguous windowClear and rebuild all Contexts in ascending seq orderreplace()
Load one older-history pageOnly deduplicated fresh Events before the windowRetain existing Context identity, then add Matches, Locations, dependencies, and local replaysapply(upserts)
Live appendOne contiguous tail EventMatch Definitions and update only the exact IDs; boundaries affect only their owning Turnapply(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).
  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.
  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).
  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.
  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().

This path starts from the newest page only at the pagination layer. State within the page always computes forward, so the same window does not produce different business results under a different scan direction.

A Context without a start is not an error. It is a pending aggregation container waiting for an older page; that Definition's buildViewNode() decides whether the evidence already makes it visible.

If an update with the same ID is genuinely earlier than the start in log order, rather than merely loaded first, replay fails with a protocol error after the start arrives. Arrival order may be reversed; business log order may not.

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.
  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.
  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.
  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.
  11. An empty page that changes hasMore from true to false also rechecks dependencies and resolves a provisional undefined to definitive absence.
  12. The flush republishes Step/Turn Location data and target Nodes only for dirty Contexts, then passes non-null results to View Builder apply() as upserts.

Prepend retains existing Context keys and current Node identity. A page may add historical keys at the front of Chat order or correct an existing Node's anchor, Location, visibility, or data, but it does not recreate unrelated business Contexts.

On a structural change, the Chat Builder recomputes visible order and the secondary Location index from its keyed store. That is view-index work; it neither reruns every business Definition nor replaces unchanged Node values.

Reader gap repair is the largest algorithmic difference between prepend and ordinary append. A page can both add visible historical Nodes and change later Inbox instantaneous states and the Message classifications that depend on them.

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.
  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).
  5. A new ID creates a Context; a normal tail update for an existing ID invokes update() once.
  6. A start or any evidence inserted before the tail uses complete replayContext() and retains the same forward-order semantics.
  7. After a Context revision changes, only recorded Reader dependents replay.
  8. Location close updates affected Matches within its owning Turn and replays those Contexts, allowing unfinished Assistant, Tool, or Retry values to acquire interrupted or cancelled presentation.
  9. The Assembler takes the highest publication urgency among all matching Definitions: immediate outranks animation-frame, which outranks none.
  10. Session routes immediate work to the microtask notifier and animation-frame work to the RAF notifier.
  11. The flush updates Step/Turn Location data for dirty Contexts, then invokes buildViewNode() and passes this transaction's upserts and latest timeline to each View Builder.
  12. The new React snapshot reuses stable Context keys; the same Tool running→settled or Assistant streaming→final value never moves across parents.

Append's business-matching cost is the Definition count plus the Contexts actually updated, independent of historical Context count. Reader consumers and Location closure add replay proportional to real dependencies or the owning Turn.

A structural Chat order change can still reorder the current visible keys. A data-only update replaces one keyed-store Node and touches its Location index. The guarantee is that unrelated businesses do not refold and unchanged Node identity is retained, not that every view-index operation has constant complexity.

Consistency across replace, prepend, and append

All three paths preserve the same invariants: Context Matches are seq-ordered, State folds forward from one unique start, Reader sees only strictly preceding active Contexts, Location data publishes in Step→Turn order, and Node key depends only on kind and ID.

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.

How built-in businesses use Definitions

Matching, ID, and State

Business / kindStable IDStart MatchUpdate MatchesState and cross-Context reads
Next-turn Inbox / inbox-next-turnSplice Event seqEach agent/inbox/spliced targeting next-turnNoneApply the current splice to the pending/claimed instantaneous state from reader.previous(ownKind)
Next-step Inbox / inbox-next-stepSplice Event seqEach agent/inbox/spliced targeting next-stepNoneBuild the same per-instruction instantaneous state; Message reads its claimed set
Message / input-messageMessage IDAppend-surface user/messageNoneUse source for a context message, or read the nearest next-step Inbox to distinguish user from steering
Assistant / assistant-stepturn:stepstep/startassistant/chunk, final assistant/message, and same-step RetryAggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data
Tool / tool-callRoot call IDRoot tool/callRoot result and Code Dispatch start/resultAggregate the root, children, and parent Map; Dispatch Events route exactly through rootCallId
Command / commandCommand IDcommand/runcommand/done and compact lifecycle/checkpoint Events carrying a source command IDAggregate command outcome and manual-compaction evidence
Automatic Compaction / compactionCompaction IDcompaction/start without a source command IDSummary, end, and replacement checkpointAggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start
Retry / model-retryRetry IDAttempt 1 llm/retryLater llm/retry and llm/retry-startedAggregate one RetryId's attempts and scheduled/started state
Turn Error / turn-errorTurn numberturn/startError turn/endAggregate the terminal failure; the turn's Retry history renders through Retry and never hides this row
Turn Tail / turn-tailTurn numberturn/startAssistant, Retry, step/end, and turn/endRetain turn end, read each Step's Assistant data, and publish Turn data; use complete Matches to choose the visual tail anchor
Deliverables / deliverablesTurn numberturn/startTool calls/results in that TurnAggregate successful mutation paths and publish Turn data without producing a view Node
Unknown fallback / unknown-surfaceEvent seqAppend-surface Event unclaimed by any ordinary DefinitionNoneRetain raw type/data for the JSON fallback

Chat Node and history/live behavior

Businesspublication()Chat outputHistory and runtime behavior
InboxnoneNo NodeRecompute instantaneous states along the Reader chain when prepend supplies earlier splices
MessageImmediate by defaultuser, steering, or contextWindow-gap repair can reclassify the same message key
AssistantRAF for chunks, immediate for final, none for pure usage/finishSame-key assistant-step with running/settled/interrupted statusMatches support fallback without step/start; Location close produces interruption presentation
ToolImmediate by defaultOne recursive tool-call root containing all subCallsA result-only history window supports fallback; running→settled retains its key
CommandImmediate by defaultOrdinary command or integrated manual-compactionCheckpoint arrival may change the anchor without changing the Context key
CompactionImmediate by defaultcompaction markerA checkpoint may render before start; an older start triggers forward replay
RetryImmediate by defaultOne model-retry Node containing all attemptsMultiple retries update one key; Location close presents the last scheduled attempt as cancelled
Turn ErrorImmediate by defaultturn-error on terminal failureError end supports fallback without start; the turn's settled Retry chain renders beside it
Turn TailImmediate only for turn/end; otherwise noneIndependent turn-tail footerCompute closing/metrics from Step Assistant data and use same-turn Matches to choose the anchor
DeliverablesImmediate by defaultNo NodeTool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files
FallbackImmediate by defaultunknown JSON rowCovers only append-surface Events; an ordinary business that claimed but has not rendered an Event does not duplicate it

Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox.

Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node.

Assistant, Turn Tail, and Deliverables demonstrate layered Location data composition. Assistant writes assistant-step data for each Step; Turn Tail derives turn-tail data from those Step values; Deliverables independently maintains deliverables data for the same Turn. Consumers read only declaration-merged keys, do not scan another business's Nodes, and cannot obtain the provider's Context State.

Tool and Command demonstrate multi-Event aggregation: the producer supplies a shared ID, and the Context builds a tree or integrates Compaction internally instead of pushing pairing into the Chat Builder.

Compaction and historical Tool results demonstrate business fallback without a start. The engine does not impose "no start means no rendering"; each Definition decides whether current Matches are sufficient.

Retry demonstrates the State and Location split. Scheduled and started belong to Retry State, while Step and Turn closure belong to engine Location; buildViewNode() combines them into cancelled presentation.

Unknown fallback demonstrates Registry ownership: it handles only append-surface Events unclaimed by every ordinary matcher, and does not create a duplicate Node merely because a claimed Context temporarily returns null.

View Builder and React identity

ConversationViewRegistry creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches.

The Assembler calls replace({ nodes, timeline }) on low-frequency complete replacements and apply({ upserts, timeline }) for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions.

ChatSnapshotBuilder maintains order, a keyed nodes store, the turn/step locations index, timeline, and the legacy slice used by StatsLine and mirrored into top-level public compatibility fields.

Only a new key or a change to anchorSeq, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild order; the keyed Node store replaces only that key's value.

For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys.

ChatView only traverses order. Each ChatNodeSeat remains in the same parent list under its Context key and dispatches the 'conversation.chat.node' keyed slot by node.kind.

ChatNodeDataMap is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; registerConversationNodes() and registerChatNodeRenderers() only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in ui-conversation, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.

The Chat entry in conversation.view registers ChatNodeTurnDataInjected once when it declares the conversation.chat.node child slot. ChatNodeSeat passes only the stable Node key as hookContext; the Slot renderer combines that key with useSession from the official standard props to construct useTurnData(businessKey). Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.

Slot-level contextual Hooks and entry-owned inject.hooks remain independent paths. The latter continues to bind only registration-owned Observables. The former caches definitions by stable slot-inject-face identity and binds its factory and Hook per stable render occurrence. The selector inside useTurnData() returns only the current Node's turn.data.get(key), so selector equality filters unrelated Session publications.

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.

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. 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.

The target-specific Trajectory Definitions, retained stage model, Steering adaptation, complexity bounds, and presentation hot paths are owned by the Trajectory Context assembly decision.

Runtime and render path

Session Event window
  -> ConversationNodeAssembler
       -> Definition.match(event) -> (kind, id, start/update)
       -> Context matches + State + Location
       -> Definition.buildLocationData(step -> turn)
            -> StepLocation.data / TurnLocation.data
       -> Definition.buildViewNode() for its declared target
  -> target View Builder
       -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat
       -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table

Verification

Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders.

Conversation tests cover every built-in Chat Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. Trajectory tests cover its independently registered Message, Assistant, Tool, Compaction, Request-header, and boundary Definitions together with the preserved stage-oriented view model.

Slot type/runtime tests pin required parent-provided common inject, the hookContext type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory.

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.

Alternatives considered

Keep the centralized Session transcript fold and extract only helpers. Rejected: business identity, history replay, and cache invalidation would still belong to one closed switch; moving functions would not establish independent ownership.

Let React renderers scan Session Events. Rejected: every view would duplicate matching and lifecycle State, React would become business authority, and paging and streaming would recompute unrelated component trees.

Pass global Nodes or Location indexes to every business renderer. Rejected: business components would scan and infer their current Turn/Step, and their subscription scope would grow with the window. A Definition publishes aggregates onto an engine-owned Location, and a renderer reads only its own Node's Location data.

Call every Context of a Definition for each new Event. Rejected: append cost would grow with history, and update() would combine matching with conversion. Context-free match(event) finds the ID first, after which only one Context updates.

Let a Definition matcher read Contexts or scan history. Rejected: matching would depend on ingestion direction, result-first history pages could not determine ownership independently, and live append would regress to searching open objects.

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.

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.

Let a Location-data consumer read the provider's Context State directly. Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates.

Add generic end(), prepared, or window-reset lifecycles. Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation.

Reuse one Event Definition across Chat and Trajectory by branching in buildViewNode(target). Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts.

Add a generic layout model above final business Nodes. Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts.

Register the Turn-data Hook only on the Assistant renderer. Rejected: current-Node Location access is a common capability of the conversation.chat.node slot, not one business renderer. The parent Chat entry registers common inject once, and every keyed renderer shares the same strongly typed contract.

Keep running Assistant or Tool values in an independent tail container. Rejected: settlement would move them across React parents, and a stable business key could not prevent remount. One keyed order permits data and position changes without changing Seat identity.

Consequences

A new business node can register its matcher, State transitions, optional Location data, final target Node, and renderer locally without changing Session's business switch. ChatNodeDataMap and the Location data maps let a business package merge strongly typed data into the contract; every related Event must still expose a stable ID derivable from that Event alone.

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.

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.

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.

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.

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.

中文

问题

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 又分别走不同更新路径,使引用稳定和局部重算只能靠各处特化缓存维持。

业务事件之间的关联方式并不统一。Tool 有 call ID,Assistant 以 turn/step 关联,Compaction 有独立生命周期和 checkpoint,Inbox splice 则表示一个连续状态的瞬间。把这些差异继续塞进统一 fold,会让任一业务变化都经过全局查表并使无关缓存失效。

决策

Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。ui-conversation 注册第一批内建 Definition 和 chat builder;Session 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。

本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。

责任分层

长期职责明确不负责
Session维护连续 Event 窗口,区分 replace、prepend、append,调度 snapshot 通知解释 Tool、Assistant、Compaction 等业务事件
Event Registry按 Cordis 生命周期保存唯一 kind 的 Definition 和唯一 fallback保存某个 Session 的 Context 或 State
Assembler匹配 Event,维护 Context、Location、依赖和发布脏集理解业务 State 字段或 Chat 排序
Node Definition定义一个业务对象的 identity、State 演进、Location data 和 target Node创建 Context、修改别的业务 State 或扫描全部 Context
View Builder把最终 target Node 增量整理成该视图的 snapshot重新解释原始 Session Event
React renderer按最终 Node 的 kind 展示 renderer-owned data,并读取当前 Node 所属 Location 的只读业务 data配对业务 Event、扫描全局 Nodes 或决定业务生命周期

Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的低频 registry rebuild。普通业务 Event 不改变 Registry,也不会因此重建全部业务类型。

ConversationNodeDefinition 总体契约

每个 ConversationNodeDefinition 独立拥有一种业务对象从 Event 到 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。

Definition 不持有跨 Session 的可变业务数据。每个 Session 的 Context、State、依赖和 View Builder 都由该 Session 的 Assembler 隔离持有。

kind、业务 ID 与 Context key

match() 返回的 id 只要求在当前 Definition 内稳定。Tool 的 ID 可以是 call ID,Assistant 的 ID 可以是 turn:step,Inbox 的 ID 可以是 splice Event seq。

Assembler 使用 conversationContextKey(kind, id) 组合无碰撞 key;不同 Definition 即使返回相同 id 也不会共享 Context。最终 view Node 必须沿用这个 engine-owned key,不能把 seq 或渲染位置当 identity。

每个 (kind, id) 最多存在一个 start Match。第二个 start 会立即报错;Definition 需要表达新生命周期时必须返回新 ID。

match(event)

match(event) 只读取当前原始 SessionEvent,返回 { id, role: 'start' | 'update' }null。它拿不到 Context、历史、Reader、Location 或 view envelope。

这项限制使单条 Event 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。

start、result、resource、checkpoint 及业务自有终止 Event 必须携带或可直接推导同一 ID。若单个 Event 不能算出 ID,生产 Event 的协议负责补足关联字段,Client 不通过“最近一个未完成对象”猜测。

role 描述 State 生命周期,不描述可见性。start 可以立即生成 terminal Node;update 也可以在 start 尚未加载时先进入 pending Context。

ConversationMatch

匹配成功后,Assembler 把原始 Event、可选的 wire presentation view、role 和引擎计算的 location 组成只读 ConversationMatch

Context 的 matches 永远按 Event seq 升序保存,而不是按网络到达或分页摄入顺序保存。历史尾页先出现 result、older 页后出现 call 时,最终 Match 顺序仍然是 call 在前、result 在后。

Location 可以随 prepend 补齐边界或 append 关闭边界而改变。Assembler 替换受影响 Match 的只读 Location 并 replay Context;业务不把旧 Location 副本当权威保存。

ConversationNodeContext

字段所有者Definition 可见语义
keyAssemblerkind + id 的稳定最终 identity
kind / idDefinition + Assembler当前业务命名空间和业务 ID
matchesAssembler当前窗口已收集且按 seq 排序的完整业务证据
startAssembler唯一 start Match;尚未加载时为 undefined
stateDefinition 返回、Assembler 持有最近一次 start/update 返回值;未初始化时为 undefined
currentAssembler各 target 最近一次 materialize 的 Node 或 null

Context 字段只读,不表示业务 State 必须是深度 immutable。Definition 可以返回新对象,也可以原地修改旧对象后返回同一引用。

Assembler 只采纳函数返回值。start()update() 返回 undefined 是契约错误并立即报错;修改了对象却不返回它同样不成立。

Definition 可以读取完整 matches 辅助构造 State 或 fallback Node,但不能增删 Match、替换 Context 字段或修改另一个 Context。

start(context, match, reader)

start() 是 State 的唯一初始化入口。Assembler 首次得到唯一 start 后调用它,并采用其返回 State。

当更早分页改变 Context 的 Match 顺序、Reader 前序答案或 Location 事实时,Assembler 从 start() 重新计算,而不是对旧 State 做方向相反的补丁。

调用 start() 时,Context 可能已经收集 start 之后的 updates。start() 返回初始 State 后,Assembler 仍会从 start 之后按日志正序逐条调用 update(),因此摄入方向不会改变最终 fold 结果。

reader 只在 start() 中可用。它允许初始化逻辑读取严格位于当前 start seq 之前、指定 kind 的最近 active Context,但不给业务一个任意扫描引擎内部 Map 的接口。

每次重新调用 start() 都会替换上一次调用登记的 Reader 依赖,保证 Definition 改变查询分支时不会保留陈旧边。

reader.previous(kind)

reader.previous(kind) 查找满足 candidate.startSeq < current.startSeq 且 State 已初始化的最近 Context。它不会返回同 seq、未来 Context 或尚无 State 的 pending Context。

返回值包含前序 Context 的 key、kind、id、start seq、只读 State 和 Matches。消费者自行解释 State;提供方只负责把自己的 State 维护正确,不需要注册特化 query 方法。

Reader 每次查询都记录 { key, revision, windowGap } 依赖。命中前序 Context 时,其 revision 变化会 replay 消费者;未命中且仍有 older 历史时,window gap 会等待后续 prepend。

若窗口已经到达 Session 起点仍未命中,undefined 是确定答案。若 hasMore 为 true,Definition 看到的仍是同一个 undefined,但 Assembler 会记住这是暂定结果。

依赖严格从较早 start 指向较晚 start,因此传递 replay 不形成时序环。Inbox 瞬间态链和 Message 对 Inbox 的读取都使用这一约束。

update(context, match)

update() 只处理已经由 match() 精确路由到当前 (kind, id) 的 post-start Match。它不再判断 Event 属于哪个 Context。

Assembler 按 seq 升序调用 update()。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 start() 完整 replay。

没有业务变化时,update() 返回原 State。存在业务变化时,它可以返回 immutable replacement,也可以原地修改并返回同一对象。

Assembler 不以 State 引用相等判断是否需要发布或传播。每次成功 update 都增加 Context revision、标记 dirty,并使直接或传递 Reader 消费者重新求值。

publication(match)

publication() 只决定最新 State 何时 materialize 成 view Node,不改变 match()start()update() 的同步执行。

返回值行为
immediate请求当前 microtask 通知与 flush
animation-frame把多条高频更新合并到下一帧 materialize
none本 Match 不主动安排 flush,State 和 dirty 标记仍被保留

省略 publication() 等于 immediate。Assistant token delta 使用 animation-frame,不可见 Inbox Context 使用 none,final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。

一帧内的每条 delta 仍执行 update;合并的只是 buildViewNode()、View Builder 和 React snapshot 通知,不会丢失 token。

buildLocationData(context, scope)

buildLocationData() 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 step、再处理 turn,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 buildViewNode()

Definition 分别收到 stepturn scope,可以在任一阶段返回一个值或 null。返回值必须声明准确的 turn/step 坐标,并使用与 Definition kind 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。

ConversationStepDataMapConversationTurnDataMap 通过 declaration merging 约束 key 与 value。Location 只暴露稳定的 data.get(key) reader,消费者不能取得提供方 Context 或修改它的 State。

buildViewNode(context, target)

buildViewNode() 在发布阶段读取最新 Context,为指定 target 直接生成最终业务 Node。Assembler 不在它之后附加通用 activity、tail candidate 或 layout 业务层。

null 表示该 Context 对这个 target 尚未 materialize。普通增量路径中,一个已经返回过非空 Node 的 Context 不能再返回 null;暂时隐藏必须保留同 key Node,并使用 target 自己的 visibility。

Assembler 校验 Node key === context.key 且 Node target === target。业务可以改变 anchorSeq、data、Location 或 visibility,但不能在一次生命周期内改变 identity。

current 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry suppression 使用它避免非法的 Node 撤回。

一个 Definition 最多拥有一个 view target;仅维护状态的 Definition 同时省略 targetbuildViewNode()。即使 Chat 与 Trajectory 识别同一持久 Event 族,它们也分别注册自己的业务 Definition;共享 Assembler 则为两个 target 提供相同的匹配、replay、Location 与发布机制。

不提供通用 end()

引擎不提供固定 end() 生命周期。单 Event 业务在 start() 中完成,多 Event 业务在自己的 update 中记录完成,长期瞬间态业务则每条 Event 建立新 Context。

Step/Turn 关闭属于外部 Location 事实,不替业务修改 State。边界变化会 replay 并 build 受影响 Context;业务结合“自己的 State 是否完成”和“Location 是否 closed”生成正常、running 或 interrupted 表现。

ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定渲染 identity,也可以作为后续 Reader 的前序证据。

Location 是一级引擎事实

ConversationLocationIndex 根据 turn/startstep/start、显式 turn/step payload、step/endturn/end 建立 Event 到 Location 的映射。

Location 有 sessionturnstepunresolved 四种形状。Turn/Step 各自带 openclosedunknown 状态,以及已加载的 start/end Event。

每个 Turn 和 Step 还持有 reference-stable 的 Location data store。Definition 更新只替换自己拥有的 key;同一个 store identity 可以随 append 或 prepend 获得新值,使 Context、View Builder 和 React renderer 共享已经确定的层级业务事实,而不复制或遍历全局 Node 数组。

unresolved 表示当前历史窗口缺少足够前序边界,不等于 session-level。older prepend 补入边界后,索引修正 Match Location,并只 replay 拥有这些 seq 的 Context。

Append 普通 Event 只继承当前坐标;append 边界只重算所属 Turn。Prepend 会基于扩展后的完整连续窗口重建 Location facts,但引用稳定逻辑保留未变化 Turn/Step 对象。

Assembler 还把 reference-stable timeline 交给 View Builder。业务不重复维护 turn order、step list、last step 或边界 Map。

三种事件窗口链路

“历史反扫”描述 UI 从最新尾页向 Session 起点逐页加载的方向,不表示 Definition 逆序执行 update()。无论历史 API 返回顺序或页面加载方向如何,Assembler 对每个当前窗口和每个 fresh page 都按 seq 升序 canonicalize。

场景输入范围Context/State 处理View Builder
初始历史尾页或 resync当前完整连续窗口清空并按 seq 正序重建全部 Contextreplace()
加载一页 older history只传更早且去重后的 fresh Events保留现有 Context identity,补 Match、Location 和依赖后局部 replayapply(upserts)
实时 append一条连续尾部 Event只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turnapply(upserts)

初始历史尾页与逻辑反扫

  1. Session.open() 拉取最新 tail page,并把连续 History Entries 交给 replaceWindow(entries, hasMore)
  2. replaceWindow 清空旧 Context、start-seq 索引、seq 反向索引、Reader 依赖和输入 Map。
  3. 全部 entries 按 Event seq 升序排序并写入当前窗口。
  4. LocationIndex 对这个窗口重建 Turn/Step facts。
  5. Assembler 按升序 Event 逐条调用每个普通 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 依赖,使同一窗口内较早瞬间态先稳定、较晚消费者再读取它。
  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。

这条链路“从最新页开始”只发生在分页选择层。页面内部 State 始终正序计算,因此同一个窗口不会因为扫描方向不同产生不同业务结果。

缺 start 的 Context 不是错误。它是等待 older 页补齐的 pending 聚合容器;是否提前可见由该 Definition 的 buildViewNode() 决定。

若当前页中的同 ID update 在日志顺序上真的早于 start,而不是仅仅先被加载,补齐 start 后 replay 会报协议错误。到达顺序可以反向,业务日志顺序不能反向。

新 older 分页的 prepend

  1. Session.loadOlder() 以当前 baseSeq 拉取紧邻前页,并先验证页尾与当前窗口连续。
  2. Session 把 raw Event/view 数组 prepend 到自己的窗口,只把这一页传给 assembler.prepend(entries, hasMore)
  3. Assembler 按 seq 去掉与当前窗口重叠的 Events,再把 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。
  8. 新页补出 pending Context 的 start 时,该 Context 从 start 初始化,再正序应用已经收集的所有 updates。
  9. 新页建立更近的 Reader predecessor、改变 predecessor revision 或消除 window gap 时,消费者从 start() 重算。
  10. Reader 依赖沿 start seq 向后传递 replay;同一传播批次不会把 Event 逆序应用。
  11. hasMore 从 true 变为 false 的空页也会复查依赖,把暂定 undefined 收敛为确定不存在。
  12. flush 只为 dirty Context 重新发布 Step/Turn Location data 和 target Node,并把非空结果作为 upserts 交给 View Builder apply()

Prepend 保留已有 Context key 和 current Node identity。新页可以在 Chat order 前部增加 key,也可以修正既有 Node 的 anchor、Location、visibility 或 data,但不会为无关业务重新创建 Context。

Chat Builder 遇到结构变化时会从 keyed store 重算可见 order 和 Location 二级索引;这是视图索引计算,不会重新执行全部业务 Definition 或替换未变化 Node value。

Reader gap 修复是 prepend 与普通 append 最大的算法差异。新页不仅可能创建可见历史 Node,也可能改变后续 Inbox 瞬间态以及依赖它的 Message 分类。

正向实时 append

  1. Session 只接受紧邻当前 tail seq 的 live Event;重叠 seq 去重,出现 gap 时先走 tail-page repair。
  2. 非边界 Event 增量写入当前 Turn/Step 坐标;边界 Event 更新所属 Turn 的 Location facts。
  3. Assembler 对这一个 Event 的每个普通 Definition 调用一次 match(),不会遍历任何 Definition 的 Context 集合。
  4. 每个命中结果通过 (kind, id) 直接定位一个 Context。
  5. 新 ID 创建 Context;已有 ID 的正常尾部 update 直接调用一次 update()
  6. start 或任何需要插入非尾部位置的证据会走完整 replayContext(),保持同一正序语义。
  7. Context revision 变化后,只沿已登记 Reader 依赖 replay 消费者。
  8. Location close 会更新所属 Turn 中受影响 Match 的 Location,并 replay 这些 Context,使未完成 Assistant、Tool 或 Retry 得到 interrupted/cancelled 语气。
  9. Assembler 汇总所有命中 Definition 的 publication urgency;immediate 高于 animation-frame,后者高于 none
  10. Session 把 immediate 交给 microtask notifier,把 animation-frame 交给 RAF notifier。
  11. flush 先为 dirty Context 更新 Step/Turn Location data,再调用 buildViewNode(),最后把本轮 upserts 和最新 timeline 交给 View Builder。
  12. React 订阅的新 snapshot 复用稳定 Context key;同一 Tool running→settled 或 Assistant streaming→final 不跨父节点移动。

Append 的业务匹配成本是 Definition 数量加实际命中的 Context 更新,不随历史 Context 数量增长。Reader 消费者和 Location 关闭会增加与真实依赖或所属 Turn 成比例的 replay。

Chat order 的结构性变化仍可能重排当前可见 key;纯 data 更新只替换 keyed store 中一个 Node,并 touch 所属 Location 索引。这里保证的是无关业务不 refold、Node identity 不替换,而不是宣称所有视图索引操作都是常数复杂度。

Replace、prepend 与 append 的一致性

三条链路最终都遵守同一不变量:Context Matches 按 seq 排序,State 从唯一 start 正序 fold,Reader 只看严格前序 active Context,Location data 按 Step→Turn 发布,Node key 只由 kind 和 ID 决定。

replaceWindow 是初始打开、resync、gap repair 和 registry 变化的低频完整替换,不用于实现普通 load older。prependappend 都保留现有 Builder 和 Context identity。

分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。

内建业务如何使用 Definition

匹配、ID 与 State

业务 / kind稳定 IDstart Matchupdate MatchesState 与跨 Context 读取
Next-turn Inbox / inbox-next-turnsplice Event seq每条目标为 next-turn 的 agent/inbox/splicedreader.previous(ownKind) 的 pending/claimed 瞬间态应用当前 splice
Next-step Inbox / inbox-next-stepsplice Event seq每条目标为 next-step 的 agent/inbox/spliced同样形成逐指令瞬间态,claimed 集合供 Message 读取
Message / input-messagemessage IDappend-surface user/message根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering
Assistant / assistant-stepturn:stepstep/startassistant/chunk、final assistant/message、同 step Retry聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data
Tool / tool-callroot call IDroot tool/callroot result、Code Dispatch start/result聚合 root、children 和 parent Map;Dispatch Event 用 rootCallId 精确路由
Command / commandcommand IDcommand/runcommand/done、带 source command ID 的 compact lifecycle/checkpoint聚合 command outcome 和手动压缩证据
Automatic Compaction / compactioncompaction ID无 source command ID 的 compaction/startsummary、end、replacement checkpoint聚合 summary/checkpoint;checkpoint 足够时可在缺 start 下 fallback
Retry / model-retryretry IDattempt 1 的 llm/retry后续 llm/retryllm/retry-started聚合同一 RetryId 的 attempts 与 scheduled/started 状态
Turn Error / turn-errorturn numberturn/starterror turn/end聚合 terminal failure;该 turn 的 Retry 历史经由 Retry 渲染,绝不会隐藏此行
Turn Tail / turn-tailturn numberturn/startAssistant、Retry、step/endturn/end保存 turn end,读取各 Step 的 Assistant data,发布 Turn data;完整 Matches 用于选择视觉尾部 anchor
Deliverables / deliverablesturn numberturn/start该 Turn 的 Tool call/result聚合成功 mutation paths 并发布 Turn data,不生成 view Node
Unknown fallback / unknown-surfaceEvent seq未被普通 Definition 认领的 append-surface Event保存原始 type/data 作为 JSON fallback

Chat Node 与历史/实时特性

业务publication()Chat 产物历史分页与运行时行为
Inboxnone不生成 Nodeprepend 补前序 splice 时沿 Reader 链重算瞬间态
Message默认 immediateusersteeringcontextwindow gap 修复可让同一 message key 重新分类
Assistantchunk 为 RAF,final immediate,纯 usage/finish 为 none同 key assistant-step,状态为 running/settled/interruptedstep/start 可先用 Matches fallback;Location close 生成中断表现
Tool默认 immediate一个递归 tool-call root,包含全部 subCallsresult-only 历史窗口可 fallback;running→settled 保持 key
Command默认 immediate普通 command 或集成 manual-compactioncheckpoint 到达可改变 anchor,但不改变 Context key
Compaction默认 immediatecompaction markercheckpoint 可先展示,older 补 start 后正序 replay
Retry默认 immediate一个 model-retry Node 内含 attempts多次 retry 更新同一 key;Location close 把最后 scheduled 表现为 cancelled
Turn Error默认 immediateterminal failure 时的 turn-error缺 start 可从 error end fallback;该 turn 定格的 Retry 链在其旁渲染
Turn Tailturn/end immediate,其余 none独立 turn-tail footer从 Step Assistant data 计算 closing/metrics,并通过同 turn Matches 决定 anchor
Deliverables默认 immediate不生成 NodeTool 结算增量更新所属 Turn data,Turn Tail 扩展槽读取 produced files
Fallback默认 immediateunknown JSON row只兜底 append surface,普通业务已认领但暂不可见时不会重复生成

Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。

Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。

Assistant、Turn Tail 和 Deliverables 展示了 Location data 的分层组合。Assistant 负责写好每个 Step 的 assistant-step data;Turn Tail 从这些 Step values 计算 turn-tail data;Deliverables 独立维护同一 Turn 的 deliverables data。消费者只读取声明合并后的 key,不扫描其他业务 Node,也不取得提供方的 Context State。

Tool 和 Command 展示了多 Event 聚合:生产者提供共同 ID,Context 在业务内部构树或整合 Compaction,不把配对工作推给 Chat Builder。

Compaction 和历史 Tool result 展示了缺 start 时的业务 fallback。引擎不统一规定“没有 start 就不渲染”;Definition 根据当前 Matches 是否足够自行决定。

Retry 展示了业务 State 与 Location 的分工。scheduled/started 属于 Retry State;Step/Turn 是否关闭属于引擎 Location;buildViewNode() 组合两者得到 cancelled 视觉状态。

Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何普通 matcher 认领的 append surface Event,不会因为普通 Context 暂时返回 null 而误生成第二个 Node。

View Builder 与 React identity

ConversationViewRegistry 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。

Assembler 低频完整替换时调用 replace({ nodes, timeline });普通 prepend/append flush 调用 apply({ upserts, timeline })。Builder 只接收 Definition 已构造完成的 target Nodes。

ChatSnapshotBuilder 维护 order、keyed nodes store、turn/step locations index、timeline,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 legacy slice。

Chat 结构变化只由新 key、anchorSeq、visibility 或 Location identity 变化触发。普通内容变化不重建 order;keyed Node store 只替换该 key 的 value。

Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。

ChatView 只遍历 order。每个 ChatNodeSeat 以 Context key 固定在同一个父列表中,并按 node.kind 分发 'conversation.chat.node' keyed slot。

ChatNodeDataMap 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;registerConversationNodes()registerChatNodeRenderers() 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 ui-conversation,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。

conversation.view 的 Chat entry 在声明 conversation.chat.node child slot 时统一注册 ChatNodeTurnDataInjectedChatNodeSeat 只把稳定 Node key 作为 hookContext 传给 slot;Slot renderer 用官方 standard props 中的 useSession 和该 key 构造 useTurnData(businessKey),因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。

Slot-level contextual Hook 与 entry-owned inject.hooks 是两条独立路径。后者继续只绑定 registration-owned Observable;前者按稳定 slot inject face 缓存定义,并按稳定 render occurrence 绑定 factory 和 Hook。useTurnData() 内部 selector 只返回当前 Node 的 turn.data.get(key),无关 Session publication 会被 selector equality 截断。

标准 useSession 仍属于所有 session-scoped slot renderer 的公开能力,useTurnData() 是收窄常见读取方式而不是权限沙箱。全窗口统计或任意对象索引仍可显式使用 Session snapshot;它们不能伪装成“当前 Node 的 Turn data”。

Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 的 data 和必要的排序属性,不再从末尾 running container 移入 finalized flow,因此组件内部 State 不因结算自动归零。

业务主动把已发布 Node 改成 hidden 时,它会退出 visible order,恢复 visible 时会重新 mount。这是明确的业务撤显语义,与 running→settled 的稳定 Seat 保证不同。

具体 Tool renderer 仍由 ui-tool ownership decision 约束。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 slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。

target 专属 Trajectory Definition、保留的 stage model、Steering 适配、复杂度上界与表现层热点由 Trajectory Context 组装决策负责。

运行时与渲染链路

Session Event window
  -> ConversationNodeAssembler
       -> Definition.match(event) -> (kind, id, start/update)
       -> Context matches + State + Location
       -> Definition.buildLocationData(step -> turn)
            -> StepLocation.data / TurnLocation.data
       -> Definition.buildViewNode() for its declared target
  -> target View Builder
       -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat
       -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table

验证

Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。

Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。Trajectory tests 则覆盖它独立注册的 Message、Assistant、Tool、Compaction、Request-header 与 boundary Definition,以及继续保留的 stage-oriented view model。

Slot type/runtime tests 固定父注册必须提供声明的 common inject、hookContext 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。

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。

考虑过的替代方案

保留中心化 Session transcript fold,只抽 helper。 拒绝:业务 identity、历史 replay 和 cache invalidation 仍属于一个闭合 switch,移动函数不会产生独立所有权。

让 React renderer 自己扫描 Session Event。 拒绝:每种 view 都会重复匹配和生命周期 State,React 会成为业务权威,paging 与 streaming 也会重算无关组件树。

把全局 Nodes 或 Location 索引传给每个业务 renderer。 拒绝:业务组件会自行扫描和推断当前 Turn/Step,订阅范围随窗口增长。Definition 把聚合值发布到 Engine-owned Location,renderer 只读取自己 Node 的 Location data。

每个新 Event 都调用同 Definition 的全部 Context。 拒绝:append 成本随历史增长,update() 也会同时承担匹配与转换。无 Context 的 match(event) 先算出 ID,随后只更新一个 Context。

让 Definition 的 matcher 读取 Context 或扫描历史。 拒绝:匹配将依赖摄入方向,result-first 历史页无法独立算出归属,实时 append 也退化成开放对象查找。

为历史反扫定义逆向 State fold。 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。

把 Inbox 做成引擎一级公民或一个窗口级 Context。 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。

给跨业务查询注册特化 query method。 拒绝:消费者仍要依赖提供方 API,新增关系会扩张中心接口。Reader 暴露指定 kind 的只读前序 Context,由提供方写好 State、消费者读懂 State。

让 Location data 消费者直接读取提供方 Context State。 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。

增加通用 end()、prepared 或 window reset 生命周期。 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。

在同一个 Event Definition 内通过 buildViewNode(target) 为 Chat 与 Trajectory 分支。 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。

在最终业务 Node 上再叠一层通用 layout model。 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。

只在 Assistant renderer 注册 Turn data Hook。 拒绝:访问当前 Node Location 是 conversation.chat.node slot 的公共能力,不属于某个业务 renderer。父 Chat entry 注册一次 common inject,所有 keyed renderer 共享同一强类型约定。

把 running Assistant 或 Tool 保留在独立 tail container。 拒绝:结算时会跨 React parent 移动,稳定业务 key 也无法阻止 remount。统一 keyed order 允许 data 和排序位置改变,但不改变 Seat identity。

后果

新增业务节点可以局部注册自己的 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。

Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。

State 更新与发布频率分离后,Assistant 每条 delta 都被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。

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 不撤回规则。

useTurnData() 不撤销 session-scoped renderer 的标准 useSession,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。