From dc825be8d874df216d3fcc50bac36149c06568e6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:05:12 +0800 Subject: [PATCH 01/20] docs: design client conversation node assembly --- ...lient-conversation-node-assembly.i18n.yaml | 6 + ...08-09-client-conversation-node-assembly.md | 407 ++++++++++++++++++ ...09-client-conversation-node-assembly.zh.md | 407 ++++++++++++++++++ 3 files changed, 820 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md create mode 100644 .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml new file mode 100644 index 0000000000..953a049744 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 0cee5449f651c568dbb87b9cb867eb77b6380184 +2026-08-09-client-conversation-node-assembly.zh.md: 146e8f68a9b1339934040cee62fd46e981145f2a diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md new file mode 100644 index 0000000000..0cee5449f6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -0,0 +1,407 @@ +# Agent Note: Client Conversation business-node assembly and keyed Chat snapshots + +Status: implemented + +English | [中文](2026-08-09-client-conversation-node-assembly.zh.md) + +## 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. + +The complete derivation, business-by-business validation, and file-level implementation plan remain in [`business-node assembler version one`](../../../../docs/client-conversation-node-engine-rfc.md), [`follow-up design differences`](../../../../docs/client-conversation-node-engine-follow-up-differences.md), [`business-node and dual-view adaptation analysis`](../../../../docs/client-conversation-node-adaptation-analysis.md), and the [`Chat implementation design`](../../../../docs/client-conversation-node-chat-implementation-plan.md). Those design documents retain the full discussion; this Note fixes the responsibilities, algorithms, and trade-offs that remain relevant after implementation. + +### Responsibility layers + +| 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 | +| 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 | +| 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 | +| 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/runtime/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. + +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 Error. 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` + +| Field | Owner | Semantics visible to the Definition | +|---|---|---| +| `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 | +| `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 | + +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 value | Behavior | +|---|---| +| `immediate` | Request a notification and flush in the current microtask | +| `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. + +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 and Turn Error suppression use it to avoid illegal Node withdrawal. + +A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory continues to consume the compatibility slice. + +#### 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`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 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. + +| 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)` | +| 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)`. +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 / `kind` | Stable ID | Start Match | Update Matches | State and cross-Context reads | +|---|---|---|---|---| +| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` | +| 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 | +| 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 | +| 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 | `compact/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start | +| Retry / `model-retry` | Retry ID | Attempt 1 `llm/retry` | Later `llm/retry` and `llm/retry-started` | Aggregate one RetryId's attempts and scheduled/started state | +| Turn Error / `turn-error` | Turn number | `turn/start` | Error `turn/end` and Retry Events for that Turn | Aggregate terminal failure and use Retry evidence to decide hiding | +| Turn Tail / `turn-tail` | Turn number | `turn/start` | Assistant, Retry, `step/end`, and `turn/end` | Retain turn end, read each Step's Assistant data, and publish Turn data; use complete Matches to choose the visual tail anchor | +| Deliverables / `deliverables` | Turn number | `turn/start` | Tool calls/results in that Turn | Aggregate successful mutation paths and publish Turn data without producing a view Node | +| Unknown fallback / `unknown-surface` | Event seq | Append-surface Event unclaimed by any ordinary Definition | None | Retain raw type/data for the JSON fallback | + +### Chat Node and history/live behavior + +| Business | `publication()` | Chat output | History and runtime behavior | +|---|---|---|---| +| 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 | +| 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 | +| 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 | +| Retry | Immediate by default | One `model-retry` Node containing all attempts | Multiple retries update one key; Location close presents the last scheduled attempt as cancelled | +| Turn Error | Immediate by default | Visible or hidden `turn-error` | Error end supports fallback without start; later Retry keeps the key and hides it | +| Turn Tail | Immediate only for `turn/end`; otherwise none | Independent `turn-tail` footer | Compute closing/metrics from Step Assistant data and use same-turn Matches to choose the anchor | +| Deliverables | Immediate by default | No Node | Tool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files | +| Fallback | Immediate by default | `unknown` JSON row | Covers 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. + +Assistant, Turn Tail, and Turn Error 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`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 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`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice temporarily consumed by Trajectory. + +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`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`. + +[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 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`](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 has no independent registered target yet. It continues to consume the legacy slice incrementally derived by the Chat Builder, while Session no longer runs a second transcript fold; a future migration does not change the Event Definition, Context, Reader, or Location contracts. + +## Runtime and render path + +```text +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(target = chat) + -> ChatSnapshotBuilder + -> order[] + keyed Node store + Location index + timeline + -> ChatView + -> ChatNodeSeat(key) + -> conversation.chat.node(entryKey = node.kind, hookContext = key) + -> slot-level useTurnData(businessKey) +``` + +## 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 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. + +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. + +**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory retains a compatibility slice until its actual migration. + +**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. + +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 until Trajectory migrates; built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md new file mode 100644 index 0000000000..146e8f68a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -0,0 +1,407 @@ +# Agent Note: Client Conversation 业务节点组装与 Chat keyed snapshot + +Status: implemented + +[English](2026-08-09-client-conversation-node-assembly.md) | 中文 + +## Problem + +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,会让任一业务变化都经过全局查表并使无关缓存失效。 + +## Decision + +Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。 + +详细的方案推导、逐业务适配和逐文件实施设计保留在 [`业务节点组装器第一版`](../../../../docs/client-conversation-node-engine-rfc.md)、[`后续方案差异`](../../../../docs/client-conversation-node-engine-follow-up-differences.md)、[`业务节点与双视图适配论证`](../../../../docs/client-conversation-node-adaptation-analysis.md) 和 [`Chat 链路工程实施设计`](../../../../docs/client-conversation-node-chat-implementation-plan.md)。这些设计稿保留完整讨论过程;本 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`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。 + +同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Error。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 可见语义 | +|---|---|---| +| `key` | Assembler | `kind + id` 的稳定最终 identity | +| `kind` / `id` | Definition + Assembler | 当前业务命名空间和业务 ID | +| `matches` | Assembler | 当前窗口已收集且按 `seq` 排序的完整业务证据 | +| `start` | Assembler | 唯一 start Match;尚未加载时为 `undefined` | +| `state` | Definition 返回、Assembler 持有 | 最近一次 `start`/`update` 返回值;未初始化时为 `undefined` | +| `current` | Assembler | 各 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 分别收到 `step` 和 `turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。 + +`ConversationStepDataMap` 和 `ConversationTurnDataMap` 通过 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 和 Turn Error suppression 使用它避免非法的 Node 撤回。 + +Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder,Trajectory 仍通过兼容 slice 使用结果。 + +#### 不提供通用 `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`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。 + +Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 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` 正序重建全部 Context | `replace()` | +| 加载一页 older history | 只传更早且去重后的 fresh Events | 保留现有 Context identity,补 Match、Location 和依赖后局部 replay | `apply(upserts)` | +| 实时 append | 一条连续尾部 Event | 只匹配 Definitions 并精确更新命中 ID,边界只影响所属 Turn | `apply(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。`prepend` 与 `append` 都保留现有 Builder 和 Context identity。 + +分页页宽、历史加载次数和 RAF 合批只影响何时得到更多证据或何时发布,不改变窗口证据相同时的最终 Context State 与 Node。 + +## 内建业务如何使用 Definition + +### 匹配、ID 与 State + +| 业务 / `kind` | 稳定 ID | start Match | update Matches | State 与跨 Context 读取 | +|---|---|---|---|---| +| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice | +| 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 | +| Assistant / `assistant-step` | `turn:step` | `step/start` | `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 Map;Dispatch 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 的 `compact/start` | summary、end、replacement checkpoint | 聚合 summary/checkpoint;checkpoint 足够时可在缺 start 下 fallback | +| Retry / `model-retry` | retry ID | attempt 1 的 `llm/retry` | 后续 `llm/retry` 与 `llm/retry-started` | 聚合同一 RetryId 的 attempts 与 scheduled/started 状态 | +| Turn Error / `turn-error` | turn number | `turn/start` | error `turn/end` 与该 turn Retry Events | 聚合 terminal failure,并用 Retry 证据决定隐藏 | +| Turn Tail / `turn-tail` | turn number | `turn/start` | Assistant、Retry、`step/end`、`turn/end` | 保存 turn end,读取各 Step 的 Assistant data,发布 Turn data;完整 Matches 用于选择视觉尾部 anchor | +| Deliverables / `deliverables` | turn number | `turn/start` | 该 Turn 的 Tool call/result | 聚合成功 mutation paths 并发布 Turn data,不生成 view Node | +| Unknown fallback / `unknown-surface` | Event seq | 未被普通 Definition 认领的 append-surface Event | 无 | 保存原始 type/data 作为 JSON fallback | + +### Chat Node 与历史/实时特性 + +| 业务 | `publication()` | Chat 产物 | 历史分页与运行时行为 | +|---|---|---|---| +| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 | +| Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | +| Assistant | chunk 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | 缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | +| Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key | +| Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key | +| Compaction | 默认 immediate | `compaction` marker | checkpoint 可先展示,older 补 start 后正序 replay | +| Retry | 默认 immediate | 一个 `model-retry` Node 内含 attempts | 多次 retry 更新同一 key;Location close 把最后 scheduled 表现为 cancelled | +| Turn Error | 默认 immediate | `turn-error` visible/hidden | 缺 start 可从 error end fallback;Retry 到达后保留 key 并隐藏 | +| Turn Tail | 仅 `turn/end` immediate,其余 none | 独立 `turn-tail` footer | 从 Step Assistant data 计算 closing/metrics,并通过同 turn Matches 决定 anchor | +| Deliverables | 默认 immediate | 不生成 Node | Tool 结算增量更新所属 Turn data,Turn Tail 扩展槽读取 produced files | +| Fallback | 默认 immediate | `unknown` JSON row | 只兜底 append surface,普通业务已认领但暂不可见时不会重复生成 | + +Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。 + +Assistant、Turn Tail 和 Turn Error 展示了同一 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`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 + +Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 + +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline` 和 Trajectory 临时使用的 `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`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。 + +[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 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 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 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`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 + +Trajectory 尚未注册独立 target。它继续消费 Chat Builder 增量派生的 legacy slice,Session 不再运行第二套 transcript fold;未来迁移不改变 Event Definition、Context、Reader 或 Location 契约。 + +## Runtime and render path + +```text +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(target = chat) + -> ChatSnapshotBuilder + -> order[] + keyed Node store + Location index + timeline + -> ChatView + -> ChatNodeSeat(key) + -> conversation.chat.node(entryKey = node.kind, hookContext = key) + -> slot-level useTurnData(businessKey) +``` + +## Verification + +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 覆盖全部内建 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。 + +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。 + +## Alternatives considered + +**保留中心化 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 负责补页失效。 + +**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在真正迁移前保留兼容 slice。 + +**在最终业务 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。 + +## Consequences + +新增业务节点可以局部注册自己的 matcher、State 转换、可选 Location data、最终 target Node 和 renderer,不再修改 Session 的业务 switch。`ChatNodeDataMap` 和 Location data maps 允许业务 package 通过 declaration merging 合入强类型 data;所有相关 Event 仍须暴露可单 Event 推导的稳定 ID。 + +初始尾页、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 在 Trajectory 迁移前仍维护 legacy slice;内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 From aa623b6e7ac5c83fa1f2ea4fe80e124b2d5d66bd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:07 +0800 Subject: [PATCH 02/20] refactor(events): add stable conversation correlation ids --- packages/compact/command-compact/src/index.ts | 2 +- packages/compact/compact-basic/package.json | 2 + packages/compact/compact-basic/src/index.ts | 9 +- packages/compact/compact-basic/src/region.ts | 40 ++++++-- packages/compact/compact-basic/tsconfig.json | 3 + packages/compact/compact/package.json | 8 ++ packages/compact/compact/src/brand.ts | 13 +++ packages/compact/compact/src/checkpoint.ts | 25 +++++ packages/compact/compact/src/index.ts | 7 +- packages/compact/compact/src/invariant.ts | 93 ++++++++++++++++++- packages/compact/compact/src/types.ts | 12 ++- packages/compact/compact/tsconfig.json | 6 ++ packages/compact/compact/tsdown.config.ts | 13 +++ packages/core/tools/src/code-mode.ts | 7 +- packages/core/tools/src/index.ts | 9 ++ packages/core/tools/src/invariant.ts | 28 ++++++ packages/interaction/commands/src/index.ts | 4 +- packages/llm/llm-retry/package.json | 6 ++ packages/llm/llm-retry/src/brand.ts | 13 +++ packages/llm/llm-retry/src/index.ts | 36 +++---- packages/llm/llm-retry/src/invariant.ts | 37 +++++++- packages/llm/llm-retry/src/types.ts | 12 +++ packages/llm/llm-retry/tsconfig.json | 3 + 23 files changed, 347 insertions(+), 41 deletions(-) create mode 100644 packages/compact/compact/src/brand.ts create mode 100644 packages/compact/compact/tsdown.config.ts create mode 100644 packages/llm/llm-retry/src/brand.ts diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index 4ac171a689..b3f80d87fd 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -63,7 +63,7 @@ async function executeCompact( return { kind: 'error', text: USAGE } } try { - const result = await ctx.compact.compactNow(invocation.agent, invocation.signal) + const result = await ctx.compact.compactNow(invocation.agent, invocation.signal, invocation.commandId) if (result === null) return { kind: 'success', text: 'No compactable history yet.' } return { kind: 'success', diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 2b0624e06e..3fffee14a7 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 27b59d0451..d3c710bef3 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -13,6 +13,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' import { @@ -361,9 +362,14 @@ export class BasicCompactService extends CompactService { * resolve only after its standalone marker pair is durably checkpointed. * @param agent - idle agent whose next-turn admission this call reserves. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for presentation correlation. * @returns the committed result, or `null` when no safe useful range exists. */ - override compactNow(agent: Agent, signal: AbortSignal): Promise { + override compactNow( + agent: Agent, + signal: AbortSignal, + sourceCommandId?: CommandId, + ): Promise { signal.throwIfAborted() try { return agent.runMaintenance(async (agentSignal) => { @@ -385,6 +391,7 @@ export class BasicCompactService extends CompactService { { owner: null, stability: 'selected-span', + ...sourceCommandId === undefined ? {} : { sourceCommandId }, flush: async () => { await this.ctx.sessions.flush(agent.session) }, diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index b220074e02..ae637d03cc 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -5,14 +5,17 @@ * @module @deepseek-ai/dsh-compact-basic/region */ +import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' import { - COMPACT_CHECKPOINT_SOURCE, + CompactionId, ManualCompactionError, + compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' @@ -54,6 +57,8 @@ interface CompactionTransactionOptions { readonly stability: 'whole-surface' | 'selected-span' /** Optional durability checkpoint after a successfully closed bracket. */ readonly flush?: () => Promise + /** Manual command that initiated this transaction, when present. */ + readonly sourceCommandId?: CommandId } interface CompactionEntryState { @@ -175,7 +180,13 @@ export async function compactSurfaceRegion( owner = entryState.openTurn } - const startEvent = session.append('compact/start', { turn: owner }) + const compactionId = CompactionId(randomUUID()) + const lifecycle = { + compactionId, + ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId }, + turn: owner, + } + const startEvent = session.append('compact/start', lifecycle) const assertStable: StabilityCheck = options.stability === 'whole-surface' ? assertWholeSurfaceUnchanged : assertSelectedSpanStable @@ -188,13 +199,20 @@ export async function compactSurfaceRegion( try { const prepared = prepareCompaction(dependencies, session, selection) - const summarized = await summarizeCompaction(dependencies, prepared, agent, signal) + const summarized = await summarizeCompaction( + dependencies, + prepared, + agent, + compactionId, + options.sourceCommandId, + signal, + ) if (options.owner === null) signal?.throwIfAborted() assertStable(dependencies, session, summarized) stage = 'commit' const pending = commitCompactionBody(session, startEvent, summarized) closing = true - const endEvent = session.append('compact/end', { turn: owner }) + const endEvent = session.append('compact/end', lifecycle) closed = true result = completeCompaction(pending, endEvent) } catch (error: unknown) { @@ -202,7 +220,7 @@ export async function compactSurfaceRegion( if (!closing) { closing = true try { - session.append('compact/end', { turn: owner, error: errorChain(error) }) + session.append('compact/end', { ...lifecycle, error: errorChain(error) }) closed = true } catch (closeError: unknown) { failure = { error: closeError, stage: 'commit' } @@ -343,12 +361,14 @@ async function summarizeCompaction( dependencies: RegionDependencies, prepared: PreparedCompaction, agent: Agent, + compactionId: CompactionResult['compactionId'], + sourceCommandId: CommandId | undefined, signal?: AbortSignal, ): Promise { const summaryResult = await dependencies.summarize(prepared.input, agent, signal) const checkpointMessage = createUserMessage({ content: frameSummary(summaryResult.summary), - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(compactionId, sourceCommandId), }) const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage) if (framedSummaryTokenCount >= prepared.shadowedTokenCount) { @@ -425,6 +445,10 @@ function commitCompactionBody( ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const } : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput } const summaryEvent = session.append('compact/summary', { + compactionId: startEvent.data.compactionId, + ...startEvent.data.sourceCommandId === undefined + ? {} + : { sourceCommandId: startEvent.data.sourceCommandId }, summary, ...callProvenance, shadowedRange: { start, end }, @@ -440,6 +464,10 @@ function commitCompactionBody( sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], }) return { + compactionId: startEvent.data.compactionId, + ...startEvent.data.sourceCommandId === undefined + ? {} + : { sourceCommandId: startEvent.data.sourceCommandId }, startSeq: startEvent.seq, summarySeq: summaryEvent.seq, summary, diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index bd1a440119..f90011f04d 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../core/agent" }, + { + "path": "../../interaction/commands" + }, { "path": "../compact" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 100a3f56f8..bb4150ee32 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/checkpoint.d.ts", "default": "./lib/types/checkpoint.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -30,12 +34,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/compact/compact/src/brand.ts b/packages/compact/compact/src/brand.ts new file mode 100644 index 0000000000..5b6de03e61 --- /dev/null +++ b/packages/compact/compact/src/brand.ts @@ -0,0 +1,13 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable identity shared by one compact start/summary/checkpoint/end transaction. */ +export type CompactionId = Branded<'CompactionId'> + +/** + * Brand an implementation-minted compaction identity. + * @param id - opaque transaction identity. + * @returns the same string, branded; no validation is performed. + */ +export function CompactionId(id: string): CompactionId { + return id as CompactionId +} diff --git a/packages/compact/compact/src/checkpoint.ts b/packages/compact/compact/src/checkpoint.ts index 9d8b98e4d6..9908fe4ac0 100644 --- a/packages/compact/compact/src/checkpoint.ts +++ b/packages/compact/compact/src/checkpoint.ts @@ -13,10 +13,35 @@ */ import type { MessageSource } from '@deepseek-ai/dsh-llm/message' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CompactionId } from './brand.ts' /** Canonical source for the replacement user message produced by every compaction backend. */ export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) +/** Message provenance carried by a concrete compaction checkpoint. */ +export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_SOURCE & { + readonly compactionId: CompactionId + readonly sourceCommandId?: CommandId +} + +/** + * Create checkpoint provenance correlated with one compaction transaction. + * @param compactionId - owning compaction identity. + * @param sourceCommandId - initiating manual command, when present. + * @returns immutable checkpoint source. + */ +export function compactCheckpointSource( + compactionId: CompactionId, + sourceCommandId?: CommandId, +): CompactCheckpointSource { + return Object.freeze({ + ...COMPACT_CHECKPOINT_SOURCE, + compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + }) +} + /** * Test whether a persisted message source identifies a compaction checkpoint. * @param source - source restored from a surface user message. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7b0bd25091..0085321333 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -9,14 +9,17 @@ import { Context, Service } from 'cordis' import type { Session } from '@deepseek-ai/dsh-session' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' +export { CompactionId } from './brand.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' // The checkpoint source and its predicate are declared on the cordis-free // `./checkpoint` leaf so client and wire programs can name them without this // root's Context merge; the root stays the host-side entry point for both. -export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts' +export { COMPACT_CHECKPOINT_SOURCE, compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' +export type { CompactCheckpointSource } from './checkpoint.ts' /** Why automatic policy is asking a backend to consider compaction. */ export type CompactionTrigger = 'pressure' | 'context-overflow' @@ -126,6 +129,7 @@ export abstract class CompactService extends Service { * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; @@ -135,6 +139,7 @@ export abstract class CompactService extends Service { abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, + sourceCommandId?: CommandId, ): Promise /** diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index 221d5aae09..adac557db5 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,8 +1,12 @@ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ import type { Context } from 'cordis' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { CompactionId } from './brand.ts' +import { isCompactCheckpointSource } from './checkpoint.ts' +import type { CompactCheckpointSource } from './checkpoint.ts' import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-compact' @@ -13,6 +17,8 @@ export const name = 'compact-invariant' export const inject = ['invariants'] interface CompactionTrace { + compactionId: CompactionId + sourceCommandId: string | undefined startSeq: number turn: number | null summarized: boolean @@ -24,11 +30,48 @@ interface SessionTrace { } type CompactionTransition = - | { kind: 'start'; startSeq: number; turn: number | null } - | { kind: 'summary'; startSeq: number; turn: number | null } + | { kind: 'start'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } + | { kind: 'summary'; compactionId: CompactionId; sourceCommandId: string | undefined; startSeq: number; turn: number | null } | { kind: 'end' } | { kind: 'end-seed' } +/** Require a durable opaque identity to be a non-empty string. */ +function validateId(value: unknown, label: string, fail: InvariantFailure): asserts value is string { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`) +} + +/** Keep the optional initiating command identity stable across one transaction. */ +function validateSourceCommandId( + eventType: string, + value: unknown, + expected: string | undefined, + fail: InvariantFailure, +): void { + if (value !== undefined) validateId(value, `${eventType} sourceCommandId`, fail) + if (value !== expected) { + fail(`${eventType} sourceCommandId ${String(value)} does not match compact/start sourceCommandId ${String(expected)}`) + } +} + +/** Validate one replacement checkpoint against its open compaction transaction. */ +function validateCheckpoint( + trace: SessionTrace, + event: SessionEvent<'user/message'>, + fail: InvariantFailure, +): void { + const source = event.data.source as typeof event.data.source & Partial + validateId(source.compactionId, 'compaction checkpoint compactionId', fail) + if (source.sourceCommandId !== undefined) { + validateId(source.sourceCommandId, 'compaction checkpoint sourceCommandId', fail) + } + const open = trace.compaction + if (open === undefined) fail('compaction checkpoint has no matching compact/start') + if (source.compactionId !== open.compactionId) { + fail(`compaction checkpoint id ${source.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compaction checkpoint', source.sourceCommandId, open.sourceCommandId, fail) +} + /** Compaction starts still unmatched when a later seed boundary made them stale. */ function inheritedOrphanStartSeqs( events: readonly SessionEvent[], @@ -99,20 +142,44 @@ function validateCompactionEvent( fail: InvariantFailure, ): CompactionTransition | undefined { if (event.type === 'session/end-seed') return { kind: 'end-seed' } + if (event.type === 'user/message' + && isReplacementSurfaceEvent(event) + && isCompactCheckpointSource(event.data.source)) { + validateCheckpoint(trace, event, fail) + return undefined + } if (event.type !== 'compact/start' && event.type !== 'compact/summary' && event.type !== 'compact/end') { return undefined } const open = trace.compaction if (event.type === 'compact/start') { + validateId(event.data.compactionId, 'compact/start compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/start sourceCommandId', fail) + } if (open !== undefined) { const owner = open.turn === null ? 'standalone compaction' : `turn ${open.turn}` fail(`compact/start while ${owner} is still compacting`) } validateOwner(event.data.turn, trace.openTurn, event.type, fail) - return { kind: 'start', startSeq: event.seq, turn: event.data.turn } + return { + kind: 'start', + compactionId: event.data.compactionId, + sourceCommandId: event.data.sourceCommandId, + startSeq: event.seq, + turn: event.data.turn, + } } if (event.type === 'compact/summary') { + validateId(event.data.compactionId, 'compact/summary compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/summary sourceCommandId', fail) + } if (open === undefined) fail('compact/summary has no matching compact/start') + if (event.data.compactionId !== open.compactionId) { + fail(`compact/summary id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compact/summary', event.data.sourceCommandId, open.sourceCommandId, fail) validateOwner(open.turn, trace.openTurn, event.type, fail) if (open.summarized) fail('compact/summary repeated within one compaction') const seqs = event.data.shadowedSeqs @@ -123,9 +190,23 @@ function validateCompactionEvent( if (!Number.isSafeInteger(event.data.shadowedTokenCount) || event.data.shadowedTokenCount < 0) { fail('compact/summary shadowedTokenCount must be a non-negative safe integer') } - return { kind: 'summary', startSeq: open.startSeq, turn: open.turn } + return { + kind: 'summary', + compactionId: open.compactionId, + sourceCommandId: open.sourceCommandId, + startSeq: open.startSeq, + turn: open.turn, + } + } + validateId(event.data.compactionId, 'compact/end compactionId', fail) + if (event.data.sourceCommandId !== undefined) { + validateId(event.data.sourceCommandId, 'compact/end sourceCommandId', fail) } if (open === undefined) fail('compact/end has no matching compact/start') + if (event.data.compactionId !== open.compactionId) { + fail(`compact/end id ${event.data.compactionId} does not match compact/start id ${open.compactionId}`) + } + validateSourceCommandId('compact/end', event.data.sourceCommandId, open.sourceCommandId, fail) if (event.data.turn !== open.turn) { fail(`compact/end owner ${String(event.data.turn)} does not match compact/start owner ${String(open.turn)}`) } @@ -142,6 +223,8 @@ function applyCompactionTransition( ): CompactionTrace | undefined { if (transition.kind === 'start') { return { + compactionId: transition.compactionId, + sourceCommandId: transition.sourceCommandId, startSeq: transition.startSeq, turn: transition.turn, summarized: false, @@ -149,6 +232,8 @@ function applyCompactionTransition( } if (transition.kind === 'summary') { return { + compactionId: transition.compactionId, + sourceCommandId: transition.sourceCommandId, startSeq: transition.startSeq, turn: transition.turn, summarized: true, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 4429e8b291..c321be426e 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -8,6 +8,8 @@ */ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CompactionId } from './brand.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -16,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' { * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ - 'compact/start': { turn: number | null } + 'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } /** * Completed summary, its inputs, and its model call facts — log-only, no surfaceOp. * The summary content is in `data.summary`; the actual surface replacement @@ -27,6 +29,8 @@ declare module '@deepseek-ai/dsh-session' { * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -62,7 +66,7 @@ declare module '@deepseek-ai/dsh-session' { * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ - 'compact/end': { turn: number | null; error?: string } + 'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } /** * Shadow price of one model-free prune replacement — log-only, no * surfaceOp. The shared shadow-price protocol: a surface `replace` event @@ -85,6 +89,10 @@ declare module '@deepseek-ai/dsh-session' { /** Result of a successful compaction operation. */ export interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ diff --git a/packages/compact/compact/tsconfig.json b/packages/compact/compact/tsconfig.json index 673ee51547..a231786c32 100644 --- a/packages/compact/compact/tsconfig.json +++ b/packages/compact/compact/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cosmokit" }, @@ -17,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../interaction/commands" + }, { "path": "../../core/session" }, diff --git a/packages/compact/compact/tsdown.config.ts b/packages/compact/compact/tsdown.config.ts new file mode 100644 index 0000000000..6284421125 --- /dev/null +++ b/packages/compact/compact/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +/** Builds each published entry as a self-contained file admitted by the package whitelist. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 2ec5a1bb25..63c5b2cb9b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -30,7 +30,7 @@ declare module '@deepseek-ai/dsh-session' { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ - 'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } + 'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } /** * One bridged sub-dispatch SETTLING: the pairing ids (matching the * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` @@ -46,7 +46,7 @@ declare module '@deepseek-ai/dsh-session' { * before returning), so its execution-enclosure relation holds by * construction. */ - 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } + 'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } } } @@ -502,6 +502,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const subCallId = CallId(`${String(exec.callId)}:code:${n}`) const input = { callId: subCallId, + rootCallId: exec.rootCallId, name, arguments: normalized.dispatched, ...exec.agent ? { agent: exec.agent } : {}, @@ -539,6 +540,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge content: result.content, }) agent.session.append('tool/code-dispatch', { + rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, @@ -563,6 +565,7 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge }, async start(): Promise { exec.agent?.session.append('tool/code-dispatch-start', { + rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 7c2346e723..6338e54501 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -297,6 +297,11 @@ export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: */ export interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -352,6 +357,8 @@ export interface CodeDispatchLog { * observers run. */ export interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -1123,6 +1130,7 @@ export class ToolRegistry extends Service { const deferredContexts: UserMessage[] = [] const token = createExecutionToken() const callId = exec.callId + const rootCallId = exec.rootCallId ?? callId const name = exec.name const agent = exec.agent const parent = exec.parent @@ -1133,6 +1141,7 @@ export class ToolRegistry extends Service { const base = { token, callId, + rootCallId, name, signal, ...agent !== undefined ? { agent } : {}, diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 78666f5890..a0d9487857 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -33,9 +33,34 @@ function validateResult( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const stages = new WeakMap() const openTurns = new WeakMap() + const dispatchRoots = new WeakMap>() + const validateDispatch = (session: Session, event: SessionEvent): void => { + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return + const root = String(event.data.rootCallId) + const parent = String(event.data.parentCallId) + const child = String(event.data.subCallId) + if (root.length === 0 || parent.length === 0 || child.length === 0) { + fail(`${event.type} must carry non-empty rootCallId, parentCallId, and subCallId`) + return + } + const roots = dispatchRoots.get(session) + const known = roots?.get(child) + if (known !== undefined && known !== root) fail(`${event.type} changed rootCallId for subCallId ${child}`) + if (parent !== root && roots?.get(parent) !== root) { + fail(`${event.type} parentCallId ${parent} does not belong to rootCallId ${root}`) + } + } + const commitDispatch = (session: Session, event: SessionEvent): void => { + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return + const roots = dispatchRoots.get(session) as Map + roots.set(String(event.data.subCallId), String(event.data.rootCallId)) + } const seed = (session: Session): number | null => { let openTurn: number | null = null + dispatchRoots.set(session, new Map()) for (const event of session.events) { + validateDispatch(session, event) + commitDispatch(session, event) if (event.type === 'turn/start') openTurn = event.data.turn else if (event.type === 'turn/end') openTurn = null else if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') @@ -51,12 +76,15 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('session/event', (session, event) => { + validateDispatch(session, event) + commitDispatch(session, event) if (event.type === 'turn/start') openTurns.set(session, event.data.turn) else if (event.type === 'turn/end') openTurns.set(session, null) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName === 'session/event') { const [session, event] = args as [Session, SessionEvent] + validateDispatch(session, event) if ((event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') && openTurnFor(session) === null) { fail(`${event.type} appended outside any open turn`) diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 94c4861e66..780569c159 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -37,6 +37,8 @@ export interface CommandInputDescriptor { /** Invocation passed to one registered command handler. */ export interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -389,7 +391,7 @@ export class CommandService extends Service { ...command.definition.recordInput === false ? {} : { args: parsed.rawInput }, source: { kind: 'user' }, }) - const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal }) + const invocation = Object.freeze({ commandId, agent, rawInput: parsed.rawInput, signal }) let result: CommandResult try { const output = command.definition.handler(invocation) diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index d3b6afebba..1685d0e552 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./package.json": "./package.json" }, "files": [ @@ -29,6 +33,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -40,6 +45,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/llm/llm-retry/src/brand.ts b/packages/llm/llm-retry/src/brand.ts new file mode 100644 index 0000000000..41682cc9d1 --- /dev/null +++ b/packages/llm/llm-retry/src/brand.ts @@ -0,0 +1,13 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable identity shared by every attempt in one request-step retry chain. */ +export type RetryId = Branded<'RetryId'> + +/** + * Brand an implementation-minted retry-chain identity. + * @param id - opaque retry identity. + * @returns the same string, branded; no validation is performed. + */ +export function RetryId(id: string): RetryId { + return id as RetryId +} diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 82b441fb8f..dcba1e2443 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -5,39 +5,26 @@ * @module @deepseek-ai/dsh-llm-retry */ +import { randomUUID } from 'node:crypto' import type { Context, Events } from 'cordis' import z from 'schemastery' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { RetryId } from './brand.ts' +import type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ - 'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure - } | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure - } + 'llm/retry': LlmRetryEventData + /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ + 'llm/retry-started': LlmRetryStartedEventData } } -export type { LlmRetryEventData } from './types.ts' +export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' +export { RetryId } from './brand.ts' export const name = 'llm-retry' export const inject = ['agents'] @@ -139,6 +126,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna policy: ResolvedRetryPolicy, policyKey: string, retry: number, + retryId: RetryId, delayMs: number, signal: AbortSignal, ): Promise { @@ -146,6 +134,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna if (fusedSignal.aborted) return const eventData = policy.mode === 'normal' ? { + retryId, turn, step, provider, @@ -157,6 +146,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna failure, } : { + retryId, turn, step, provider, @@ -168,6 +158,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } agent.session.append('llm/retry', eventData) if (!await cancellableDelay(delayMs, fusedSignal)) return + agent.session.append('llm/retry-started', { retryId, turn, step, retry }) return { kind: 'retry' } } @@ -207,6 +198,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna const previousRetry = priorPolicyRetry?.data.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() const retry = previousRetry + 1 + const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID()) let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) @@ -221,7 +213,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna delayMs = localDelay(policy, retry, random) } - return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, delayMs, signal) + return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal) } const disposeListener = ctx.on('agent/request-error', ( diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index d324c012f2..1680873c0c 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -47,7 +47,10 @@ function validateRetry( event: SessionEvent<'llm/retry'>, fail: InvariantFailure, ): void { - const { turn, step, provider, mode, policyKey, retry, delayMs } = event.data + const { retryId, turn, step, provider, mode, policyKey, retry, delayMs } = event.data + if (typeof retryId !== 'string' || retryId.length === 0) { + fail('llm/retry retryId must be a non-empty string') + } const failure: unknown = event.data.failure validateFailure(failure, fail) if (!Number.isSafeInteger(retry) || retry < 1) { @@ -110,12 +113,43 @@ function validateRetry( if (retry !== expectedRetry) { fail(`llm/retry retry ${retry} must equal provider policy retry ${expectedRetry}`) } + if (priorPolicyRetry !== undefined && priorPolicyRetry.data.retryId !== retryId) { + fail('llm/retry must preserve retryId across one provider-policy chain') + } + if (priorPolicyRetry === undefined && history.some(prior => + (prior.type === 'llm/retry' || prior.type === 'llm/retry-started') + && prior.data.retryId === retryId)) { + fail(`llm/retry retryId ${JSON.stringify(retryId)} is already owned by another chain`) + } +} + +/** Validate one wait-complete transition against its scheduled attempt. */ +function validateStarted( + history: readonly SessionEvent[], + event: SessionEvent<'llm/retry-started'>, + fail: InvariantFailure, +): void { + const { retryId, turn, step, retry } = event.data + if (typeof retryId !== 'string' || retryId.length === 0) { + fail('llm/retry-started retryId must be a non-empty string') + } + const scheduled = history.findLast((prior): prior is SessionEvent<'llm/retry'> => + prior.type === 'llm/retry' && prior.data.retryId === retryId && prior.data.retry === retry) + if (scheduled === undefined) fail('llm/retry-started pairs no prior scheduled attempt') + if (scheduled.data.turn !== turn || scheduled.data.step !== step) { + fail('llm/retry-started turn/step must match its scheduled attempt') + } + if (history.some(prior => prior.type === 'llm/retry-started' + && prior.data.retryId === retryId && prior.data.retry === retry)) { + fail('llm/retry-started repeats one scheduled attempt') + } } /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { for (const [index, event] of session.events.entries()) { if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail) } } @@ -127,6 +161,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type === 'llm/retry') validateRetry(session.events, event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index f59aef4495..12820889dd 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,8 +1,10 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' +import type { RetryId } from './brand.ts' /** Durable payload recorded before one provider-routed model-request retry wait. */ export type LlmRetryEventData = | { + retryId: RetryId turn: number step: number provider: string @@ -13,7 +15,9 @@ export type LlmRetryEventData = delayMs: number failure: LlmFailure } + | { + retryId: RetryId turn: number step: number provider: string @@ -23,3 +27,11 @@ export type LlmRetryEventData = delayMs: number failure: LlmFailure } + +/** Durable transition recorded after one retry delay completes. */ +export interface LlmRetryStartedEventData { + retryId: RetryId + turn: number + step: number + retry: number +} diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json index 48c858951a..41e9ca65c5 100644 --- a/packages/llm/llm-retry/tsconfig.json +++ b/packages/llm/llm-retry/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cosmokit" }, From 81d688600677eabab42fbb9f364ed5a3d7ce7b30 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:19 +0800 Subject: [PATCH 03/20] feat(client): add conversation node engine and contextual slots --- packages/client/runtime/package.json | 1 - .../src/client/contract/conversation.ts | 261 ++++++ .../conversation/definition-registry.ts | 60 ++ .../src/client/conversation/event-registry.ts | 56 ++ .../src/client/conversation/view-registry.ts | 26 + packages/client/runtime/src/client/index.ts | 39 +- .../src/client/sessions/assistant-timing.ts | 7 +- .../client/sessions/conversation-assembler.ts | 798 ++++++++++++++++++ .../sessions/conversation-location-index.ts | 508 +++++++++++ .../src/client/sessions/conversation.ts | 91 +- .../runtime/src/client/sessions/manager.ts | 15 +- .../runtime/src/client/sessions/partial.ts | 9 +- .../src/client/sessions/queue-mirror.ts | 74 ++ .../runtime/src/client/sessions/service.ts | 36 +- .../runtime/src/client/sessions/session.ts | 454 ++-------- .../src/client/sessions/transcript-adapter.ts | 409 --------- packages/client/runtime/src/client/slots.ts | 2 +- packages/client/ui-slots/src/index.ts | 192 ++++- packages/client/ui-slots/src/renderer.ts | 4 +- .../client/web-react/src/scoped-slots.tsx | 333 ++++++-- 20 files changed, 2475 insertions(+), 900 deletions(-) create mode 100644 packages/client/runtime/src/client/contract/conversation.ts create mode 100644 packages/client/runtime/src/client/conversation/definition-registry.ts create mode 100644 packages/client/runtime/src/client/conversation/event-registry.ts create mode 100644 packages/client/runtime/src/client/conversation/view-registry.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation-assembler.ts create mode 100644 packages/client/runtime/src/client/sessions/conversation-location-index.ts create mode 100644 packages/client/runtime/src/client/sessions/queue-mirror.ts delete mode 100644 packages/client/runtime/src/client/sessions/transcript-adapter.ts diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3d97e70de5..3176428b36 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -33,7 +33,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts new file mode 100644 index 0000000000..fb98965d25 --- /dev/null +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -0,0 +1,261 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' + +/** One raw log event plus its optional envelope-level presentation view. */ +export interface ConversationEventInput { + readonly event: SessionEvent + readonly view: ToolEventView | undefined +} + +/** Definition-local identity and lifecycle role extracted from one event. */ +export interface ConversationMatchResult { + readonly id: string + readonly role: 'start' | 'update' +} + +/** Merge-extensible business values published against one Turn. */ +export interface ConversationTurnDataMap {} + +/** Merge-extensible business values published against one Step. */ +export interface ConversationStepDataMap {} + +/** Stable keyed reader for independently owned Location business values. */ +export interface ConversationLocationDataStore { + /** + * Read one business value without exposing another owner's mutable State. + * @param key - declaration-merged business key. + * @returns latest immutable value, when its owning Context has published one. + */ + get(key: Key): Readonly | undefined +} + +interface ConversationLocationDataValue { + readonly kind: 'turn' | 'step' + readonly turn: number + readonly step?: number + readonly key: string + readonly value: unknown +} + +type RegisteredTurnData = { + [Key in keyof ConversationTurnDataMap & string]: { + readonly kind: 'turn' + readonly turn: number + readonly key: Key + readonly value: ConversationTurnDataMap[Key] + } +}[keyof ConversationTurnDataMap & string] + +type RegisteredStepData = { + [Key in keyof ConversationStepDataMap & string]: { + readonly kind: 'step' + readonly turn: number + readonly step: number + readonly key: Key + readonly value: ConversationStepDataMap[Key] + } +}[keyof ConversationStepDataMap & string] + +/** One Definition-owned value attached to an Engine-owned Turn or Step. */ +export type ConversationLocationData = + [keyof ConversationTurnDataMap | keyof ConversationStepDataMap] extends [never] + ? ConversationLocationDataValue + : RegisteredTurnData | RegisteredStepData + +/** Immutable resolved boundary for one Agent step. */ +export interface StepLocation { + readonly turn: number + readonly step: number + readonly start: SessionEvent<'step/start'> | undefined + readonly end: SessionEvent<'step/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + /** Stable reader for Step-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Immutable resolved boundary for one Agent turn. */ +export interface TurnLocation { + readonly turn: number + readonly start: SessionEvent<'turn/start'> | undefined + readonly end: SessionEvent<'turn/end'> | undefined + readonly status: 'open' | 'closed' | 'unknown' + readonly steps: readonly StepLocation[] + /** Stable reader for Turn-scoped business values. */ + readonly data: ConversationLocationDataStore +} + +/** Engine-owned placement of one matched event in the Session hierarchy. */ +export type ConversationLocation = + | { readonly kind: 'session' } + | { readonly kind: 'turn'; readonly turn: TurnLocation } + | { 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' + readonly location: ConversationLocation +} + +/** Target-neutral identity returned by a business Definition. */ +export interface ConversationViewNode { + readonly key: string + readonly kind: string + readonly id: string + readonly target: string + readonly data: unknown +} + +/** Final Chat render unit produced directly by a business Definition. */ +export interface ChatConversationViewNode extends ConversationViewNode { + readonly target: 'chat' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly visibility: 'visible' | 'hidden' +} + +/** Immutable public view of an assembled business Context. */ +export interface ConversationNodeContext { + readonly key: string + readonly kind: string + readonly id: string + readonly matches: readonly ConversationMatch[] + readonly start: ConversationMatch | undefined + readonly state: State | undefined + readonly current: ReadonlyMap +} + +/** Read-only predecessor returned to a Definition's start function. */ +export interface ConversationPreviousContext { + readonly key: string + readonly kind: string + readonly id: string + readonly startSeq: number + readonly state: Readonly + readonly matches: readonly ConversationMatch[] +} + +/** Strictly-backward Context lookup available while a start is evaluated. */ +export interface ConversationContextReader { + /** + * Find the active Context of `kind` with the greatest start seq below the + * current start event. + * @param kind - Definition kind to query. + * @returns the nearest predecessor, or undefined when absent in the current window. + */ + previous(kind: string): ConversationPreviousContext | undefined +} + +/** Requested cadence for materializing updated business State into view Nodes. */ +export type ConversationPublication = 'none' | 'animation-frame' | 'immediate' + +/** Engine-owned Location data publication phase. */ +export type ConversationLocationDataScope = 'step' | 'turn' + +/** One independently registered business Event-to-Node state machine. */ +export interface ConversationNodeDefinition { + readonly kind: string + /** + * Extract this Definition's stable business identity from one event. + * @param event - raw Session event; no Context or history access is available. + * @returns identity and lifecycle role, or null when unrelated. + */ + match(event: SessionEvent): ConversationMatchResult | null + /** + * Create State from the unique start Match. + * @param context - complete evidence currently collected for the Context. + * @param match - the start Match. + * @param reader - strictly-backward read-only Context lookup. + * @returns the State adopted by the engine. + */ + start( + context: ConversationNodeContext, + match: ConversationMatch, + reader: ConversationContextReader, + ): State + /** + * Apply one post-start update Match. + * @param context - Context with its current State. + * @param match - update Match in ascending log order. + * @returns the State adopted by the engine. + */ + update( + context: ConversationNodeContext & { readonly state: State }, + match: ConversationMatch, + ): State + /** + * Select publication cadence for one accepted Match. + * @param match - accepted Match. + * @returns requested cadence; omission defaults to immediate. + */ + publication?(match: ConversationMatch): ConversationPublication + /** + * Publish this Definition's read-only business value for one Location phase. + * The Engine evaluates every Definition first for Step and then for Turn, + * owns replacement/removal, and rejects another Context trying to publish + * the same Location key. + * @param context - latest complete Context. + * @param scope - Location hierarchy level currently being materialized. + * @returns current Location value, or null while unavailable. + */ + buildLocationData?( + context: ConversationNodeContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null + /** + * Materialize one final Node for a registered view target. + * @param context - latest complete Context. + * @param target - registered view target such as `chat`. + * @returns final Node, or null when this Context is not currently visible. + */ + buildViewNode( + context: ConversationNodeContext, + target: string, + ): ConversationViewNode | null +} + +/** Reference-stable Turn/Step facts published beside view Nodes. */ +export interface ConversationTimelineSnapshot { + readonly turnOrder: readonly number[] + readonly turns: ReadonlyMap +} + +/** Per-Session incremental builder for one view target. */ +export interface ConversationViewBuilder { + readonly empty: Snapshot + /** + * Replace the low-frequency complete materialized Node set. + * @param input - complete Nodes and current timeline. + * @returns next view snapshot. + */ + replace(input: { + readonly nodes: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot + /** + * Apply only Nodes whose materialized values changed in this transaction. + * @param input - changed Nodes and current timeline. + * @returns next view snapshot. + */ + apply(input: { + readonly upserts: readonly Node[] + readonly timeline: ConversationTimelineSnapshot + }): Snapshot +} + +/** Registry contribution that creates one isolated view builder per Session. */ +export interface ConversationViewDefinition { + readonly target: string + /** @returns a new Session-owned incremental builder. */ + create(): ConversationViewBuilder +} + +/** + * Build a stable collision-free key for one Definition-local business identity. + * @param kind - Definition kind. + * @param id - Definition-local business identity. + * @returns engine-owned Context key. + */ +export function conversationContextKey(kind: string, id: string): string { + return `${kind.length}:${kind}${id}` +} diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/runtime/src/client/conversation/definition-registry.ts new file mode 100644 index 0000000000..d43f494e1a --- /dev/null +++ b/packages/client/runtime/src/client/conversation/definition-registry.ts @@ -0,0 +1,60 @@ +import { Service } from 'cordis' + +/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ +export abstract class ConversationDefinitionRegistry extends Service { + protected readonly definitions = new Map() + private listeners = new Set<() => void>() + private cached: readonly Definition[] = [] + + /** + * Return reference-stable Definitions in registration order. + * @returns current Definitions. + */ + entries(): readonly Definition[] { + return this.cached + } + + /** + * Observe low-frequency registry changes. + * @param listener - synchronous invalidation callback. + * @returns unsubscribe callback. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Register one uniquely keyed Definition for the caller's lifetime. + * @param key - registry-local unique key. + * @param definition - contributed Definition. + * @param duplicateMessage - error raised when the key is already owned. + * @param effectName - Cordis effect diagnostic label. + * @returns idempotent disposer. + */ + protected registerDefinition( + key: string, + definition: Definition, + duplicateMessage: string, + effectName: string, + ): () => void { + if (this.definitions.has(key)) throw new Error(duplicateMessage) + const owner = this.ctx + const dispose = owner.effect(() => { + this.definitions.set(key, definition) + this.refresh() + return () => { + if (this.definitions.get(key) !== definition) return + this.definitions.delete(key) + this.refresh() + } + }, effectName) + return () => { void dispose() } + } + + /** Refresh cached entries and synchronously invalidate subscribers. */ + protected refresh(): void { + this.cached = [...this.definitions.values()] + for (const listener of this.listeners) listener() + } +} diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts new file mode 100644 index 0000000000..6935ed1741 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -0,0 +1,56 @@ +import type { Context } from 'cordis' +import type { ConversationNodeDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of independently owned Conversation business Definitions. */ +export class ConversationEventRegistry extends ConversationDefinitionRegistry { + private fallback: ConversationNodeDefinition | undefined + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationEvents') + } + + /** + * Register a uniquely named business Definition for the caller's lifetime. + * @param definition - Definition contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationNodeDefinition): () => void { + return this.registerDefinition( + definition.kind, + definition, + `conversation Definition "${definition.kind}" is already registered`, + `conversationEvents.register(${JSON.stringify(definition.kind)})`, + ) + } + + /** + * Register the sole fallback used only when no ordinary Definition matches. + * @param definition - fallback Definition. + * @returns idempotent disposer. + */ + registerFallback(definition: ConversationNodeDefinition): () => void { + if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered') + const owner = this.ctx + const dispose = owner.effect(() => { + this.fallback = definition + this.refresh() + return () => { + if (this.fallback !== definition) return + this.fallback = undefined + this.refresh() + } + }, `conversationEvents.registerFallback(${JSON.stringify(definition.kind)})`) + return () => { void dispose() } + } + + /** + * Return the current unmatched-event fallback. + * @returns installed fallback, when present. + */ + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } + +} diff --git a/packages/client/runtime/src/client/conversation/view-registry.ts b/packages/client/runtime/src/client/conversation/view-registry.ts new file mode 100644 index 0000000000..1e2e53e141 --- /dev/null +++ b/packages/client/runtime/src/client/conversation/view-registry.ts @@ -0,0 +1,26 @@ +import type { Context } from 'cordis' +import type { ConversationViewDefinition } from '../contract/conversation.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' + +/** Runtime registry of per-target Conversation snapshot builders. */ +export class ConversationViewRegistry extends ConversationDefinitionRegistry { + + /** @param ctx - owning Client Runtime context. */ + constructor(ctx: Context) { + super(ctx, 'conversationViews') + } + + /** + * Register a uniquely named view builder factory for the caller's lifetime. + * @param definition - target builder contribution. + * @returns idempotent disposer. + */ + register(definition: ConversationViewDefinition): () => void { + return this.registerDefinition( + definition.target, + definition, + `conversation view target "${definition.target}" is already registered`, + `conversationViews.register(${JSON.stringify(definition.target)})`, + ) + } +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b7226c0085..fe1ce8f7b0 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -10,8 +10,27 @@ import { SessionHistoryService } from './session-history/service.ts' import { WorkspacesService } from './workspaces/service.ts' import type { ConversationSnapshot } from './sessions/conversation.ts' import type { UseProjection } from './sessions/projection-store.ts' +import { ConversationEventRegistry } from './conversation/event-registry.ts' +import { ConversationViewRegistry } from './conversation/view-registry.ts' + +export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' export { SlotsService } from './slots.ts' +export { ConversationEventRegistry } from './conversation/event-registry.ts' +export { ConversationViewRegistry } from './conversation/view-registry.ts' +export { ConversationNodeAssembler } from './sessions/conversation-assembler.ts' +export { ConversationLocationIndex } from './sessions/conversation-location-index.ts' +export { conversationContextKey } from './contract/conversation.ts' +export type { + ChatConversationViewNode, ConversationContextReader, ConversationEventInput, + ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore, + ConversationStepDataMap, + ConversationLocation, ConversationMatch, ConversationMatchResult, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationPublication, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, StepLocation, TurnLocation, +} from './contract/conversation.ts' +export type { ConversationRuntime } from './sessions/conversation-assembler.ts' export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { SessionHistoryService } from './session-history/service.ts' @@ -49,11 +68,17 @@ export type { } from './contract/store.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, - AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase, + AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, - RunningToolCall, + LegacyConversationSlice, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' +export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './sessions/conversation.ts' +export { emptyAssistantBlock } from './sessions/partial.ts' +export { isTokenDelta } from './sessions/assistant-timing.ts' +export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, } from './sessions/conversation-context.ts' @@ -165,6 +190,10 @@ declare module 'cordis' { } interface Context { slots: import('./slots.ts').SlotsService + /** Event-to-business-Context Definition registry. */ + conversationEvents: import('./conversation/event-registry.ts').ConversationEventRegistry + /** Per-target Conversation snapshot builder registry. */ + conversationViews: import('./conversation/view-registry.ts').ConversationViewRegistry /** The outward face only; the concrete service stays inside the runtime. */ sessions: import('./contract/sessions.ts').ISessions /** Read-only history sources isolated from Chat sessions and workspace state. */ @@ -182,8 +211,12 @@ export const inject = ['connection', 'typert'] */ export function apply(ctx: Context): void { ctx.plugin(SlotsService) + const conversation = { + events: new ConversationEventRegistry(ctx), + views: new ConversationViewRegistry(ctx), + } const connection = ctx.get('connection') as ConnectionHandle - const sessions = new SessionsService(ctx, connection.api) + const sessions = new SessionsService(ctx, connection.api, conversation) ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), }) diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 021c679d04..3c54c9c36d 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -1,7 +1,6 @@ -// Shared assistant step-timing fold: both transcript projections (the live -// window adapter and the trajectory history fold) derive AssistantTiming from -// the same step/start -> first token delta -> assistant/message sequence, so -// the derivation lives once here instead of drifting per projection. +// Shared assistant step-timing fold: Chat Definitions and the Trajectory +// history fold derive AssistantTiming from the same step/start -> first token +// delta -> assistant/message sequence. import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts new file mode 100644 index 0000000000..3b6fbd6266 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -0,0 +1,798 @@ +import type { + ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, + ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, + ConversationViewDefinition, ConversationViewNode, +} from '../contract/conversation.ts' +import { conversationContextKey } from '../contract/conversation.ts' +import { + ConversationLocationIndex, type ConversationLocationDataChange, +} from './conversation-location-index.ts' + +interface Dependency { + readonly kind: string + readonly key: string | undefined + readonly revision: number | undefined + readonly windowGap: boolean +} + +interface InternalContext { + readonly key: string + readonly kind: string + readonly id: string + readonly definition: ConversationNodeDefinition + startSeq: number | undefined + start: ConversationMatch | undefined + matches: ConversationMatch[] + state: unknown + revision: number + readonly current: Map + readonly locationData: Record + dependencies: Map +} + +interface PendingMatch { + readonly definition: ConversationNodeDefinition + readonly id: string + readonly match: ConversationMatch +} + +interface ViewState { + readonly target: string + readonly builder: ConversationViewBuilder + snapshot: unknown +} + +const PUBLICATION_RANK: Record = { + none: 0, + 'animation-frame': 1, + immediate: 2, +} + +const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn'] + +function emptyLocationData(): Record { + return { step: null, turn: null } +} + +function maximumPublication( + left: ConversationPublication, + right: ConversationPublication, +): ConversationPublication { + return PUBLICATION_RANK[left] >= PUBLICATION_RANK[right] ? left : right +} + +function startSeq(context: InternalContext): number | undefined { + return context.startSeq +} + +function insertionIndex(contexts: readonly InternalContext[], seq: number): number { + let low = 0 + let high = contexts.length + while (low < high) { + const middle = low + Math.floor((high - low) / 2) + const candidate = contexts[middle] + if (candidate !== undefined && (candidate.startSeq as number) < seq) low = middle + 1 + else high = middle + } + return low +} + +function contextSnapshot(context: InternalContext): ConversationNodeContext { + return { + key: context.key, + kind: context.kind, + id: context.id, + matches: context.matches, + start: context.start, + state: context.state as State | undefined, + current: context.current, + } +} + +function mergeMatches( + key: string, + additions: readonly ConversationMatch[], + existing: readonly ConversationMatch[], +): ConversationMatch[] { + const merged: ConversationMatch[] = [] + let added = 0 + let current = 0 + while (added < additions.length || current < existing.length) { + const left = additions[added] + const right = existing[current] + if (left !== undefined && right !== undefined && left.event.seq === right.event.seq) { + throw new Error(`conversation Context ${key} received duplicate Match ${left.event.seq}`) + } + if (right === undefined || (left !== undefined && left.event.seq < right.event.seq)) { + merged.push(left as ConversationMatch) + added++ + } else { + merged.push(right) + current++ + } + } + return merged +} + +/** Event Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationEventDefinitions { + /** @returns ordinary Definitions in registration order. */ + entries(): readonly ConversationNodeDefinition[] + /** @returns unmatched-event fallback, when registered. */ + fallbackEntry(): ConversationNodeDefinition | undefined +} + +/** View Registry subset consumed by a Session-owned Assembler. */ +export interface ConversationViewDefinitions { + /** @returns view builder factories in registration order. */ + entries(): readonly ConversationViewDefinition[] +} + +/** + * Session-owned incremental engine that assembles business Contexts from a + * contiguous Event window and materializes registered view snapshots. + */ +export class ConversationNodeAssembler { + private readonly contexts = new Map() + private readonly contextsByKind = new Map() + private readonly contextsBySeq = new Map>() + private readonly inputs = new Map() + private readonly locationIndex = new ConversationLocationIndex() + private readonly dirty = new Set() + private readonly revised = new Set() + private readonly dependents = new Map>() + private readonly views = new Map() + private hasMore = false + private replacePending = true + private timelineDirty = true + + /** + * @param eventDefinitions - live Event Definition registry. + * @param viewDefinitions - live view builder registry. + */ + constructor( + private readonly eventDefinitions: ConversationEventDefinitions, + private readonly viewDefinitions: ConversationViewDefinitions, + ) { + this.resetViewBuilders() + } + + /** + * Replace the complete loaded window after open, resync, or gap repair. + * @param entries - complete contiguous window. + * @param hasMore - whether older history remains outside the window. + * @returns immediate publication request. + */ + replaceWindow(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.contexts.clear() + this.contextsByKind.clear() + this.contextsBySeq.clear() + this.inputs.clear() + this.dirty.clear() + this.revised.clear() + this.dependents.clear() + this.hasMore = hasMore + const sorted = [...entries].sort((left, right) => left.event.seq - right.event.seq) + for (const entry of sorted) this.inputs.set(entry.event.seq, entry) + this.locationIndex.rebuild(sorted) + this.timelineDirty = true + for (const entry of sorted) this.matchInput(entry) + this.replayDependencies() + this.revised.clear() + for (const context of this.contexts.values()) this.dirty.add(context) + this.replacePending = true + return 'immediate' + } + + /** + * Add one contiguous live tail event without scanning existing Contexts. + * @param input - appended Event and optional wire view. + * @returns highest requested publication cadence. + */ + append(input: ConversationEventInput): ConversationPublication { + if (this.inputs.has(input.event.seq)) return 'none' + this.revised.clear() + this.inputs.set(input.event.seq, input) + let publication: ConversationPublication = 'none' + if (isLocationBoundary(input.event.type)) { + const previousTimeline = this.locationIndex.snapshot() + const changed = this.locationIndex.appendBoundary(input.event) + if (this.locationIndex.snapshot() !== previousTimeline) { + this.timelineDirty = true + publication = 'immediate' + } + this.replayContexts(this.refreshMatchLocations(changed)) + if (changed.size > 0) publication = 'immediate' + } else { + this.locationIndex.appendNonBoundary(input.event) + } + publication = maximumPublication(publication, this.matchInput(input)) + if (this.replayRevisedDependents()) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Add an older page while preserving existing Context and view identities. + * @param entries - newly loaded older Events. + * @param hasMore - whether history still precedes the expanded window. + * @returns highest requested publication cadence. + */ + prepend(entries: readonly ConversationEventInput[], hasMore: boolean): ConversationPublication { + this.revised.clear() + let publication: ConversationPublication = 'none' + const previousHasMore = this.hasMore + const fresh = entries + .filter(entry => !this.inputs.has(entry.event.seq)) + .sort((left, right) => left.event.seq - right.event.seq) + for (const entry of fresh) this.inputs.set(entry.event.seq, entry) + this.hasMore = hasMore + const previousTimeline = this.locationIndex.snapshot() + const changedLocations = this.locationIndex.rebuild(this.sortedInputs()) + if (this.locationIndex.snapshot() !== previousTimeline) this.timelineDirty = true + const affected = this.refreshMatchLocations(changedLocations) + const pending = new Map() + for (const entry of fresh) { + publication = maximumPublication(publication, this.collectInput(entry, pending)) + } + this.applyPendingMatches(pending, affected) + this.replayContexts(affected) + if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { + publication = 'immediate' + } + if (changedLocations.size > 0) publication = 'immediate' + this.revised.clear() + return publication + } + + /** + * Rebuild against the current Registry set after a low-frequency plugin change. + * @returns immediate publication request. + */ + rebuildRegistry(): ConversationPublication { + this.resetViewBuilders() + return this.replaceWindow(this.sortedInputs(), this.hasMore) + } + + /** + * Materialize dirty Contexts and advance every registered view builder. + * @returns whether any view snapshot was rebuilt or incrementally applied. + */ + flush(): boolean { + if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false + if (this.replacePending) { + this.replaceLocationData() + const allByTarget = new Map() + for (const target of this.views.keys()) allByTarget.set(target, []) + for (const context of this.contexts.values()) { + for (const target of this.views.keys()) { + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) allByTarget.get(target)?.push(node) + } + } + for (const view of this.views.values()) { + view.snapshot = view.builder.replace({ + nodes: allByTarget.get(view.target) ?? [], + timeline: this.locationIndex.snapshot(), + }) + } + this.replacePending = false + this.dirty.clear() + this.timelineDirty = false + return true + } + + const upsertsByTarget = new Map() + for (const target of this.views.keys()) upsertsByTarget.set(target, []) + if (this.applyDirtyLocationData()) this.timelineDirty = true + for (const context of this.dirty) { + for (const target of this.views.keys()) { + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) + } + context.current.set(target, node) + if (node !== null) upsertsByTarget.get(target)?.push(node) + } + } + this.dirty.clear() + const timelineDirty = this.timelineDirty + this.timelineDirty = false + for (const view of this.views.values()) { + const upserts = upsertsByTarget.get(view.target) ?? [] + if (upserts.length === 0 && !timelineDirty) continue + view.snapshot = view.builder.apply({ + upserts, + timeline: this.locationIndex.snapshot(), + }) + } + return true + } + + /** + * Read the latest snapshot of a registered target. + * @param target - registered view target. + * @returns target snapshot, or undefined when no builder is registered. + */ + snapshot(target: string): unknown { + return this.views.get(target)?.snapshot + } + + private sortedInputs(): ConversationEventInput[] { + return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) + } + + private matchInput(input: ConversationEventInput): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => + this.acceptMatch(definition, id, role, input)) + } + + private collectInput( + input: ConversationEventInput, + pending: Map, + ): ConversationPublication { + return this.dispatchInput(input, (definition, id, role) => { + const key = conversationContextKey(definition.kind, id) + const match: ConversationMatch = { + ...input, + role, + location: this.locationIndex.locationOf(input.event), + } + const matches = pending.get(key) ?? [] + matches.push({ definition, id, match }) + pending.set(key, matches) + return definition.publication?.(match) ?? 'immediate' + }) + } + + private dispatchInput( + input: ConversationEventInput, + accept: ( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + ) => ConversationPublication, + ): ConversationPublication { + let matched = false + let publication: ConversationPublication = 'none' + for (const definition of this.eventDefinitions.entries()) { + const result = definition.match(input.event) + if (result === null) continue + matched = true + publication = maximumPublication(publication, accept(definition, result.id, result.role)) + } + if (!matched) { + const fallback = this.eventDefinitions.fallbackEntry() + const result = fallback?.match(input.event) ?? null + if (fallback !== undefined && result !== null) { + publication = maximumPublication(publication, accept(fallback, result.id, result.role)) + } + } + return publication + } + + private acceptMatch( + definition: ConversationNodeDefinition, + id: string, + role: ConversationMatch['role'], + input: ConversationEventInput, + ): ConversationPublication { + const key = conversationContextKey(definition.kind, id) + let context = this.contexts.get(key) + if (role === 'start' && context?.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + if (context === undefined) { + context = { + key, + kind: definition.kind, + id, + definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + const match: ConversationMatch = { + ...input, + role, + location: 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}`) + } + if (role === 'start' && context.matches.length > 0) { + throw new Error(`conversation Context ${key} received an update before its start Match`) + } + context.matches.push(match) + if (role === 'start') { + context.startSeq = input.event.seq + context.start = match + this.indexStartedContext(context) + } + const owners = this.contextsBySeq.get(input.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(input.event.seq, owners) + + if (role === 'start') { + this.replayContext(context) + } else if (context.state !== undefined) { + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState(definition, 'update', definition.update(typed, match)) + context.revision++ + this.revised.add(context) + } + this.dirty.add(context) + return definition.publication?.(match) ?? 'immediate' + } + + private applyPendingMatches( + pending: ReadonlyMap, + affected: Set, + ): void { + const startsByKind = new Map() + for (const [key, entries] of pending) { + const first = entries[0] + if (first === undefined) continue + let context = this.contexts.get(key) + if (context === undefined) { + context = { + key, + kind: first.definition.kind, + id: first.id, + definition: first.definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + } + let discoveredStart: ConversationMatch | undefined + const additions = entries + .map((entry) => { + if (entry.definition !== context.definition || entry.id !== context.id) { + throw new Error(`conversation Context ${key} received inconsistent Definition identity`) + } + if (entry.match.role === 'start') { + if (discoveredStart !== undefined || context.start !== undefined) { + throw new Error(`conversation Context ${key} received more than one start Match`) + } + discoveredStart = entry.match + } + const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set() + owners.add(context) + this.contextsBySeq.set(entry.match.event.seq, owners) + return entry.match + }) + .sort((left, right) => left.event.seq - right.event.seq) + context.matches = mergeMatches(context.key, additions, context.matches) + if (discoveredStart !== undefined) { + context.start = discoveredStart + context.startSeq = discoveredStart.event.seq + const starts = startsByKind.get(context.kind) ?? [] + starts.push(context) + startsByKind.set(context.kind, starts) + } + if (context.start !== undefined && context.matches[0] !== context.start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + affected.add(context) + this.dirty.add(context) + } + for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) + } + + private replayContexts(contexts: ReadonlySet): void { + const ordered = [...contexts].sort((left, right) => + (left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY)) + for (const context of ordered) { + if (context.start === undefined) { + context.state = undefined + this.dirty.add(context) + continue + } + this.replayContext(context) + } + } + + private replayContext(context: InternalContext): void { + const start = context.start + if (start === undefined) { + context.state = undefined + return + } + if (context.matches[0] !== start) { + throw new Error(`conversation Context ${context.key} received an update before its start Match`) + } + const dependencies = new Map() + const reader = this.readerFor(start.event.seq, dependencies) + context.state = undefined + context.state = requireState( + context.definition, + 'start', + context.definition.start(contextSnapshot(context), start, reader), + ) + this.replaceDependencies(context, dependencies) + for (let index = 1; index < context.matches.length; index++) { + const match = context.matches[index] + if (match === undefined || match.role !== 'update') continue + const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } + context.state = requireState( + context.definition, + 'update', + context.definition.update(typed, match), + ) + } + context.revision++ + this.revised.add(context) + this.dirty.add(context) + } + + private replaceDependencies(context: InternalContext, dependencies: Map): void { + for (const dependency of context.dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) + current?.delete(context) + if (current?.size === 0) this.dependents.delete(dependency.key) + } + context.dependencies = dependencies + for (const dependency of dependencies.values()) { + if (dependency.key === undefined) continue + const current = this.dependents.get(dependency.key) ?? new Set() + current.add(context) + this.dependents.set(dependency.key, current) + } + } + + private replayRevisedDependents(): boolean { + const pending = [...this.revised] + const replayed = new Set() + for (let index = 0; index < pending.length; index++) { + const dependency = pending[index] + if (dependency === undefined) continue + for (const dependent of this.dependents.get(dependency.key) ?? []) { + if (replayed.has(dependent)) continue + replayed.add(dependent) + this.replayContext(dependent) + pending.push(dependent) + } + } + return replayed.size > 0 + } + + private readerFor( + beforeSeq: number, + dependencies: Map, + ): ConversationContextReader { + return { + previous: (kind: string): ConversationPreviousContext | undefined => { + const predecessor = this.previousContext(kind, beforeSeq) + dependencies.set(kind, { + kind, + key: predecessor?.key, + revision: predecessor?.revision, + windowGap: predecessor === undefined && this.hasMore, + }) + if (predecessor?.state === undefined) return undefined + const seq = startSeq(predecessor) + if (seq === undefined) return undefined + return { + key: predecessor.key, + kind: predecessor.kind, + id: predecessor.id, + startSeq: seq, + state: predecessor.state as Readonly, + matches: predecessor.matches, + } + }, + } + } + + private previousContext(kind: string, beforeSeq: number): InternalContext | undefined { + const candidates = this.contextsByKind.get(kind) ?? [] + const indexBefore = insertionIndex(candidates, beforeSeq) + for (let index = indexBefore - 1; index >= 0; index--) { + const candidate = candidates[index] + if (candidate?.state !== undefined) return candidate + } + return undefined + } + + /** Insert one newly discovered start into its Definition's ordered predecessor index. */ + private indexStartedContext(context: InternalContext): void { + const seq = context.startSeq + if (seq === undefined) return + const candidates = this.contextsByKind.get(context.kind) ?? [] + const previous = candidates.at(-1) + if (previous === undefined || (previous.startSeq as number) < seq) candidates.push(context) + else candidates.splice(insertionIndex(candidates, seq), 0, context) + this.contextsByKind.set(context.kind, candidates) + } + + private indexStartedContexts(kind: string, additions: readonly InternalContext[]): void { + if (additions.length === 0) return + const sorted = [...additions].sort((left, right) => + (left.startSeq as number) - (right.startSeq as number)) + const existing = this.contextsByKind.get(kind) ?? [] + const merged: InternalContext[] = [] + let before = 0 + let added = 0 + while (before < existing.length || added < sorted.length) { + const left = existing[before] + const right = sorted[added] + if (right === undefined || (left !== undefined && (left.startSeq as number) < (right.startSeq as number))) { + merged.push(left as InternalContext) + before++ + } else { + merged.push(right) + added++ + } + } + this.contextsByKind.set(kind, merged) + } + + private replayDependencies(): boolean { + let replayed = false + const ordered = [...this.contexts.values()] + .filter(context => startSeq(context) !== undefined) + .sort((left, right) => (startSeq(left) as number) - (startSeq(right) as number)) + for (const context of ordered) { + if (context.state === undefined || context.dependencies.size === 0) continue + const before = startSeq(context) + if (before === undefined) continue + let changed = false + for (const dependency of context.dependencies.values()) { + const current = this.previousContext(dependency.kind, before) + const windowGap = current === undefined && this.hasMore + if (current?.key !== dependency.key + || current?.revision !== dependency.revision + || windowGap !== dependency.windowGap) { + changed = true + break + } + } + if (changed) { + this.replayContext(context) + replayed = true + } + } + return replayed + } + + private refreshMatchLocations(changedSeqs: ReadonlySet): Set { + const affected = new Set() + if (changedSeqs.size === 0) return affected + for (const seq of changedSeqs) { + for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context) + } + for (const context of affected) { + 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 + }) + context.matches = matches + context.start = start + } + return affected + } + + private buildNode(context: InternalContext, target: string): ConversationViewNode | null { + const node = context.definition.buildViewNode(contextSnapshot(context), target) + if (node === null) return null + if (node.key !== context.key) { + throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`) + } + if (node.target !== target) { + throw new Error(`conversation Definition "${context.kind}" returned target "${node.target}" while building "${target}"`) + } + return node + } + + private buildLocationData( + context: InternalContext, + scope: ConversationLocationDataScope, + ): ConversationLocationData | null { + const build = context.definition.buildLocationData + if (build === undefined) return null + const data = build(contextSnapshot(context), scope) + if (data === null) return null + if (data.kind !== scope) { + throw new Error( + `conversation Definition "${context.kind}" published ${data.kind} data through its ${scope} scope`, + ) + } + if (data.key !== context.kind) { + throw new Error( + `conversation Definition "${context.kind}" published Location data key "${data.key}"; expected its owned kind`, + ) + } + if (!Number.isSafeInteger(data.turn) || data.turn < 0) { + throw new Error(`conversation Definition "${context.kind}" published invalid turn ${data.turn}`) + } + if (data.kind === 'step' && (!Number.isSafeInteger(data.step) || (data.step as number) < 0)) { + throw new Error(`conversation Definition "${context.kind}" published invalid step ${String(data.step)}`) + } + return data + } + + private replaceLocationData(): void { + const entries: { owner: string; data: ConversationLocationData }[] = [] + for (const scope of LOCATION_DATA_SCOPES) { + for (const context of this.contexts.values()) { + const data = this.buildLocationData(context, scope) + context.locationData[scope] = data + if (data !== null) entries.push({ owner: context.key, data }) + } + this.locationIndex.replaceData(entries) + } + } + + private applyDirtyLocationData(): boolean { + let changed = false + for (const scope of LOCATION_DATA_SCOPES) { + const changes: ConversationLocationDataChange[] = [] + for (const context of this.dirty) { + const previous = context.locationData[scope] + const next = this.buildLocationData(context, scope) + context.locationData[scope] = next + if (previous !== next) changes.push({ owner: context.key, previous, next }) + } + changed = this.locationIndex.applyData(changes) || changed + } + return changed + } + + private resetViewBuilders(): void { + this.views.clear() + for (const definition of this.viewDefinitions.entries()) { + const builder = definition.create() + this.views.set(definition.target, { + target: definition.target, + builder, + snapshot: builder.empty, + }) + } + this.replacePending = true + } +} + +function isLocationBoundary(type: string): boolean { + return type === 'turn/start' || type === 'turn/end' || type === 'step/start' || type === 'step/end' +} + +function requireState( + definition: ConversationNodeDefinition, + phase: 'start' | 'update', + state: unknown, +): unknown { + if (state === undefined) { + throw new Error(`conversation Definition "${definition.kind}" returned undefined from ${phase}()`) + } + return state +} + +/** Structural registry pair accepted by Session and SessionManager. */ +export interface ConversationRuntime { + readonly events: ConversationEventDefinitions & { subscribe(listener: () => void): () => void } + readonly views: ConversationViewDefinitions & { subscribe(listener: () => void): () => void } +} diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts new file mode 100644 index 0000000000..20fe3efdff --- /dev/null +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -0,0 +1,508 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationEventInput, ConversationLocation, ConversationLocationData, + ConversationLocationDataStore, ConversationStepDataMap, ConversationTimelineSnapshot, + ConversationTurnDataMap, StepLocation, TurnLocation, +} from '../contract/conversation.ts' + +interface OwnedLocationData { + readonly owner: string + readonly value: unknown +} + +/** One Context's previous and next Location-data publication. */ +export interface ConversationLocationDataChange { + readonly owner: string + readonly previous: ConversationLocationData | null + readonly next: ConversationLocationData | null +} + +class MutableLocationDataStore { + private entries = new Map() + + get(key: Key): unknown { + return this.entries.get(key)?.value + } + + remove(owner: string, key: string): boolean { + const current = this.entries.get(key) + if (current?.owner !== owner) return false + this.entries.delete(key) + return true + } + + set(owner: string, key: string, value: unknown): boolean { + const current = this.entries.get(key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${key}" is already owned by ${current.owner}`) + } + if (current?.value === value) return false + this.entries.set(key, { owner, value }) + return true + } + + replace(entries: ReadonlyMap): boolean { + let changed = this.entries.size !== entries.size + if (!changed) { + for (const [key, value] of entries) { + const current = this.entries.get(key) + if (current?.owner !== value.owner || current.value !== value.value) { + changed = true + break + } + } + } + if (changed) this.entries = new Map(entries) + return changed + } +} + +interface Coordinates { + readonly turn?: number + readonly step?: number + readonly session?: true +} + +interface StepDraft { + readonly turn: number + readonly step: number + firstSeq: number + start?: SessionEvent<'step/start'> + end?: SessionEvent<'step/end'> +} + +interface TurnDraft { + readonly turn: number + firstSeq: number + start?: SessionEvent<'turn/start'> + end?: SessionEvent<'turn/end'> + readonly steps: Map +} + +const SESSION_LOCATION = { kind: 'session' } as const +const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const + +function payloadCoordinates(event: SessionEvent): 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 + ? data.turn as number + : undefined + const step = Number.isSafeInteger(data.step) && (data.step as number) >= 0 + ? data.step as number + : undefined + return { ...turn === undefined ? {} : { turn }, ...step === undefined ? {} : { step } } +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function sameStep(left: StepLocation | undefined, right: StepLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data +} + +function sameTurn(left: TurnLocation | undefined, right: TurnLocation): boolean { + return left !== undefined + && left.start === right.start && left.end === right.end && left.status === right.status + && left.data === right.data && sameReferences(left.steps, right.steps) +} + +function sameLocation(left: ConversationLocation | undefined, right: ConversationLocation | undefined): boolean { + if (left === undefined || right === undefined || left.kind !== right.kind) return left === right + if (left.kind === 'session' || left.kind === 'unresolved') return true + if (right.kind === 'session' || right.kind === 'unresolved') return false + if (left.kind === 'turn' || right.kind === 'turn') { + return left.kind === 'turn' && right.kind === 'turn' && left.turn === right.turn + } + return left.turn === right.turn && left.step === right.step +} + +/** Session-owned Turn/Step timeline and event-to-Location index. */ +export class ConversationLocationIndex { + private coordinates = new Map() + private locations = new Map() + private seqsByTurn = new Map>() + private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() } + private readonly turnDataStores = new Map() + private readonly stepDataStores = new Map() + private currentTurn: number | undefined + private currentStep: number | undefined + + /** + * Return the current reference-stable timeline. + * @returns current timeline snapshot. + */ + snapshot(): ConversationTimelineSnapshot { + return this.timeline + } + + /** Replace all Definition-owned Location values while preserving reader identities. */ + replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean { + const turns = new Map>() + const steps = new Map>() + for (const { owner, data } of entries) { + const values = data.kind === 'turn' + ? turns.get(data.turn) ?? new Map() + : steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map() + const current = values.get(data.key) + if (current !== undefined && current.owner !== owner) { + throw new Error(`conversation Location data "${data.key}" is already owned by ${current.owner}`) + } + values.set(data.key, { owner, value: data.value }) + if (data.kind === 'turn') turns.set(data.turn, values) + else steps.set(stepDataKey(data.turn, requireStep(data)), values) + } + let changed = false + for (const turn of new Set([...this.turnDataStores.keys(), ...turns.keys()])) { + changed = this.mutableTurnData(turn).replace(turns.get(turn) ?? new Map()) || changed + } + for (const step of new Set([...this.stepDataStores.keys(), ...steps.keys()])) { + changed = this.mutableStepData(step).replace(steps.get(step) ?? new Map()) || changed + } + return changed + } + + /** Apply changed Context publications without rebuilding Turn/Step membership. */ + applyData(changes: readonly ConversationLocationDataChange[]): boolean { + let changed = false + for (const change of changes) { + const previous = change.previous + if (previous === null) continue + changed = this.storeFor(previous).remove(change.owner, previous.key) || changed + } + for (const change of changes) { + const next = change.next + if (next === null) continue + changed = this.storeFor(next).set(change.owner, next.key, next.value) || changed + } + return changed + } + + /** + * Resolve the latest Location for one event. + * @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 { + return this.locations.get(event.seq) ?? SESSION_LOCATION + } + + /** + * Rebuild timeline facts after replace/prepend or a boundary append. + * @param entries - complete current window in ascending seq order. + * @returns seqs whose resolved Location changed. + */ + rebuild(entries: readonly ConversationEventInput[]): ReadonlySet { + const previousLocations = this.locations + const turns = new Map() + const coordinates = new Map() + let currentTurn: number | undefined + let currentStep: number | undefined + + const turnDraft = (turn: number, seq: number): TurnDraft => { + let draft = turns.get(turn) + if (draft === undefined) { + draft = { turn, firstSeq: seq, steps: new Map() } + turns.set(turn, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + const stepDraft = (turn: number, step: number, seq: number): StepDraft => { + const owner = turnDraft(turn, seq) + let draft = owner.steps.get(step) + if (draft === undefined) { + draft = { turn, step, firstSeq: seq } + owner.steps.set(step, draft) + } else { + draft.firstSeq = Math.min(draft.firstSeq, seq) + } + return draft + } + + for (const { event } of entries) { + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + currentTurn = event.data.turn + currentStep = undefined + } + if (event.type === 'step/start') { + currentTurn = event.data.turn + currentStep = event.data.step + } + if (explicit.session !== true && explicit.turn !== undefined) { + if (currentTurn !== explicit.turn) currentStep = undefined + currentTurn = explicit.turn + if (explicit.step !== undefined) currentStep = explicit.step + } + const turn = explicit.session === true ? undefined : explicit.turn ?? currentTurn + const step = explicit.session === true || event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turn === currentTurn ? currentStep : undefined) + coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) turnDraft(turn, event.seq) + if (turn !== undefined && step !== undefined) stepDraft(turn, step, event.seq) + + if (event.type === 'turn/start') { + turnDraft(event.data.turn, event.seq).start = event + } else if (event.type === 'turn/end') { + turnDraft(event.data.turn, event.seq).end = event + } else if (event.type === 'step/start') { + stepDraft(event.data.turn, event.data.step, event.seq).start = event + } else if (event.type === 'step/end') { + stepDraft(event.data.turn, event.data.step, event.seq).end = event + } + + if (event.type === 'step/end' && currentTurn === event.data.turn && currentStep === event.data.step) { + currentStep = undefined + } + if (event.type === 'turn/end' && currentTurn === event.data.turn) { + currentTurn = undefined + currentStep = undefined + } + } + + const previousTurns = this.timeline.turns + const nextTurns = new Map() + const orderedDrafts = [...turns.values()].sort((left, right) => left.firstSeq - right.firstSeq) + for (const draft of orderedDrafts) { + const previousTurn = previousTurns.get(draft.turn) + const previousSteps = new Map(previousTurn?.steps.map(step => [step.step, step]) ?? []) + const steps = [...draft.steps.values()] + .sort((left, right) => left.firstSeq - right.firstSeq) + .map((candidate): StepLocation => { + const value: StepLocation = { + turn: candidate.turn, + step: candidate.step, + start: candidate.start, + end: candidate.end, + status: candidate.end !== undefined + ? 'closed' + : candidate.start === undefined ? 'unknown' : 'open', + data: this.stepData(candidate.turn, candidate.step), + } + const previous = previousSteps.get(candidate.step) + return sameStep(previous, value) ? previous as StepLocation : value + }) + const value: TurnLocation = { + turn: draft.turn, + start: draft.start, + end: draft.end, + status: draft.end !== undefined ? 'closed' : draft.start === undefined ? 'unknown' : 'open', + steps, + data: this.turnData(draft.turn), + } + nextTurns.set(draft.turn, sameTurn(previousTurn, value) ? previousTurn as TurnLocation : value) + } + + const nextOrder = orderedDrafts.map(draft => draft.turn) + const turnOrder = this.timeline.turnOrder.length === nextOrder.length + && this.timeline.turnOrder.every((turn, index) => turn === nextOrder[index]) + ? this.timeline.turnOrder + : nextOrder + let sameMap = previousTurns.size === nextTurns.size + if (sameMap) { + for (const [turn, value] of nextTurns) { + if (previousTurns.get(turn) !== value) { + sameMap = false + break + } + } + } + this.timeline = sameMap && turnOrder === this.timeline.turnOrder + ? this.timeline + : { turnOrder, turns: nextTurns } + this.coordinates = coordinates + this.locations = new Map() + this.seqsByTurn = new Map() + for (const { event } of entries) { + const coordinates = this.coordinates.get(event.seq) + if (coordinates?.turn !== undefined) this.indexTurnSeq(coordinates.turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + this.currentTurn = currentTurn + this.currentStep = currentStep + + const changed = new Set() + for (const { event } of entries) { + if (!sameLocation(previousLocations.get(event.seq), this.locations.get(event.seq))) { + changed.add(event.seq) + } + } + return changed + } + + /** + * Append one Turn/Step boundary while revisiting only the owning Turn. + * @param event - contiguous tail boundary event. + * @returns seqs whose immutable Location reference changed. + */ + appendBoundary(event: SessionEvent): ReadonlySet { + if (event.type !== 'turn/start' && event.type !== 'turn/end' + && event.type !== 'step/start' && event.type !== 'step/end') { + throw new Error(`conversation Location boundary expected, received ${event.type}`) + } + + const explicit = payloadCoordinates(event) + if (event.type === 'turn/start') { + this.currentTurn = event.data.turn + this.currentStep = undefined + } else if (event.type === 'step/start') { + this.currentTurn = event.data.turn + this.currentStep = event.data.step + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turnNumber = explicit.turn ?? this.currentTurn + if (turnNumber === undefined) throw new Error(`conversation boundary ${event.type} has no turn`) + const stepNumber = event.type === 'turn/start' || event.type === 'turn/end' + ? undefined + : explicit.step ?? (turnNumber === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + turn: turnNumber, + ...stepNumber === undefined ? {} : { step: stepNumber }, + }) + this.indexTurnSeq(turnNumber, event.seq) + + const previousTurn = this.timeline.turns.get(turnNumber) + let steps = previousTurn?.steps ?? [] + if (event.type === 'step/start' || event.type === 'step/end') { + const number = event.data.step + const previousStep = steps.find(candidate => candidate.step === number) + const candidate: StepLocation = { + turn: turnNumber, + step: number, + start: event.type === 'step/start' ? event : previousStep?.start, + end: event.type === 'step/end' ? event : previousStep?.end, + status: event.type === 'step/end' || previousStep?.end !== undefined ? 'closed' : 'open', + data: this.stepData(turnNumber, number), + } + const nextStep = sameStep(previousStep, candidate) ? previousStep as StepLocation : candidate + const index = steps.findIndex(step => step.step === number) + steps = index < 0 + ? [...steps, nextStep] + : steps.map((step, at) => at === index ? nextStep : step) + } + const candidate: TurnLocation = { + turn: turnNumber, + start: event.type === 'turn/start' ? event : previousTurn?.start, + end: event.type === 'turn/end' ? event : previousTurn?.end, + status: event.type === 'turn/end' || previousTurn?.end !== undefined + ? 'closed' + : event.type === 'turn/start' || previousTurn?.start !== undefined ? 'open' : 'unknown', + steps, + data: this.turnData(turnNumber), + } + const turn = sameTurn(previousTurn, candidate) ? previousTurn as TurnLocation : candidate + const turns = new Map(this.timeline.turns) + turns.set(turnNumber, turn) + const turnOrder = previousTurn === undefined + ? [...this.timeline.turnOrder, turnNumber] + : this.timeline.turnOrder + this.timeline = { turnOrder, turns } + + const changed = new Set() + for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { + const previous = this.locations.get(seq) + const next = this.resolve(seq) + this.locations.set(seq, next) + if (!sameLocation(previous, next)) changed.add(seq) + } + + if (event.type === 'step/end' && this.currentTurn === event.data.turn && this.currentStep === event.data.step) { + this.currentStep = undefined + } + if (event.type === 'turn/end' && this.currentTurn === event.data.turn) { + this.currentTurn = undefined + this.currentStep = undefined + } + return changed + } + + /** + * Index one non-boundary tail event without rescanning the window. + * @param event - contiguous appended event. + */ + appendNonBoundary(event: SessionEvent): void { + const explicit = payloadCoordinates(event) + if (explicit.session === true) { + this.coordinates.set(event.seq, {}) + this.locations.set(event.seq, SESSION_LOCATION) + return + } + if (explicit.turn !== undefined) { + if (this.currentTurn !== explicit.turn) this.currentStep = undefined + this.currentTurn = explicit.turn + if (explicit.step !== undefined) this.currentStep = explicit.step + } + const turn = explicit.turn ?? this.currentTurn + const step = explicit.step ?? (turn === this.currentTurn ? this.currentStep : undefined) + this.coordinates.set(event.seq, { + ...turn === undefined ? {} : { turn }, + ...turn === undefined || step === undefined ? {} : { step }, + }) + if (turn !== undefined) this.indexTurnSeq(turn, event.seq) + this.locations.set(event.seq, this.resolve(event.seq)) + } + + private indexTurnSeq(turn: number, seq: number): void { + const current = this.seqsByTurn.get(turn) ?? new Set() + current.add(seq) + this.seqsByTurn.set(turn, current) + } + + private turnData(turn: number): ConversationLocationDataStore { + return this.mutableTurnData(turn) as ConversationLocationDataStore + } + + private stepData(turn: number, step: number): ConversationLocationDataStore { + return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore + } + + private mutableTurnData(turn: number): MutableLocationDataStore { + const current = this.turnDataStores.get(turn) ?? new MutableLocationDataStore() + this.turnDataStores.set(turn, current) + return current + } + + private mutableStepData(key: string): MutableLocationDataStore { + const current = this.stepDataStores.get(key) ?? new MutableLocationDataStore() + this.stepDataStores.set(key, current) + return current + } + + private storeFor(data: ConversationLocationData): MutableLocationDataStore { + return data.kind === 'turn' + ? this.mutableTurnData(data.turn) + : this.mutableStepData(stepDataKey(data.turn, requireStep(data))) + } + + private resolve(seq: number): ConversationLocation { + const coordinates = this.coordinates.get(seq) + if (coordinates?.turn === undefined) return SESSION_LOCATION + const turn = this.timeline.turns.get(coordinates.turn) + if (turn === undefined) return UNRESOLVED_LOCATION + if (coordinates.step === undefined) return { kind: 'turn', turn } + const step = turn.steps.find(candidate => candidate.step === coordinates.step) + return step === undefined ? { kind: 'turn', turn } : { kind: 'step', turn, step } + } +} + +function stepDataKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function requireStep(data: ConversationLocationData): number { + if (data.kind === 'step' && data.step !== undefined) return data.step + throw new Error(`conversation Step data "${data.key}" requires a step`) +} diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2681bb33f3..99daa7c8a5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -13,6 +13,9 @@ import type { } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' +import type { + ChatConversationViewNode, ConversationTimelineSnapshot, +} from '../contract/conversation.ts' export type { TodoItem } /** Request configuration recorded for one provider call. */ @@ -206,7 +209,7 @@ export interface CompactionSummaryNode { * Fallback for surface events this UI version does not know: the documented * default arm of `SessionEventMap`, which is merge-extensible, so the * projection's switch cannot end in `assertNever`. No event produces this node - * today — `isAppendSurfaceEvent` admits only the four types in core's + * today — `isAppendSurfaceEvent` admits only the three types in core's * `SurfaceEventType`, and each has its own arm — and it exists so widening that * set core-side degrades to a raw row instead of dropping the event silently. */ @@ -222,8 +225,8 @@ export interface UnknownSurfaceNode { /** * One slash-command lifecycle folded from the log-only `command/run` / * `command/done` pair (paired by commandId, mirroring tool call↔result). - * Log-only events are not surface events, so the TranscriptAdapter indexes - * them separately and merges the nodes into the flow by seq. A window cut + * Log-only events are not surface events, so the command Definition indexes + * them separately and the Chat builder orders the resulting node by seq. A window cut * between the pair soft-falls like tool pairs: a done with no in-window run * still builds a node (name/args null), and a run with no done renders as * still executing. @@ -311,21 +314,19 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' * Input-area shape of an OPEN session, derived at snapshot assembly (the one * place that knows the predicate — consumers switch, never re-derive): * - * - `blank`: no activity ever (no nodes, no partial, not running, no pending - * waits, no prompt attempt) — the UI renders the blank-session guidance - * hero. - * - `engaging`: the first prompt was initiated but no content landed yet — - * the UI holds the composer through the accept → running → first-event - * frames. Entered synchronously before prompt()'s first await. - * - `active`: content exists (nodes, partial, running turn, or pending - * waits) — the ordinary conversation view. + * - `blank`: the authoritative blank bit is still set and no prompt was + * attempted — the UI renders the blank-session guidance hero. + * - `engaging`: a first prompt was attempted, but no accepted turn or other + * authoritative activity signal has arrived — the UI keeps the composer + * visible through admission and error frames. + * - `active`: the session is non-blank beyond its pending first prompt, is + * running, or owns a pending interaction — the ordinary conversation view. * - * Monotone within a session object: blank → engaging → active, no returns. * A failed first prompt stays `engaging` (composer + error strip — retry - * semantics; bouncing back to the hero would discard the error context). + * semantics; returning to the hero would discard the error context). * Sessions whose window is not open (`loading`/`error`) are outside phase * jurisdiction: consumers branch on {@link ConversationSnapshot.openState} - * first (phase still reports `active`-ish facts but must not be rendered). + * first. */ export type ComposerPhase = 'blank' | 'engaging' | 'active' @@ -335,10 +336,70 @@ export interface PromptError { error: RpcError } +/** Stable per-key reader for final Chat view Nodes. */ +export interface ChatNodeStore { + /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ + get(key: string): ChatConversationViewNode | undefined + /** @returns all currently materialized Nodes without imposing render order. */ + values(): readonly ChatConversationViewNode[] +} + +/** Stable per-Location membership index for turn-local and step-local consumers. */ +export interface ChatLocationNodeIndex { + /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ + getTurn(turn: number): readonly string[] + /** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */ + getStep(turn: number, step: number): readonly string[] +} + +/** Temporary projection consumed by Trajectory and unmigrated readers. */ +export interface LegacyConversationSlice { + readonly nodes: readonly ConversationNode[] + readonly turnTimings: ReadonlyMap + readonly turnEnds: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +/** Incremental Chat target snapshot: stable keyed stores plus structural order. */ +export interface ChatSnapshot { + readonly order: readonly string[] + readonly nodes: ChatNodeStore + readonly locations: ChatLocationNodeIndex + readonly timeline: ConversationTimelineSnapshot + readonly legacy: LegacyConversationSlice +} + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } + +/** Empty Chat target used before a view builder is registered. */ +export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { + order: EMPTY_LIST, + nodes: { + get: () => undefined, + values: () => EMPTY_LIST, + }, + locations: { + getTurn: () => EMPTY_LIST, + getStep: () => EMPTY_LIST, + }, + timeline: EMPTY_TIMELINE, + legacy: { + nodes: EMPTY_LIST, + turnTimings: new Map(), + turnEnds: new Map(), + partial: null, + runningCalls: EMPTY_LIST, + }, +} + /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ + /** Final Chat target assembled from independently registered business Definitions. */ + chat: ChatSnapshot + /** Legacy Trajectory slice derived from the registered Chat Definitions. */ nodes: readonly ConversationNode[] /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ turnTimings: ReadonlyMap diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..b4eec9d6df 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -10,6 +10,7 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' import { flattenLineage } from './lineage.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -158,6 +159,7 @@ export class SessionManager { private readonly api: IApiClient, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, + private readonly conversation?: ConversationRuntime, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -282,7 +284,12 @@ export class SessionManager { const address = this.addresses.get(sessionId) const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries .find(entry => entry.kind === 'child' && entry.id === sessionId) - if (child?.kind === 'child') session.handleRunning(child.activity === 'running') + if (child?.kind === 'child') { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false) + session.handleRunning(child.activity === 'running') + } } } return session @@ -301,9 +308,15 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...this.conversation === undefined ? {} : { conversation: this.conversation }, }) } + /** Rebuild every resident Session after one coalesced registry transaction. */ + rebuildConversationRegistry(): void { + for (const session of this.sessions.values()) session.rebuildConversationRegistry() + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) diff --git a/packages/client/runtime/src/client/sessions/partial.ts b/packages/client/runtime/src/client/sessions/partial.ts index 31a04124cd..599ea61c3d 100644 --- a/packages/client/runtime/src/client/sessions/partial.ts +++ b/packages/client/runtime/src/client/sessions/partial.ts @@ -48,7 +48,7 @@ export class PartialAccumulator { push(chunk: StreamChunk): boolean { switch (chunk.type) { case 'block-start': { - this.blocks[chunk.index] = emptyBlock(chunk.blockType) + this.blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) this.changed = true return true } @@ -102,7 +102,12 @@ export class PartialAccumulator { } } -function emptyBlock(blockType: string): AssistantBlock { +/** + * Create the empty client projection for one streamed Assistant block kind. + * @param blockType - wire block kind. + * @returns empty projected block ready to receive deltas. + */ +export function emptyAssistantBlock(blockType: string): AssistantBlock { switch (blockType) { case 'text': return { kind: 'text', text: '' } case 'reasoning': return { kind: 'reasoning', text: '' } diff --git a/packages/client/runtime/src/client/sessions/queue-mirror.ts b/packages/client/runtime/src/client/sessions/queue-mirror.ts new file mode 100644 index 0000000000..be7351cf5f --- /dev/null +++ b/packages/client/runtime/src/client/sessions/queue-mirror.ts @@ -0,0 +1,74 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MuxFrame } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { QueuedMessage } from './conversation.ts' + +const QUEUE_PREVIEW_CHARS = 200 + +function previewOf(content: readonly ContentBlock[]): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + +type QueueItems = Extract['items'] + +/** Authoritative transient queue projection and durable steering handoff. */ +export class SessionQueueMirror { + private current: readonly QueuedMessage[] = [] + + /** + * Return the current immutable queue projection. + * @returns current queue rows. + */ + snapshot(): readonly QueuedMessage[] { + return this.current + } + + /** + * Drop the stale generation before its replacement queue baseline arrives. + * @returns whether any projected queue row was removed. + */ + reset(): boolean { + if (this.current.length === 0) return false + this.current = [] + return true + } + + /** + * Replace from one authoritative stream queue frame. + * @param items - complete host queue snapshot. + */ + replace(items: QueueItems): void { + this.current = items.map(item => ({ + id: item.id, + messageId: item.message.id, + placement: item.placement, + content: item.message.content, + preview: previewOf(item.message.content), + text: textOf(item.message.content), + })) + } + + /** + * Retire a transient steering row once its durable message enters the log. + * @param event - newly contiguous durable Session event. + * @returns whether the projection changed. + */ + acceptDurable(event: SessionEvent): boolean { + if (event.type !== 'user/message') return false + const messageId = event.data.id + const index = this.current.findIndex(item => + item.placement === 'steering' && item.messageId === messageId) + if (index < 0) return false + this.current = this.current.filter((_item, candidate) => candidate !== index) + return true + } +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 64eeaf91d7..dcb5f71610 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -31,6 +31,7 @@ import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' import { SessionManager } from './manager.ts' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { PendingInteractionStatus } from './pending.ts' @@ -259,16 +260,30 @@ export class SessionsService implements ISessions { /** * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. + * @param conversationRuntime - same-pass registry instances, when runtime apply owns them. */ constructor( private readonly rootCtx: Context, api: IApiClient, + conversationRuntime?: ConversationRuntime, ) { this.selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }) const restored = this.selection.getSnapshot() - this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress) + const conversationEvents = rootCtx.get('conversationEvents') + const conversationViews = rootCtx.get('conversationViews') + const conversation = conversationRuntime ?? ( + conversationEvents === undefined || conversationViews === undefined + ? undefined + : { events: conversationEvents, views: conversationViews } + ) + this.manager = new SessionManager( + api, + restored.sessionId, + restored.subagentAddress, + conversation, + ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', subagentsByParent: {}, currentAddress: undefined, @@ -296,6 +311,25 @@ export class SessionsService implements ISessions { resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current), }) this.currentProvideInfo = this.provideChannel.currentProvideInfo + let registryRebuildQueued = false + const scheduleRegistryRebuild = (): void => { + if (registryRebuildQueued) return + registryRebuildQueued = true + queueMicrotask(() => { + registryRebuildQueued = false + this.manager.rebuildConversationRegistry() + }) + } + if (conversation !== undefined) { + rootCtx.effect(() => { + const disposeEvents = conversation.events.subscribe(scheduleRegistryRebuild) + const disposeViews = conversation.views.subscribe(scheduleRegistryRebuild) + return () => { + disposeEvents() + disposeViews() + } + }, 'sessions: conversation registry rebuild') + } rootCtx.reflect.provide('sessions', this, undefined) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0813c02711..a533f9dd6f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,7 +2,6 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, @@ -12,27 +11,23 @@ import type { // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' +import { ConversationNodeAssembler } from './conversation-assembler.ts' +import type { ConversationRuntime } from './conversation-assembler.ts' +import type { ConversationEventInput, ConversationPublication } from '../contract/conversation.ts' import type { - ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, - OpenState, PromptError, QueuedMessage, RunningToolCall, + ChatSnapshot, ComposerPhase, ConversationSnapshot, OpenState, PromptError, } from './conversation.ts' +import { EMPTY_CHAT_SNAPSHOT } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' -import { TranscriptAdapter } from './transcript-adapter.ts' -import { displayFailureMessage } from './failure-display.ts' import { Notifier } from './notifier.ts' -import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts' import { ProjectionValueStore } from './projection-store.ts' import type { ProjectionsBaseline } from './projection-store.ts' -import { ToolCallTree } from './tool-call-tree.ts' +import { SessionQueueMirror } from './queue-mirror.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 -// Browser bundles cannot value-import the host timeout library. This protocol -// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. -const MAX_RETRY_DELAY_MS = 2_147_483_647 - /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ @@ -54,24 +49,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore -} - -/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ -const QUEUE_PREVIEW_CHARS = 200 - -/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ -function queuePreviewOf(content: readonly ContentBlock[]): string { - const flat = content - .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) - .join(' ').replace(/\s+/g, ' ').trim() - const chars = Array.from(flat) - return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat -} - -/** Recover complete composer text only when editing cannot discard non-text blocks. */ -function queueTextOf(content: readonly ContentBlock[]): string | null { - if (!content.every(block => block.type === 'text')) return null - return content.map(block => block.text).join('') + /** Runtime registries used by this Session-owned Conversation assembler. */ + conversation?: ConversationRuntime } /** @@ -96,42 +75,13 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false - private readonly transcript = new TranscriptAdapter() - private partial: PartialAccumulator | null = null - private openCalls = new Map() - /** Last entered step per turn, folded from step/start for terminal error placement. */ - private lastStepByTurn = new Map() - /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. - * Derived from window events and rebuilt with partial/openCalls; the transcript is - * seq-monotonic, so a plain seq merge preserves event order. */ - private derivedNodes: ConversationNode[] = [] private pending = new Map() - // Revision counters preserve array identity when derived content is unchanged, so - // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every - // tool card and pending card). Mutation sites bump the matching revision. partial needs no - // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. - private callsRev = 0 - private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - private derivedRev = 0 - private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null - /** Exact turn timing retained from the raw window so presentation never - * infers elapsed time from transcript content. */ - private turnTimings = new Map() - private turnTimingsRev = 0 - private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null - /** Completed turn boundaries retained from the raw window so presentation - * actions never infer a safe fork point from transcript content alone. */ - private turnEnds = new Map() - private turnEndsRev = 0 - private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ - private queued: QueuedMessage[] = [] - private queueRev = 0 - private queueCache: { rev: number; value: QueuedMessage[] } | null = null - /** Window-derived child-call lifecycle and immutable tree projection. */ - private readonly toolCallTree = new ToolCallTree() + private readonly queueMirror = new SessionQueueMirror() + /** Session-owned business Context engine over the contiguous raw window. */ + private readonly conversation: ConversationNodeAssembler private running = false private address: SubagentAddress | undefined private parentAvailable = false @@ -141,8 +91,10 @@ export class Session implements SessionFace { * engaging edge of the phase machine (see ComposerPhase). */ private promptAttempted = false - /** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */ - private blankBit = false + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false + /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + private blankBit = true private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null @@ -167,9 +119,7 @@ export class Session implements SessionFace { readonly projections: ProjectionValueStore private snapshotCache: ConversationSnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) + private readonly notifier: Notifier /** * Agent-scoped cordis context, bound once by SessionsService when it * mints the scope (the client mirror of the host Agent's loopCtx). The @@ -192,6 +142,16 @@ export class Session implements SessionFace { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address this.parentAvailable = options.parentAvailable ?? false + this.conversation = options.conversation === undefined + ? new ConversationNodeAssembler( + { entries: () => [], fallbackEntry: () => undefined }, + { entries: () => [] }, + ) + : new ConversationNodeAssembler(options.conversation.events, options.conversation.views) + this.notifier = new Notifier(() => { + this.conversation.flush() + this.snapshotCache = this.buildSnapshot() + }) this.snapshotCache = this.buildSnapshot() } @@ -228,6 +188,7 @@ export class Session implements SessionFace { // visible on the session area's very first frame when a caller sends // ahead of navigation (first-send flow). this.promptAttempted = true + if (this.blankBit) this.firstPromptPendingTurn = true this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { @@ -375,6 +336,7 @@ export class Session implements SessionFace { const older = result.value.events if (older.length === 0) { this.hasMore = result.value.hasMore + this.conversation.prepend([], this.hasMore) return } const tail = older[older.length - 1] @@ -389,8 +351,7 @@ export class Session implements SessionFace { /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head) - this.rebuildDerivedFromWindow() + this.conversation.prepend(older.map(conversationInput), this.hasMore) } catch (error) { console.error('[web-runtime] loadOlder failed:', error) } finally { @@ -461,15 +422,7 @@ export class Session implements SessionFace { return } case 'session/queue': { - this.queued = frame.items.map(item => ({ - id: item.id, - messageId: item.message.id, - placement: item.placement, - content: item.message.content, - preview: queuePreviewOf(item.message.content), - text: queueTextOf(item.message.content), - })) - this.queueRev++ + this.queueMirror.replace(frame.items) this.notifier.markDirty() return } @@ -479,11 +432,7 @@ export class Session implements SessionFace { // snapshot AFTER the subscribed frame on the same stream, so the // stale mirror clears here — race-free against onConnected/resync // timing (clearing there could wipe a baseline that already landed). - if (this.queued.length > 0) { - this.queued = [] - this.queueRev++ - this.notifier.markDirty() - } + if (this.queueMirror.reset()) this.notifier.markDirty() return } case 'approval/requested': { @@ -527,6 +476,7 @@ export class Session implements SessionFace { this.blankBit = false this.notifier.markDirty() } + if (running) this.firstPromptPendingTurn = false if (this.running === running) return this.running = running this.notifier.markDirty() @@ -590,6 +540,11 @@ export class Session implements SessionFace { /** No-op because session instances remain resident. */ dispose(): void {} + /** Rebuild the current window after a low-frequency Definition or view registration change. */ + rebuildConversationRegistry(): void { + this.scheduleConversation(this.conversation.rebuildRegistry()) + } + // ---- 私有 ---- /** Requested-frame arrival: the wait enters the pending map under its own key. */ @@ -651,8 +606,8 @@ export class Session implements SessionFace { this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore - this.transcript.reset(this.events, this.views) - this.rebuildDerivedFromWindow() + if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false + this.conversation.replaceWindow(entries.map(conversationInput), hasMore) if (projections !== undefined) this.projections.seed(projections) const buffered = this.liveBuffer this.liveBuffer = [] @@ -661,32 +616,22 @@ export class Session implements SessionFace { } /** Seq-guarded append shared by stitching and the open-state live path. */ - private appendLive(event: SessionEvent, view?: ToolEventView): void { + private appendLive(event: SessionEvent, view?: ToolEventView): ConversationPublication { const tailSeq = this.windowTailSeq() - if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop + if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop this.events.push(event) this.views.push(view) - this.transcript.append(event, view) - this.handoffPendingSteering(event) - this.applyEventSideEffects(event, view) - } - - /** Retire the first matching live steering occurrence when its durable message takes over. */ - private handoffPendingSteering(event: SessionEvent): void { - if (event.type !== 'user/message') return - const message = event.data - const index = this.queued.findIndex(item => - item.placement === 'steering' && item.messageId === message.id) - if (index === -1) return - this.queued = this.queued.filter((_item, candidate) => candidate !== index) - this.queueRev++ + if (event.type === 'turn/start') this.firstPromptPendingTurn = false + const queueChanged = this.queueMirror.acceptDurable(event) + const publication = this.conversation.append({ event, view }) + return queueChanged ? 'immediate' : publication } /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; * a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an * expected reconnect-window artifact, repaired by refetch). The window stays one contiguous - * raw range, which is what lets the transcript render every event between its ends and lets a - * compaction checkpoint find its cited summary event. */ + * raw range, which lets Conversation Definitions correlate every recorded event between its + * ends and lets a compaction checkpoint resolve its cited summary event. */ private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) @@ -699,12 +644,13 @@ export class Session implements SessionFace { void this.repairGap() return } - this.appendLive(event, view) - if (event.type === 'assistant/chunk') { - if (isVisibleAssistantChunk(event.data.chunk.type)) this.notifier.markFrameDirty() - return - } - this.notifier.markDirty() + this.scheduleConversation(this.appendLive(event, view)) + } + + /** Route assembler cadence into the Session's existing microtask/RAF notifier. */ + private scheduleConversation(publication: ConversationPublication): void { + if (publication === 'immediate') this.notifier.markDirty() + else if (publication === 'animation-frame') this.notifier.markFrameDirty() } /** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared @@ -728,238 +674,35 @@ export class Session implements SessionFace { } } - /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk/retry projection and openCalls add-remove. */ - private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { - const eventType = event.type as string - if (eventType === 'llm/retry') { - const data = parseRetryEventData(event.data) - if (data === null) { - console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) - return - } - if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { - this.partial = null - } - this.derivedNodes.push({ - kind: 'model-retry', - seq: event.seq, - time: event.time, - retryState: 'scheduled', - ...data, - }) - this.derivedRev++ - return - } - // These lifecycle events are declared by a host-only plugin whose Context - // types cannot enter the client program. ToolCallTree owns their structural - // wire narrowing, pairing, and nested snapshot projection. - if (this.toolCallTree.apply(event)) return - switch (event.type) { - case 'turn/start': - this.lastStepByTurn.set(event.data.turn, 0) - this.turnTimings.set(event.data.turn, { startTime: event.time }) - this.turnTimingsRev++ - return - case 'step/start': - this.lastStepByTurn.set(event.data.turn, event.data.step) - return - case 'assistant/chunk': { - const { turn, step, chunk } = event.data - this.settleScheduledRetry('started', turn) - if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { - this.partial = new PartialAccumulator(turn, step) - } - this.partial.push(chunk) - return - } - case 'assistant/message': { - if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) { - this.partial = null // finalize swaps in place (same notification batch, no flicker) - } - return - } - case 'tool/call': { - this.openCalls.set(String(event.data.callId), { - callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, time: event.time, - callView: view?.for === 'call' ? view.view : null, - subCalls: [], - }) - this.callsRev++ - return - } - case 'tool/result': { - if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++ - return - } - case 'turn/end': { - const lastStep = this.lastStepByTurn.get(event.data.turn) ?? 0 - const timing = this.turnTimings.get(event.data.turn) - if (timing !== undefined) { - this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time }) - this.turnTimingsRev++ - } - this.turnEnds.set(event.data.turn, event.seq) - this.turnEndsRev++ - if (event.data.reason.kind === 'aborted') { - this.settleScheduledRetry('cancelled', event.data.turn) - } - if ( - event.data.reason.kind === 'error' - && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn) - ) { - const failure = event.data.reason.error - this.derivedNodes.push({ - kind: 'turn-error', - seq: event.seq, - time: event.time, - turn: event.data.turn, - step: lastStep, - message: displayFailureMessage(failure), - code: failure.code, - }) - this.derivedRev++ - } - if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn) - // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it - // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. - // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node - // from the logged chunks. Content-free partials are dropped outright. - if (this.partial !== null && this.partial.turn === event.data.turn) { - const { blocks } = this.partial.toPartial() - const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) - if (visible) { - // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. - this.derivedNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, time: event.time, - turn: this.partial.turn, step: this.partial.step, - blocks, interrupted: true, - }) - this.derivedRev++ - } - this.partial = null - } - let callOffset = 0 - for (const [callId, call] of this.openCalls) { - if (call.turn !== event.data.turn) continue - this.openCalls.delete(callId) - this.callsRev++ - // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). - this.derivedNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, - callId, - call: { name: call.name, argsRaw: call.argsRaw }, - callTime: call.time, - content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, resultView: null, subCalls: [], - }) - this.derivedRev++ - } - this.lastStepByTurn.delete(event.data.turn) - return - } - default: - return - } - } - - /** - * Settle the newest scheduled retry, optionally restricted to its failed turn. - * @param retryState - next client projection state to publish. - * @param turn - failed turn required for cancellation; omitted for the next retry turn start. - */ - private settleScheduledRetry( - retryState: Exclude, - turn?: number, - ): void { - const index = this.derivedNodes.findLastIndex(node => - node.kind === 'model-retry' - && node.retryState === 'scheduled' - && (turn === undefined || node.turn === turn)) - if (index < 0) return - const node = this.derivedNodes[index] - /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ - if (node?.kind !== 'model-retry') return - this.derivedNodes[index] = { ...node, retryState } - this.derivedRev++ - } - - /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps - * paging/stitching consistent, and makes live handling and history replay converge on the same - * retry notices and interrupted nodes. */ - private rebuildDerivedFromWindow(): void { - this.partial = null - this.openCalls.clear() - this.lastStepByTurn.clear() - this.callsRev++ - this.derivedNodes = [] - this.derivedRev++ - this.turnTimings = new Map() - this.turnTimingsRev++ - this.turnEnds = new Map() - this.turnEndsRev++ - this.toolCallTree.reset() - for (let i = 0; i < this.events.length; i++) { - const event = this.events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event !== undefined) this.applyEventSideEffects(event, this.views[i]) - } - } - private windowTailSeq(): number | null { const tail = this.events[this.events.length - 1] return tail === undefined ? null : tail.seq } private buildSnapshot(): ConversationSnapshot { - const projected = this.transcript.nodes() - // Derived interruption nodes ride fractional seqs while retry notices keep their event seq. - // The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the - // merge on (projected reference, derivedRev) to retain identity across unrelated swaps. - let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) { - nodes = this.nodesCache.value - } else { - nodes = this.derivedNodes.length === 0 - ? projected - : [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes } - } - if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { - this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } - } - if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) { - this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) } - } - if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { - this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } - } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } - if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { - this.queueCache = { rev: this.queueRev, value: this.queued } - } - const partial = this.partial?.toPartial() ?? null + const chat = (this.conversation.snapshot('chat') as ChatSnapshot | undefined) ?? EMPTY_CHAT_SNAPSHOT + const legacy = chat.legacy return { sessionId: this.sessionId, - nodes: this.toolCallTree.projectNodes(nodes), - turnTimings: this.turnTimingsCache.value, - turnEnds: this.turnEndsCache.value, - partial, - runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value), + chat, + nodes: legacy.nodes, + turnTimings: legacy.turnTimings, + turnEnds: legacy.turnEnds, + partial: legacy.partial, + runningCalls: legacy.runningCalls, pending: this.pendingCache.value, - queue: this.queueCache.value, + queue: this.queueMirror.snapshot(), running: this.running, subagent: this.address === undefined ? null : { address: this.address, parentAvailable: this.parentAvailable }, composerPhase: derivePhase( - // Command lifecycle nodes are not conversation: running /permission - // or /plan on a fresh session keeps the hero (the client mirror of - // the host's no-turn sessionBlank predicate). - nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0, + (!this.blankBit && !this.firstPromptPendingTurn) + || this.running + || this.pendingCache.value.length > 0, this.promptAttempted, ), removed: this.removed, @@ -985,67 +728,18 @@ export class Session implements SessionFace { } } -/** Validate the plugin-owned payload at the session-event wire boundary. */ -function parseRetryEventData(value: unknown): LlmRetryEventData | null { - if (value === null || typeof value !== 'object') return null - const data = value as Record - const failure = data.failure - if (failure === null || typeof failure !== 'object') return null - const failureData = failure as Record - if (!nonNegativeSafeInteger(data.turn) - || !nonNegativeSafeInteger(data.step) - || typeof data.provider !== 'string' - || data.provider.length === 0 - || typeof data.policyKey !== 'string' - || data.policyKey.length === 0 - || !positiveSafeInteger(data.retry) - || typeof data.delayMs !== 'number' - || !Number.isFinite(data.delayMs) - || data.delayMs < 0 - || data.delayMs > MAX_RETRY_DELAY_MS - || typeof failureData.message !== 'string' - || failureData.message.length === 0 - || typeof failureData.code !== 'string' - || failureData.code.length === 0) return null - if (data.mode === 'normal') { - if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null - } else if (data.mode === 'always') { - if ('maxRetries' in data) return null - } else { - return null - } - if (failureData.status !== undefined - && (typeof failureData.status !== 'number' - || !Number.isInteger(failureData.status) - || failureData.status < 100 - || failureData.status > 599)) return null - if (failureData.providerRetryAfterMs !== undefined - && (typeof failureData.providerRetryAfterMs !== 'number' - || !Number.isFinite(failureData.providerRetryAfterMs) - || failureData.providerRetryAfterMs <= 0)) return null - if (failureData.requestId !== undefined - && (typeof failureData.requestId !== 'string' - || failureData.requestId.length === 0)) return null - return data as unknown as LlmRetryEventData -} - -function nonNegativeSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -} - -function positiveSafeInteger(value: unknown): value is number { - return nonNegativeSafeInteger(value) && value > 0 +/** Convert one wire history row into the assembler's transport-neutral input. */ +function conversationInput(entry: HistoryEntry): ConversationEventInput { + return { event: entry.event, view: entry.view } } /** * The composerPhase judgment — the single site that knows the predicate - * (consumers switch on the result, never re-derive). Monotone per session - * object: `hasContent` only grows within a window and `promptAttempted` is - * sticky, so blank → engaging → active never steps back; a failed first - * prompt stays engaging (retry semantics — see ComposerPhase). - * @param hasContent - any conversation material exists (non-command nodes, - * partial, running turn, pending waits; command lifecycle rows alone keep - * the session blank). + * (consumers switch on the result, never re-derive). A failed first prompt + * stays engaging until an authoritative accepted-turn, running, or pending + * signal arrives (retry semantics — see ComposerPhase). + * @param hasContent - authoritative non-blank activity beyond a pending first + * prompt, a running turn, or a pending interaction. * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts deleted file mode 100644 index 78090ce87f..0000000000 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ /dev/null @@ -1,409 +0,0 @@ -// TranscriptAdapter: the human transcript projected from the raw event window -// in LOG order. The model-visible surface deliberately shadows replaced ranges, -// so it is the wrong source for conversation a reader already saw; this adapter -// keeps every append-origin event at its own log position and contributes one -// marker node per landed compaction checkpoint. Node order is therefore -// seq-monotonic by construction — no surface fold, no padding sentinels, no -// seq === index assertion to satisfy, and no degradation branch. - -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -// Subpath export (package.json exports "./surface", alias added for this): all value imports -// go through it — the package root points at lib/index.js (needs a build) which the vite -// browser bundle cannot resolve; surface.ts has no Node dependencies. -import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -// Cordis-free leaf subpath (the dsh-commands/brand shape): the Service Definition's -// declaration of the checkpoint source, reachable as a TYPE from this program. -// The package ROOT is not — it reaches dsh-session's root, whose Context merge -// declares the HOST `sessions: SessionStore` against this program's -// `sessions: ISessions` (TS2717, the one-program-per-side rule in -// docs/development.md). -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' -import { toAssistantBlocks } from './conversation.ts' -import { contextForm, contextProvenance } from './context-provenance.ts' -import { SteeringHistory } from './steering-history.ts' -import type { AssistantStepMetadata } from './assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' - -/** - * The compaction capability's checkpoint plugin, pinned to the Service Definition's declaration - * at COMPILE time: renaming it there fails this annotation (`TS2322`). The - * import stays type-only because a value import would fail the client purity - * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are - * forbidden in a browser bundle — while an erased type never reaches it. - */ -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' - -/** In-window tool/call index entry used to materialize result cards. */ -interface CallIndexEntry { - name: string - argsRaw: string - turn: number - step: number - /** Unix epoch ms of the tool/call event. */ - time: number - /** Wire view riding the tool/call (envelope-level; never inside the event). */ - callView: ToolCallView | null -} - -/** One event -> UI node (pure function; the ten-variant ConversationNode union). */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - steering: boolean, - stepTimings: ReadonlyMap, -): ConversationNode { - switch (event.type) { - case 'user/message': { - // Injected context (plugin/goal/skill-invocation source) folds to a - // context node, not a user message; only a direct human prompt is a - // user node. A compaction checkpoint never reaches here - // (isCompactCheckpoint routes it away). - if (event.data.source.kind !== 'user') { - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - provenance: contextProvenance(event.data.source), - form: contextForm(event.data.source), - } - } - if (steering) { - return { - kind: 'steering', messageId: event.data.id, - seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - return { - kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - case 'assistant/message': - return { - kind: 'assistant', seq: event.seq, time: event.time, - turn: event.data.turn, step: event.data.step, - blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), - } - case 'tool/result': { - const result = event.data.message.content[0] - const callId = String(event.data.message.source.callId) - const call = callIndex.get(callId) - return { - kind: 'tool-result', seq: event.seq, time: event.time, - callId, - call: call ? { name: call.name, argsRaw: call.argsRaw } : null, - callTime: call?.time ?? null, - content: result.content, isError: result.isError === true, - ...(event.data.error !== undefined ? { error: event.data.error } : {}), - meta: event.data.meta, - callView: call?.callView ?? null, - resultView, - subCalls: [], - } - } - /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types - can be append-origin, and each has a case above; reachable only if core - adds an eligible type. */ - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} - -/** - * Whether an event is a landed compaction checkpoint — all three conditions, - * matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the - * compaction seam's checkpoint plugin source, that REPLACED a surface range. A - * plugin-sourced `user/message` that appends is injected context (a - * session-reference card), not a compaction; a replacement `tool/result` is an - * in-place prune and a replacement `assistant/message` a generic rewrite, and - * both mark no boundary in the conversation. - * @param event - the raw window event. - * @returns true when the event compacted a surface range. - */ -function isCompactCheckpoint(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const source = event.data.source - return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN - && isReplacementSurfaceEvent(event) -} - -/** Whether an event contributes a node to the human transcript. */ -function isTranscriptEvent(event: SessionEvent): boolean { - return isAppendSurfaceEvent(event) || isCompactCheckpoint(event) -} - -/** - * Concatenated text of a `compact/summary` payload, or null when it carries no - * usable text. The payload is a `ContentBlock[]` whose union is - * merge-extensible, so a non-text block is skipped rather than discarding the - * text beside it; a payload with no text block at all falls to null through the - * empty check. - */ -function compactSummaryText(event: SessionEvent): string | null { - const summary = (event.data as unknown as { summary?: unknown }).summary - if (!Array.isArray(summary)) return null - let text = '' - for (const block of summary as readonly unknown[]) { - const candidate = block as { type?: unknown; text?: unknown } - if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue - text += candidate.text - } - return text.trim() === '' ? null : text -} - -interface CompactSummaryDetails { - readonly summary: string | null - readonly shadowedItemCount: number | null - readonly shadowedTokenCount: number | null -} - -/** Recover human-facing summary material from one structurally narrowed wire event. */ -function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails { - const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown } - const shadowedSeqs = data.shadowedSeqs - const tokenCount = data.shadowedTokenCount - return { - summary: compactSummaryText(event), - shadowedItemCount: Array.isArray(shadowedSeqs) - && shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0) - ? shadowedSeqs.length - : null, - shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 - ? tokenCount as number - : null, - } -} - -/** - * One landed checkpoint -> the human-facing compaction marker. The summary text - * comes from the checkpoint's cited `compact/summary` event (`sourceEventSeqs` names the - * `compact/summary` event), never from the framed checkpoint payload, which is - * an instruction envelope written for the model. A window cut that left the - * summary event outside soft-falls to `summary: null` (a non-expandable marker), - * the same posture as a call-less tool result. - */ -function materializeCompaction( - checkpoint: SessionEvent, - eventIndex: ReadonlyMap, -): CompactionSummaryNode { - const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs - let summary: string | null = null - let summaryEventSeq: number | null = null - let shadowedItemCount: number | null = null - let shadowedTokenCount: number | null = null - for (const seq of sources ?? []) { - const candidate = eventIndex.get(seq) - if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - const details = compactSummaryDetails(candidate) - summary = details.summary - summaryEventSeq = candidate.seq - shadowedItemCount = details.shadowedItemCount - shadowedTokenCount = details.shadowedTokenCount - break - } - return { - kind: 'compaction', - seq: checkpoint.seq, - time: checkpoint.time, - summary, - summaryEventSeq, - shadowedItemCount, - shadowedTokenCount, - } -} - -/** Log-ordered human transcript over a paged raw event window (never consults surface order). */ -export class TranscriptAdapter { - /** Window events by seq, used to find the summary event cited by a checkpoint. */ - private eventIndex = new Map() - /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ - private projected: ConversationNode[] = [] - private callIdx = new Map() - /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ - private stepTimings = new Map() - /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ - private resultViews = new Map() - /** Durable inbox replay used to distinguish next-step human input from queued prompts. */ - private readonly steeringHistory = new SteeringHistory() - /** - * Command lifecycle nodes by commandId (insertion = run order). The - * `command/run`/`command/done` pair is log-only, so it is not a surface - * event and never joins the transcript projection; this index folds the pair - * (done settles its run's node in place) and nodes() merges the products in - * by seq. Window cuts soft-fall like tool pairs: a done with no in-window - * run still builds a node. - */ - private commandIdx = new Map() - /** Projection revision, bumped only when a transcript node or a command node actually - * changed, keying the nodes() result cache: an unchanged projection returns the previous - * ARRAY reference, not just cached elements — the snapshot's reference-stability contract - * (§A.9.4) starts here, and a chunk storm bumps nothing at all. */ - private rev = 0 - private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - - /** - * Window rebuild (after open/resync/page prepend): re-index the raw window - * and re-project the transcript. - * @param events - the new window contents (seq-ascending). - * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). - */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { - this.rev++ - this.eventIndex = new Map() - this.callIdx = new Map() - this.resultViews.clear() - this.commandIdx = new Map() - this.steeringHistory.reset() - const steeringSeqs = new Set() - this.stepTimings = new Map() - for (let i = 0; i < events.length; i++) { - const event = events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event === undefined) continue - this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) - this.indexCommand(event) - if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) - indexAssistantStepTiming(this.stepTimings, event) - } - // Indexes first, then project: a tool/result materializes against the - // complete call index, and a checkpoint against the complete event index. - const projected: ConversationNode[] = [] - for (const event of events) { - if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq))) - } - this.projected = projected - } - - /** - * Tail append (live session/event): index the event and, when it belongs to - * the transcript, extend the projection by one copy-on-write node so a - * published array never mutates. An event that changes no node (a chunk - * storm) bumps no revision, so nodes() keeps returning the same array - * reference. - * @param event - the live event (seq = window tail + 1). - * @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering. - */ - append(event: SessionEvent, view?: ToolEventView): void { - this.eventIndex.set(event.seq, event) - this.indexCall(event, view) - const steering = this.steeringHistory.apply(event) - indexAssistantStepTiming(this.stepTimings, event) - if (this.indexCommand(event)) this.rev++ - if (!isTranscriptEvent(event)) return - this.projected = [...this.projected, this.materialize(event, steering)] - this.rev++ - } - - /** - * The current transcript node array. Same revision -> same array reference - * (memo boundary); node objects are materialized once, so an unchanged node - * keeps its identity across appends. - * @returns transcript nodes in log order, command nodes merged in by seq. - */ - nodes(): readonly ConversationNode[] { - if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value - // Command nodes fold outside the transcript (log-only events); merge by - // seq. Both inputs are seq-ascending (log order and run-index insertion - // order are the same order), so one linear merge keeps flow order. - let nodes = this.projected - if (this.commandIdx.size > 0) { - nodes = [] - const commands = [...this.commandIdx.values()] - let next = 0 - for (const node of this.projected) { - for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) { - nodes.push(cmd) - } - nodes.push(node) - } - for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd) - } - this.nodesResult = { rev: this.rev, value: nodes } - return nodes - } - - /** Materialize one transcript event against the complete current indexes. */ - private materialize(event: SessionEvent, steering: boolean): ConversationNode { - return isCompactCheckpoint(event) - ? materializeCompaction(event, this.eventIndex) - : materializeNode( - event, - this.callIdx, - this.resultViews.get(event.seq) ?? null, - steering, - this.stepTimings, - ) - } - - /** - * Fold one command lifecycle event into its node (run mints, done settles in - * place; done-only soft-falls). - * @returns whether the command index changed, so callers can bump the revision. - */ - private indexCommand(event: SessionEvent): boolean { - // Log-only plugin events: the host-side dsh-commands declaration cannot - // enter the client program, so this wire consumer narrows structurally - // (the same posture as tool/code-dispatch in session.ts). - if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, - }) - return true - } - if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - const run = this.commandIdx.get(data.commandId) - const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number - : undefined - const outcome = { - kind: data.kind, - ...data.text === undefined ? {} : { text: data.text }, - ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, - } - if (run === undefined) { - // Cross-window cut: the run page fell out of the window — build the - // node from the done alone (same soft-fall as a call-less tool result). - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: null, args: null, outcome, - }) - return true - } - // Settle in place: a fresh node object (published references stay immutable). - this.commandIdx.set(data.commandId, { ...run, outcome }) - return true - } - - private indexCall(event: SessionEvent, view?: ToolEventView): void { - if (event.type === 'tool/result') { - if (view?.for === 'result') this.resultViews.set(event.seq, view.view) - return - } - if (event.type !== 'tool/call') return - this.callIdx.set(String(event.data.callId), { - name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, - time: event.time, - callView: view?.for === 'call' ? view.view : null, - }) - // No backfill into already-materialized tool-result nodes for this callId - // (window order puts the call before its result; cannot happen on the normal path). - } -} diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index a30adb4dd6..e3ad848e05 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -320,7 +320,7 @@ export class SlotsService extends Service { const dispose = (this._core as unknown as ErasedCore).register(erased, component) if (store !== undefined) { // Register succeeded, so the target's spec is on the ledger. - const scope = (this._core.specDynamic(options.name) as SlotSpec).scope + const scope = (this._core.specDynamic(options.name) as SlotSpec).scope this._acquire(store, scope) } let disposed = false diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 7d7d9a6c34..4ed6ecf519 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -101,15 +101,40 @@ export interface SlotEntryDef { kind: SlotKind scope: SlotScope owner?: object + /** + * Optional keyed-entry prop table. A keyed registration contributes one + * literal key and receives the corresponding prop share; ordinary owner + * props remain common to every key. + */ + keyProps?: Record + /** + * Optional opaque context carried by one renderSlot occurrence. Only + * function-valued members of the slot-level injected hooks compartment + * receive it; the slot machinery never interprets the value. + */ + hookContext?: unknown + /** + * Optional Slot-level inject face supplied by the parent registration's + * child declaration. Every registered entry receives its bound component + * face; child registrants do not own or replace this common capability. + */ + inject?: object } /** * Runtime dispatch spec for one slot, recorded from a register call's * `children` value. The literal is compile-time checked against the SlotMap - * entry (`SlotSpec` in {@link ChildrenDecl}), so type and value - * are declared at one point and validate each other. + * entry (`SlotSpec` in {@link ChildrenDecl}), so kind, scope, and + * any common inject face are declared at one point and validate each other. */ -export interface SlotSpec { kind: E['kind']; scope: E['scope'] } +export type SlotSpec = { + kind: E['kind'] + scope: E['scope'] +} & ('inject' extends keyof E + ? E extends { inject: infer Injected extends object } + ? { inject: Injected } + : { inject?: object } + : { inject?: never }) /** * Child-slot declaration table for register(): keys are the declared (and @@ -123,6 +148,30 @@ export type ChildrenDecl = { [P in keyof SlotMap & string]?: SlotSpec = SlotMap[K] extends { owner: infer O extends object } ? O : object +/** Registration/dispatch key domain of one keyed slot. */ +export type EntryKeyOf = + SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? keyof P & string + : string + +/** Key-dependent props supplied by the owner at one keyed dispatch site. */ +export type KeyPropsOf< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, +> = SlotMap[K] extends { kind: 'keyed'; keyProps: infer P extends object } + ? EntryKey extends keyof P + ? P[EntryKey] extends object ? P[EntryKey] : never + : never + : object + +/** Opaque per-render occurrence context declared by one slot. */ +export type HookContextOf = + SlotMap[K] extends { hookContext: infer Context } ? Context : never + +/** Common render-occurrence inject face declared by one slot. */ +export type SlotInjectOf = + SlotMap[K] extends { inject: infer Injected extends object } ? Injected : object + /** Scope axis of a slot key's SlotMap entry. */ export type ScopeOf = SlotMap[K]['scope'] @@ -159,15 +208,26 @@ export type SessionIdOf = SessionStandardProps extends { sessionId: infer S } ? * Runtime props share for a slot key: owner share (parent's renderSlot call * site) + session standard kit (session scope only) + the global seat. */ -export type PropsRuntime = +export type PropsRuntime< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf = EntryKeyOf, +> = OwnerOf & + KeyPropsOf & + SlotInjectFace> & (ScopeOf extends 'session' ? SessionStandardProps : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps : object) & GlobalStandardProps -/** renderSlot dispatch options: keyed dispatch key, list filtering, empty fallback. */ -export interface RenderOpts { entryKey?: string; only?: string; fallback?: ReactNode } +/** renderSlot dispatch options: keyed dispatch key, list filtering, and empty fallback. */ +export interface RenderOpts { + entryKey?: EntryKey + only?: string + fallback?: ReactNode + /** Type-erased runtime seat; PropsRenderSlots narrows or removes it per slot declaration. */ + hookContext?: unknown +} /** renderSlotChain dispatch options. */ export interface ChainRenderOpts { @@ -200,6 +260,40 @@ export type ChainSelect = (owner: O) => M | null export type ChainKeysOf = S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never +/** Keys in a render share whose dispatch occurrence requires hookContext. */ +type ContextualKeysOf = + S extends unknown ? (SlotMap[S] extends { hookContext: unknown } ? S : never) : never + +/** Keys in a render share with the ordinary optional options bag. */ +type OrdinaryKeysOf = Exclude> + +/** + * Plain and contextual child dispatch signatures. Keeping them as separate + * call signatures preserves ordinary renderSlot assignability while making a + * declared hookContext mandatory only for the Slot keys that need it. + */ +type RenderSlotFn = + ([ContextualKeysOf] extends [never] ? object : { + < + K extends ContextualKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts: RenderOpts & { hookContext: HookContextOf }, + ): ReactNode + }) & + ([OrdinaryKeysOf] extends [never] ? object : { + < + K extends OrdinaryKeysOf, + EntryKey extends EntryKeyOf = EntryKeyOf, + >( + key: K, + owner: OwnerOf & KeyPropsOf>, + opts?: Omit, 'hookContext'>, + ): ReactNode + }) + /** * Chain matched share: a chain-slot component receives its selector's * non-null result as the framework-injected `matched` prop; other kinds add @@ -248,7 +342,7 @@ export type PropsRenderSlots = { * @param opts - kind dispatch options. * @returns rendered node(s). */ - renderSlot: >>(key: K, owner: OwnerOf, opts?: RenderOpts) => ReactNode + renderSlot: RenderSlotFn>> readonly __renders?: ((key: S) => void) | undefined } & ([ChainKeysOf] extends [never] ? object : { /** @@ -277,19 +371,53 @@ export type SlotComponent

= (props: P) => ReactNode /** * Registrant hooks compartment: bare observable sources (getSnapshot + - * subscribe pairs) supplied under the reserved `hooks` key of an inject - * face. The registrant-private twin of the `sessions.provide` hooks - * compartment: the renderer binds each source into a `use` selector - * hook, so the sources never reach the component and plugin-private reactive - * facts ride the same subscription machinery as the standard kit instead of - * hand-rolled component subscriptions. + * subscribe pairs) supplied under the reserved `hooks` key of an entry's + * inject face. These retain the original source-to-selector binding and do + * not participate in render-occurrence context. */ export type HooksSources = Record> +/** Framework-owned props visible while a slot-level contextual Hook is bound. */ +export type StandardPropsOf = + (ScopeOf extends 'session' ? SessionStandardProps + : ScopeOf extends 'session-maybe' ? SessionMaybeStandardProps + : object) & + GlobalStandardProps + +/** + * One function-valued slot-level inject.hooks member. The factory is pure and + * returns the actual custom Hook; it must not invoke a Hook while being bound. + */ +export type SlotHookFactory< + K extends keyof SlotMap & string, + Hook extends (...args: never[]) => unknown, +> = ( + standard: StandardPropsOf, + hookContext: HookContextOf, +) => Hook + +/** Component-side Hook produced from one slot-level inject.hooks member. */ +type BoundHookOf = + Definition extends HostObservable + ? SnapshotSelectorHook + : Definition extends (...args: never[]) => infer Hook + ? Hook extends (...args: never[]) => unknown ? Hook : never + : never + /** * Selector-hook share synthesized from a hooks compartment: each source * `name` becomes a `use` selector hook over its snapshot type. */ +export type PropsSlotHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + BoundHookOf +} + +/** Component-side view of a slot dispatcher's common inject face. */ +export type SlotInjectFace = + I extends { hooks: infer HS extends object } ? Omit & PropsSlotHooks : I + +/** Selector-hook share synthesized from an entry inject hooks compartment. */ export type PropsHooks = { [N in keyof HS & string as `use${Capitalize}`]: SnapshotSelectorHook ? T : never> @@ -313,12 +441,13 @@ export type InjectFace = */ export type ComposedProps< K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, S extends keyof SlotMap & string, H, I extends object, M = never, N = undefined, -> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare & PropsLocale /** * Inject factory parameter list, derived from the registration's declaration: @@ -345,12 +474,16 @@ export type InjectParams = export type SlotLabel = string | (() => string) /** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */ -export type KindOptions = - E['kind'] extends 'keyed' ? { key: string } - : E['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } - : E['kind'] extends 'chain' ? { +export type KindOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + M = never, +> = + SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey } + : SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel } + : SlotMap[K]['kind'] extends 'chain' ? { /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */ - select: ChainSelect + select: ChainSelect /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */ priority?: number } @@ -372,7 +505,14 @@ type RendersCheck = : unknown /** Common register options share (see {@link SlotCore.register} for semantics). */ -type BaseOptions = { +type BaseOptions< + K extends keyof SlotMap & string, + EntryKey extends EntryKeyOf, + D extends ChildrenDecl, + H, + M = never, + N = undefined, +> = { /** Target slot key (the entry contributes INTO this slot). */ name: K /** Child-slot declaration + render authorization + runtime spec, in one table. */ @@ -388,7 +528,7 @@ type BaseOptions +} & KindOptions /** * One stored registration, as recorded by the core and read by the render @@ -528,15 +668,16 @@ export class SlotCore { * would lose the per-overload inference of I. */ register< K extends keyof SlotMap & string, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject?: undefined }, + options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> & RendersCheck, ): () => void /** @@ -552,15 +693,16 @@ export class SlotCore { register< K extends keyof SlotMap & string, I extends object, + const EntryKey extends EntryKeyOf = EntryKeyOf, const D extends ChildrenDecl = Record, H extends StoreDecl | undefined = undefined, M = never, N extends (keyof LocaleNamespaceMap & string) | undefined = undefined, C extends SlotComponent = SlotComponent, >( - options: BaseOptions & { inject: (...args: InjectParams) => I }, + options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> & RendersCheck, ): () => void /* jscpd:ignore-end */ diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2a676fa22a..20f6a68eb1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -87,11 +87,13 @@ export interface SessionProvideInfo extends SessionMaybeProvideInfo { hooks: Record> } -/** renderSlot dispatch options at the machinery level: keyed dispatch key, list filtering, empty fallback. */ +/** renderSlot dispatch options at the machinery level. */ export interface RenderOpts { entryKey?: string only?: string fallback?: ReactNode + /** Opaque occurrence context consumed only by function-valued injected Hooks. */ + hookContext?: unknown } /** Host surface the runtime SlotsService presents to the installed renderer. */ diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index f7a3c32e93..69d37f35b4 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -2,7 +2,7 @@ * React renderer for declarative slots. Per-entry bindings enforce child * authorization, and entry boundaries contain registrant failures. */ -import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' +import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts, @@ -16,6 +16,14 @@ import { type InjectedProps = Record +type SlotHookFactory = (standard: InjectedProps, hookContext: unknown) => unknown +type SlotHookFactories = Readonly> + +interface BoundSlotInject { + readonly props: InjectedProps + readonly slotHookFactories?: SlotHookFactories | undefined +} + type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode @@ -89,9 +97,11 @@ const rootInjectCache = new WeakMap() const sessionInjectCache = new WeakMap>() const sessionMaybeInjectCache = new WeakMap>() +const EMPTY_INJECTED_PROPS: InjectedProps = {} + function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps { const inject = entry.inject - if (!inject) return {} + if (!inject) return EMPTY_INJECTED_PROPS // Declaration-derived positional arguments: sessionId for session scope, // baked actions when a store is declared. const args: unknown[] = [] @@ -101,11 +111,8 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined } /** - * Bind an inject face's reserved `hooks` compartment (bare observable - * sources, see HooksSources) into `use` selector hooks — the - * registrant-private twin of the provide-bundle binding in standardKit. - * Runs once per cached inject result; hook identity rides observableHook's - * per-source cache. + * Normalize one entry-owned inject face on its existing cache axis. Its hooks + * compartment remains the original Observable-only contract. */ function bindInjectHooks(face: InjectedProps): InjectedProps { const sources = face['hooks'] @@ -119,6 +126,53 @@ function bindInjectHooks(face: InjectedProps): InjectedProps { return bound } +const slotInjectCache = new WeakMap() +const EMPTY_SLOT_INJECT: BoundSlotInject = { props: EMPTY_INJECTED_PROPS } + +/** Normalize one dispatcher-owned inject face by its stable object identity. */ +function cachedSlotInject(face: object | undefined): BoundSlotInject { + if (face === undefined) return EMPTY_SLOT_INJECT + let bound = slotInjectCache.get(face) + if (bound !== undefined) return bound + const definitions = (face as InjectedProps)['hooks'] + if (definitions === undefined) { + bound = { props: face as InjectedProps } + slotInjectCache.set(face, bound) + return bound + } + const { hooks: _hooks, ...rest } = face as InjectedProps + const props: InjectedProps = rest + let factories: Record | undefined + for (const [name, definition] of Object.entries(definitions as Record)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (typeof definition === 'function') { + factories ??= {} + factories[name] = definition as SlotHookFactory + } else { + props[hookName] = observableHook(definition as HostObservable) + } + } + bound = factories === undefined + ? { props } + : { props, slotHookFactories: factories } + slotInjectCache.set(face, bound) + return bound +} + +/** Bind deferred slot-level factories for one stable renderSlot occurrence. */ +function bindSlotHookFactories( + factories: SlotHookFactories, + standard: InjectedProps, + hookContext: unknown, +): InjectedProps { + const hooks: InjectedProps = {} + for (const [name, factory] of Object.entries(factories)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + hooks[hookName] = factory(standard, hookContext) + } + return hooks +} + function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { let props = rootInjectCache.get(entry) if (!props) { @@ -270,6 +324,54 @@ class SlotErrorBoundary extends Component< } } +interface StandardPropsCache { + readonly root: InjectedProps + readonly session: WeakMap + readonly sessionMaybe: WeakMap +} + +const standardPropsCache = new WeakMap() + +/** Stable official-props object used by contextual Hook factories. */ +function standardProps( + host: SlotRendererHost, + scope: SlotScope, + info: SessionMaybeProvideInfo | undefined, +): InjectedProps { + let cache = standardPropsCache.get(host) + if (cache === undefined) { + cache = { + root: { + useSessions: observableHook(host.sessions.list), + useWorkspaces: observableHook(host.workspaces.list), + }, + session: new WeakMap(), + sessionMaybe: new WeakMap(), + } + standardPropsCache.set(host, cache) + } + if (scope === 'root') return cache.root + if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`) + const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe + let standard = byInfo.get(info) + if (standard !== undefined) return standard + standard = { ...cache.root } + for (const [name, source] of Object.entries(info.hooks)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + if (scope === 'session-maybe') { + standard[hookName] = maybeObservableHook(source) + } else { + if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) + standard[hookName] = observableHook(source) + } + } + Object.assign(standard, info.props) + standard['sessionId'] = info.sessionId + standard['useProjection'] = projectionHook(info) + byInfo.set(info, standard) + return standard +} + /** * Standard-kit synthesis shared by both scope branches: the global * useSessions/useWorkspaces hooks, the per-session provide bundle (every @@ -289,28 +391,11 @@ function standardKit( info: SessionMaybeProvideInfo | undefined, ): { kit: InjectedProps + standard: InjectedProps actions: object | undefined } { - const kit: InjectedProps = { - useSessions: observableHook(host.sessions.list), - useWorkspaces: observableHook(host.workspaces.list), - } - if (scope !== 'root' && info !== undefined) { - for (const [name, source] of Object.entries(info.hooks)) { - const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` - if (scope === 'session-maybe') { - kit[hookName] = maybeObservableHook(source) - } else { - if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`) - kit[hookName] = observableHook(source) - } - } - Object.assign(kit, info.props) - kit['sessionId'] = info.sessionId - // The useProjection seat (fifth framework hook): key-addressed cell - // reader, bound per provide bundle (cached by info identity). - kit['useProjection'] = projectionHook(info) - } + const standard = standardProps(host, scope, info) + const kit: InjectedProps = { ...standard } if (entry.locale !== undefined) { const face = host.locale // Loud assembly failure: locale is immediately-tier infrastructure; a @@ -344,38 +429,98 @@ function standardKit( kit['SessionProvider'] = SessionProvider } } - return { kit, actions: store?.actions } + return { kit, standard, actions: store?.actions } } /** - * One rendered entry: standard kit + cached inject + owner props (owner - * wins). The kit and injected shares are erased at the render boundary — the - * registration contract already proved the composed type — so each Entry renders - * through a props-widened view of the component (the design-budgeted - * composition point, one per scope branch). + * One rendered entry: standard kit + cached entry inject + common slot inject + * + owner props (owner wins). The shares are erased at this render boundary; + * the registration and renderSlot seams already proved their contracts. */ -function SessionEntry({ entry, ownerProps, info }: { +function ContextualEntry({ + slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext, +}: { + slotKey: string + Comp: FC + kit: InjectedProps + standard: InjectedProps + injected: InjectedProps + slotInjected: BoundSlotInject & { readonly slotHookFactories: SlotHookFactories } + ownerProps: object + hookContext: unknown + hasHookContext: boolean +}) { + const contextual = useMemo( + () => { + if (!hasHookContext) { + throw new SlotAssemblyError(`slot '${slotKey}' has contextual injected Hooks but no hookContext`) + } + return bindSlotHookFactories(slotInjected.slotHookFactories, standard, hookContext) + }, + [hasHookContext, hookContext, slotInjected.slotHookFactories, slotKey, standard], + ) + return +} + +function renderEntry( + slotKey: string, + Comp: FC, + kit: InjectedProps, + standard: InjectedProps, + injected: InjectedProps, + slotInjected: BoundSlotInject, + ownerProps: object, + hookContext: unknown, + hasHookContext: boolean, +): ReactNode { + if (slotInjected.slotHookFactories === undefined) { + return + } + return ( + + ) +} + +function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session', info) + const { kit, standard, actions } = standardKit(host, entry, 'session', info) const injected = cachedSessionInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function SessionMaybeEntryBody({ entry, ownerProps, info }: { +function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: { entry: StoredEntry ownerProps: object info: SessionMaybeProvideInfo + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'session-maybe', info) + const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info) const injected = cachedSessionMaybeInject(entry, info, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } /** @@ -391,7 +536,14 @@ function SessionMaybeEntryBody({ entry, ownerProps, info }: { * that must SURVIVE a switch belongs in session-bound sources (machine, * store, hooks) — the existing layering rule, now load-bearing. */ -function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const info = useSessionMaybeProvideInfo() // The child key is an incarnation counter, NOT the session id: adoption // must keep the key constant across undefined → first id. Bookkeeping @@ -416,7 +568,18 @@ function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerPro epoch += 1 setState({ adopted, epoch }) } - return + return ( + + ) } /** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */ @@ -429,24 +592,42 @@ interface MaybeIncarnation { const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 } -function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) { +function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasHookContext }: { + entry: StoredEntry + ownerProps: object + slotKey: string + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean +}) { const host = useHost() const Comp = entry.component as FC - const { kit, actions } = standardKit(host, entry, 'root', undefined) + const { kit, standard, actions } = standardKit(host, entry, 'root', undefined) const injected = cachedRootInject(entry, actions) - return + return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext) } -function StrictSessionEntry({ slotKey, entry, ownerProps }: { +function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: { slotKey: string entry: StoredEntry ownerProps: object + slotInjected: BoundSlotInject + hookContext: unknown + hasHookContext: boolean }) { const info = useSessionMaybeProvideInfo() if (info.sessionId === undefined) return null return ( - + ) } @@ -478,31 +659,62 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { // An absent strict overlay chain follows its ordinary empty-election path, // preserving the Fragment/fallback-wrapper shape across session arrival. const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey) + const slotInjected = cachedSlotInject(spec.inject) // The boundary must wrap the Entry ELEMENT, not live inside it: inject // factories and kit synthesis run in the Entry body and must land in the // per-entry fallback rather than escaping to the tree above. - const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => ( - spec.scope === 'session' - ? + const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => { + const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext') + const hookContext = opts?.hookContext + return spec.scope === 'session' + ? ( + + ) : ( {spec.scope === 'session-maybe' - ? - : } + ? ( + + ) + : ( + + )} ) - ) + } if (spec.kind === 'single') { const entry = entries[0] if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'keyed') { const entry = entries.find(e => e.options.key === opts?.entryKey) if (!entry) return <>{opts?.fallback ?? null} - return guarded(entry) + return guarded(entry, entryKeyOf(entry)) } if (spec.kind === 'chain') { // Entries arrive priority-sorted from the ledger (the core orders at @@ -561,7 +773,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: { let list = [...withListOptions].sort((a, b) => a.order - b.order) if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only) if (list.length === 0) return <>{opts?.fallback ?? null} - return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))} + return <>{list.map(item => guarded(item.entry, entryKeyOf(item.entry)))} } /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank (§1). */ @@ -575,8 +787,15 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) { const entry = host.entriesOf('root')[0] if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)") return ( - - + + ) } From 6d09b3168d035f7668ea73b745a115be1dc7eaac Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:48:40 +0800 Subject: [PATCH 04/20] refactor(ui-conversation): register business node definitions --- packages/client/ui-conversation/package.json | 2 + .../ui-conversation/src/client/apply.ts | 36 +- .../src/client/chat/AssistantMarkdown.tsx | 72 +-- .../src/client/chat/AssistantNodeView.tsx | 32 ++ .../src/client/chat/ChatNodeSeat.tsx | 60 +++ .../src/client/chat/ChatView.module.css | 14 +- .../src/client/chat/ChatView.tsx | 303 ++---------- .../src/client/chat/CommandNodeView.tsx | 40 ++ .../src/client/chat/MessageItem.tsx | 126 ++--- .../src/client/chat/StatsLine.tsx | 4 +- .../client/chat/TurnTailNodeView.module.css | 9 + .../src/client/chat/TurnTailNodeView.tsx | 43 ++ .../src/client/chat/chat-flow.ts | 214 -------- .../client/chat/register-node-renderers.ts | 46 ++ .../src/client/chat/tool-node-reader.ts | 46 ++ .../src/client/chat/turn-assistant.ts | 10 + .../src/client/chat/turn-metrics.ts | 6 +- .../src/client/contract/chat-nodes.ts | 82 ++++ .../src/client/contract/slots.ts | 93 ++-- .../client/conversation-nodes/assistant.ts | 316 ++++++++++++ .../chat-snapshot-builder.ts | 456 ++++++++++++++++++ .../src/client/conversation-nodes/command.ts | 243 ++++++++++ .../src/client/conversation-nodes/common.ts | 55 +++ .../client/conversation-nodes/compaction.ts | 63 +++ .../src/client/conversation-nodes/fallback.ts | 40 ++ .../src/client/conversation-nodes/inbox.ts | 71 +++ .../src/client/conversation-nodes/message.ts | 83 ++++ .../src/client/conversation-nodes/register.ts | 30 ++ .../src/client/conversation-nodes/retry.ts | 116 +++++ .../src/client/conversation-nodes/tool.ts | 271 +++++++++++ .../client/conversation-nodes/turn-error.ts | 112 +++++ .../client/conversation-nodes/turn-tail.ts | 180 +++++++ .../ui-conversation/src/client/index.ts | 19 +- .../src/client/skeleton/ApprovalPanel.tsx | 9 +- .../src/client/skeleton/DetailsPanel.tsx | 27 +- .../client/ui-conversation/src/invariant.ts | 2 +- .../ui-deliverables/src/client/index.ts | 7 +- .../src/client/turn-deliverables.ts | 139 ++++-- packages/client/ui-tool/src/client/apply.ts | 5 +- .../ui-tool/src/client/contract/slots.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 3 +- 41 files changed, 2744 insertions(+), 745 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx delete mode 100644 packages/client/ui-conversation/src/client/chat/chat-flow.ts create mode 100644 packages/client/ui-conversation/src/client/chat/register-node-renderers.ts create mode 100644 packages/client/ui-conversation/src/client/chat/tool-node-reader.ts create mode 100644 packages/client/ui-conversation/src/client/chat/turn-assistant.ts create mode 100644 packages/client/ui-conversation/src/client/contract/chat-nodes.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/command.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/common.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/message.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/register.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/retry.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/tool.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2ff87cc0e2..3f5bd8364c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0e486e9154..6d8e8e8c67 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,8 +7,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { - ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, - ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected, + ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, + ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, + DetailsInjected, } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' import { createChatStore } from './stores.ts' @@ -30,6 +31,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { en, NS, zh, type ConversationKey } from './locales.ts' +import { registerConversationNodes } from './conversation-nodes/register.ts' +import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] +export const inject = [ + 'slots', 'layout', 'sessions', 'workspaces', 'locale', + 'conversationEvents', 'conversationViews', +] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -63,6 +69,19 @@ const ABSENT_MENU_LAUNCHER = { subscribe: () => () => {}, } +const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { + hooks: { + turnData: ({ useSession }, nodeKey) => function useTurnData(key) { + return useSession((snapshot) => { + const location = snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(key) + : undefined + }) + }, + }, +} + /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) @@ -86,6 +105,9 @@ export function apply(ctx: Context): void { const layout = ctx.layout const slots = ctx.slots + registerConversationNodes(ctx) + registerChatNodeRenderers(ctx) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries') // Registration-time text (the view tab label) reads through the bound @@ -296,8 +318,8 @@ export function apply(ctx: Context): void { slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel) // The chat view: first entry of the ring this package just declared. - // ChatView owns ordered Tool placement but delegates each whole root call - // to ui-tool, which owns root/subcall composition and atomic dispatch. + // ChatView owns only the stable ordered Node list. Business renderers are + // independently keyed behind its one Node seat. slots.register({ name: 'conversation.view', id: 'chat', @@ -305,9 +327,7 @@ export function apply(ctx: Context): void { label: () => t('view.chat'), locale: NS, children: { - 'conversation.chat.tool': { kind: 'single', scope: 'session' }, - 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index d8883be4cd..6dd9f09b4d 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -11,12 +11,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' -import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' -import { hasContentText } from './chat-flow.ts' -import { MessageIconActions } from './MessageIconActions.tsx' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,61 +22,19 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Unix epoch ms for the IconActions clock; omitted while streaming or when - * the parent withholds chrome (mid-turn content assistants and every node - * of a turn that has not ended). */ - time?: number | undefined - /** Turn wall time in ms for the IconActions run-time label; omitted when the - * turn's triggering input is outside the loaded window. */ - runMs?: number | undefined - /** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */ - ttftMs?: number | undefined - /** Turn decode throughput for the IconActions label; omitted when unrecorded. */ - tokensPerSecond?: number | undefined - /** Event sequence used as the fork boundary; omitted while streaming. */ - seq?: number | undefined - /** Fork the session through this finalized message's completed turn when eligible. */ - onFork?: ((seq: number) => void) | undefined - /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ - turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined - /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ - fileMentions?: ChatViewInjected['fileMentions'] | undefined - /** The message is not the transcript tail of a completed turn. */ - forkUnavailable?: boolean | undefined + /** Resolved prose file mentions for this Assistant's closing turn. */ + mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } -/** Joined text blocks for the copy action (reasoning / tool heads stay out). */ -function copyText(blocks: readonly AssistantBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - if (block.kind === 'text') parts.push(block.text) - } - return parts.join('') -} - /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, - fileMentions, t, + blocks, streaming, interrupted, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) - // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the - // growing transcript: a settled turn's produced files are final, and a - // fresh identity per append would discard MarkdownText's cached parse for - // every settled closing message on every stream chunk. The window-prepend - // edge (a mid-turn window start later gaining earlier same-turn writes) - // leaves a mention unlinked until remount — never a wrong link. - const owner = turnTail?.owner - const mentions: MarkdownFileMentions | undefined = useMemo( - () => (owner === undefined ? undefined : fileMentions?.(owner)), - // Deliberately not `owner`: its identity changes per append while the - // seq-addressed vocabulary it yields does not. - [fileMentions, owner?.seq], - ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -88,10 +43,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null - // Footer only under settled content text; Think-only / streaming omit it. - const showActions = !streaming && time !== undefined && hasContentText(blocks) return ( -

+
{blocks.map((block, i) => { switch (block.kind) { @@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}}
- {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} - {showActions && ( - { onFork(seq) }} - branchUnavailable={forkUnavailable} - className={css.actions} - t={t} - /> - )}
) }) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx new file mode 100644 index 0000000000..d0f8b33e6f --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -0,0 +1,32 @@ +import { memo, useMemo } from 'react' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { AssistantMarkdown } from './AssistantMarkdown.tsx' + +/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ +export const AssistantNodeView = memo(function AssistantNodeView({ + node, useTurnData, openFile, fileMentions, t, +}: ChatNodeViewProps<'assistant-step'>) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + const tail = useTurnData('turn-tail') + const owner = useMemo(() => { + if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined + if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined + return { turn, seq: data.finalNode.seq, openFile } + }, [data.finalNode, openFile, tail, turn]) + const mentions = useMemo( + () => owner === undefined ? undefined : fileMentions(owner), + [fileMentions, owner], + ) + return ( + + ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx new file mode 100644 index 0000000000..1f7a2f7451 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -0,0 +1,60 @@ +import { memo, useMemo } from 'react' +import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import css from './ChatView.module.css' + +interface ChatNodeSeatProps extends ChatNodeOwnerProps { + readonly nodeKey: string + readonly useSession: ChatViewSlotProps['useSession'] + readonly renderSlot: ChatViewSlotProps['renderSlot'] + readonly t: ChatViewSlotProps['t'] +} + +type RoutedChatNodeOwner = { + [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode } +}[ChatNode['kind']] + +/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +export const ChatNodeSeat = memo(function ChatNodeSeat({ + nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, + fileMentions, useSession, renderSlot, t, +}: ChatNodeSeatProps) { + const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) + const routedNode = node as ChatNode | undefined + const owner = useMemo(() => node === undefined + ? null + : { + selectedCallId, + cwd, + openFile, + inspectCall, + forkAt, + fileMentions, + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions]) + if (routedNode === undefined || owner === null) return null + // Runtime dispatch owns the correlation: every Node's discriminant is the + // keyed-slot entry passed alongside that same Node. TypeScript does not + // distribute an object containing a union into a union of objects itself. + const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner + return ( +
+ {renderSlot('conversation.chat.node', routedOwner, { + entryKey: routedNode.kind, + hookContext: nodeKey, + fallback: ( + t('json.truncated', { total })} + /> + ), + })} +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index c600b3b0aa..d16608c856 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,6 +1,5 @@ -/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool - runs) via the column gap and between consecutive tool rows via the group - gap. Input padding cap rides the skeleton. Under +/* Chat flow: one 16px rhythm everywhere through the column gap. Input + padding cap rides the skeleton. Under `[data-conversation-scroll]` the column host owns overflow and this view is ordinary flow (see ConversationRoot active-phase rules). */ @@ -51,10 +50,11 @@ min-width: 0; } -.toolGroup { - display: flex; - flex-direction: column; - gap: 16px; +/* A keyed renderer may intentionally decline its row after dispatch (the + completed-turn tail does this when it owns neither actions nor extensions). + An empty flex item must not consume the column gap. */ +.flowItem:empty { + display: none; } .callRow { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 3636a2a971..44ae6f345a 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,41 +1,24 @@ -// ChatView: the default conversation view — message flow with user bubbles, -// assistant narration, tool summary rows grouped into step runs, pending -// cards, paging, and bottom-follow. Session stats live on -// 'conversation.composer.dock' (sticky with the composer). Pure component -// registered directly; its registration declares the whole-Tool -// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and -// keyed per-tool dispatch behind that boundary. +// ChatView: the default conversation view — one stable keyed parent list over +// final business Nodes, plus paging, pending steering and bottom-follow. +// Each row dispatches through 'conversation.chat.node'; ui-tool owns the +// tool-call renderer and its recursive root/subcall composition. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when // mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and // prepend anchoring always target the resolved scrollport. // -// Render economics (architecture RFC performance model): the list parent -// subscribes to snapshot segments that do NOT change per streaming chunk -// (nodes/runningCalls/pending keep their references across chunk batches), so -// during a token storm only StreamingTail re-renders; history rows hold via -// memo on cache-stable node slices. Selection changes re-render the parent -// map but only rows whose own selected bit flipped. renderSlot is -// entry-identity-stable (framework binding cache), so passing it through -// memoized rows never churns them. +// Render economics: order changes only when rows enter, leave or move. Each +// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool +// lifecycle updates replace only their own row without remounting it. -import { - memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, -} from 'react' -import type { - CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' -import { AssistantMarkdown } from './AssistantMarkdown.tsx' -import { CompactionCommandCard } from './CompactionCommandCard.tsx' -import { GenericCommandCard } from './GenericCommandCard.tsx' -import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' +import { PendingSteeringBubble } from './MessageItem.tsx' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' -import { deriveTurnMetrics } from './turn-metrics.ts' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | return visibleRows[0] ?? rows[0] ?? null } -type OpenFile = (path: string) => void - -type InspectCall = (callId: string) => void - -/** Declared child-slot render share (stable framework binding). */ -type RenderChatSlot = ChatViewSlotProps['renderSlot'] - type ChatScrollPosition = NonNullable> -/** ui-slots' UseSession is deliberately wide (dependency direction); the - * chat view narrows once to the runtime snapshot the binding actually feeds. */ -type UseConversation = SnapshotSelectorHook - -function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean { - return callId !== undefined - && (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId))) -} - -function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { - if (!running) return null - for (let index = nodes.length - 1; index >= 0; index -= 1) { - const node = nodes[index] - if (node === undefined) continue - if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq - if (node.kind === 'assistant' || node.kind === 'user') return null - } - return null -} - /** Capture a reflow-resistant reader position from the current rendered window. */ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { const row = pagingAnchor(list, scrollport) @@ -139,77 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } -/** One ordered root Tool call handed intact to the Tool presentation plugin. */ -const ToolSeat = memo(function ToolSeat({ - renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall, -}: { - renderSlot: RenderChatSlot - callId: string - toolName: string - block: ToolResultNode | RunningToolCall - openFile: OpenFile - selectedCallId?: string | undefined - cwd: string | undefined - inspectCall: InspectCall -}) { - const owner = useMemo(() => ({ - callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, - }), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall]) - return renderSlot('conversation.chat.tool', owner) -}) - -/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: { - renderSlot: RenderChatSlot - results: readonly ToolResultNode[] - openFile: OpenFile - /** Tool ownership resolves whether the selection is this root or one of its children. */ - selectedCallId: string | undefined - /** Session workspace root for path-relative summaries. */ - cwd: string | undefined - inspectCall: InspectCall -}) { - return ( -
- {results.map(node => ( - - ))} -
- ) -}) - -/** One command lifecycle row: keyed dispatch on the command name with the - * generic card as the render-site fallback (zero registration required). A - * run-less cross-window node has no name and always lands on the fallback. */ -const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: { - renderSlot: RenderChatSlot - node: CommandNode - compaction?: Extract - t: ChatViewSlotProps['t'] -}) { - const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node]) - const fallback = node.name === 'compact' - ? - : - return ( -
- {renderSlot('conversation.chat.commandview', owner, { - entryKey: node.name ?? '', - fallback, - })} -
- ) -}) +function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { + let latest: number | null = null + for (const turn of timeline.turns.values()) { + if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time + } + return latest +} /** Turn-level model activity label retained across first-token, tool, and streaming phases. */ function TurnStatus({ startTime, t }: { @@ -247,52 +139,32 @@ function TurnStatus({ startTime, t }: { ) } -/** The streaming partial, isolated so chunk batches re-render only this tail; - * the column ResizeObserver owns bottom-follow when its box grows. */ -function StreamingTail({ useSession, t }: { - useSession: UseConversation - t: ChatViewSlotProps['t'] -}) { - const partial = useSession(s => s.partial) - if (partial === null) return null - return -} - /** * The chat view slot entry: pure component over the composed props; each - * ordered root Tool call crosses the declared whole-Tool render seat. + * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { - const nodes = useSession(s => s.nodes) - const turnTimings = useSession(s => s.turnTimings) - const turnEnds = useSession(s => s.turnEnds) + const order = useSession(s => s.chat.order) + const nodeStore = useSession(s => s.chat.nodes) + const timeline = useSession(s => s.chat.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) - const runningCalls = useSession(s => s.runningCalls) const openState = useSession(s => s.openState) const openError = useSession(s => s.openError) const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) - const items = useMemo(() => deriveChatFlow(nodes), [nodes]) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), [inbox], ) - const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) - // Only the last content assistant of each completed turn owns IconActions; - // mid-turn text and every node of a running turn omit `time`, so - // AssistantMarkdown stays chrome-free until the answer settles. - const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds]) - const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) - const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) - const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) + const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(null) const columnRef = useRef(null) @@ -312,11 +184,12 @@ export function ChatView({ * scrolls the rest of the way to the floor). */ const followSigRef = useRef(null) - const firstSeq = nodes[0]?.seq ?? null - const lastItem = items[items.length - 1] - const lastKey = lastItem?.key ?? null + const firstKey = order[0] + const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null + const lastKey = order.at(-1) ?? null + const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -377,8 +250,7 @@ export function ChatView({ firstSeqRef.current = firstSeq // Own words must be visible: a new trailing user node force-scrolls // (send lives in the composer, so arrival is detected here, not armed there). - const appendedUser = lastKey !== lastKeyRef.current - && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey @@ -490,71 +362,6 @@ export function ChatView({ loadOlder() } - const renderItem = (item: ChatFlowItem): ReactNode => { - if (item.kind === 'tool-group') { - return ( - - ) - } - if (item.kind === 'command-compaction') { - return ( - - ) - } - const node: ConversationNode = item.node - if (node.kind === 'assistant') { - const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined - // Metrics gate on the settled in-window timing: turn/start loaded means - // every step of the turn is loaded, so first-step TTFT is genuine. - const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn) - return ( - - ) - } - if (node.kind === 'command') { - return - } - /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ - if (node.kind === 'tool-result') return null - return ( - - ) - } - return (
@@ -572,43 +379,21 @@ export function ChatView({
)} - {items.map(item => ( -
- {renderItem(item)} -
+ {order.map(nodeKey => ( + ))} - - {runningCalls.length > 0 && ( -
- {runningCalls.map(call => ( - - ))} -
- )} {/* No pending placeholders: questions (ui-question) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx new file mode 100644 index 0000000000..a1fc9f197a --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CommandNodeView.tsx @@ -0,0 +1,40 @@ +import { memo, useMemo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { + ChatNodeViewProps, CommandRowOwnerProps, +} from '../contract/slots.ts' +import { CompactionCommandCard } from './CompactionCommandCard.tsx' +import { GenericCommandCard } from './GenericCommandCard.tsx' +import css from './ChatView.module.css' + +type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'> + +/** Ordinary command lifecycle renderer with command-name keyed specialization. */ +export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) { + const command = node.data + const owner = useMemo(() => ({ node: command }), [command]) + return ( +
+ {renderSlot('conversation.chat.commandview', owner, { + entryKey: command.name ?? '', + fallback: , + })} +
+ ) +}) + +/** One integrated `/compact` command and compaction transaction renderer. */ +export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({ + node, t, +}: ChatNodeViewProps<'manual-compaction'>) { + const data = node.data + return ( +
+ +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 30b51b3870..972fba8710 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -7,30 +7,15 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, - TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + ModelRetryNode, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' -export interface MessageItemProps { - node: - | UserMessageNode - | SteeringMessageNode - | ContextMessageNode - | CompactionSummaryNode - | ModelRetryNode - | TurnErrorNode - | UnknownSurfaceNode - retryActive?: boolean - /** The owning view's locale seat, passed down as a plain prop. */ - t: ChatViewSlotProps['t'] -} - function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { const texts: string[] = [] const rest: unknown[] = [] @@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: { ) } -export const MessageItem = memo(function MessageItem({ - node, retryActive = false, t, -}: MessageItemProps) { - const truncated = (total: number): string => t('json.truncated', { total }) - switch (node.kind) { - case 'user': - case 'steering': - return ( - ( - - )} - /> - ) - case 'context': - return ( - ) { + const data = node.data + return ( + ( + - ) - case 'compaction': - return - case 'model-retry': - return - case 'turn-error': - return - default: - return ( -
- -
- ) - } + )} + /> + ) +}) + +/** Injected-context keyed Chat renderer. */ +export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) { + const data = node.data + return ( + + ) +}) + +/** Automatic compaction keyed Chat renderer. */ +export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) { + return +}) + +/** Correlated retry-chain keyed Chat renderer. */ +export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) { + const data = node.data + return +}) + +/** Terminal turn-error keyed Chat renderer. */ +export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) { + return +}) + +/** Explicit unknown-surface keyed Chat renderer. */ +export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) { + const data = node.data + return ( +
+ t('json.truncated', { total })} + /> +
+ ) }) diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 8e672740cf..8e43f3c0b4 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -157,9 +157,9 @@ export interface StatsLineProps { } export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { - const nodes = useSession(s => s.nodes) + const settledNodes = useSession(s => s.chat.legacy.nodes) + const stats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') - const stats = useMemo(() => deriveStats(nodes), [nodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css new file mode 100644 index 0000000000..831e6e212b --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.module.css @@ -0,0 +1,9 @@ +.root { + display: flex; + flex-direction: column; + gap: 16px; +} + +.actions { + margin-left: -6px; +} diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx new file mode 100644 index 0000000000..bf48fc75c6 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -0,0 +1,43 @@ +import { memo } from 'react' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts' +import { MessageIconActions } from './MessageIconActions.tsx' +import { assistantText } from './turn-assistant.ts' +import css from './TurnTailNodeView.module.css' + +type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'> + +/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ +export const TurnTailNodeView = memo(function TurnTailNodeView({ + node, openFile, forkAt, renderSlotChain, t, +}: TurnTailNodeViewProps) { + const data = node.data + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + if (turn === undefined) return null + const closing = data.closing + const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile } + const tail = renderSlotChain('conversation.chat.turnTail', owner) + if (closing === null) return tail === null ? null :
{tail}
+ const runMs = turn.start === undefined || turn.end === undefined + ? undefined + : Math.max(0, turn.end.time - turn.start.time) + return ( +
+ {tail} + { forkAt(closing.finalNode.seq) }} + branchUnavailable={data.branchUnavailable} + className={css.actions} + t={t} + /> +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts deleted file mode 100644 index a9b12cbe2d..0000000000 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool - * results group into consecutive-run tool groups (figma step-summary flow, - * VERTICAL gap10) alternating with narration. Consecutive retry notices - * reuse the first notice's row while projecting the latest retry turn. - * Item identity keys are stable across snapshots so the list parent can - * subscribe to keys only while rows subscribe to content. IconActions ownership - * and completed-turn branch points are derived here too so ChatView and the - * flow share their gates. - */ -import type { - AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode, -} from '@deepseek-ai/dsh-client-runtime/client' - -/** One renderable flow item; key is the React key and the parent's identity unit. */ -export type ChatFlowItem = - | { kind: 'node'; key: string; node: ConversationNode } - | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } - | { - kind: 'command-compaction' - key: string - command: CommandNode - compaction: CompactionSummaryNode - } - -/** Match explicit command outcome references to exactly one compaction checkpoint. */ -function commandCompactionPairs(nodes: readonly ConversationNode[]): { - readonly byCommandId: ReadonlyMap - readonly byCompactionSeq: ReadonlyMap -} { - const commandsBySource = new Map() - for (const node of nodes) { - if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue - const source = node.outcome.sourceEventSeq - if (source === undefined) continue - commandsBySource.set(source, commandsBySource.has(source) ? null : node) - } - const compactionsBySummary = new Map() - for (const node of nodes) { - if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue - const summary = node.summaryEventSeq - compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node) - } - const byCommandId = new Map() - const byCompactionSeq = new Map() - for (const [source, command] of commandsBySource) { - const compaction = compactionsBySummary.get(source) - if (command === null || compaction === undefined || compaction === null) continue - byCommandId.set(command.commandId, compaction) - byCompactionSeq.set(compaction.seq, command) - } - return { byCommandId, byCompactionSeq } -} - -/** - * True when the node has model-visible text content worth IconActions chrome. - * Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting - * cannot diverge. - * @param blocks - assistant blocks of one finalized node. - * @returns Whether any text block carries non-blank content. - */ -export function hasContentText(blocks: readonly AssistantBlock[]): boolean { - return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') -} - -/** An assistant node that renders nothing: only tool-call heads (rows render - * via the grouping pass) and blank text/reasoning. Skipped by the flow so it - * neither costs column gaps nor splits a tool-row run. Interrupted nodes - * always render (the 已停止 marker). */ -function rendersNothing(node: ConversationNode): boolean { - return node.kind === 'assistant' && node.interrupted !== true - && node.blocks.every(b => b.kind === 'tool-call' - || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) -} - -/** - * Seq set of assistants that own IconActions: the last content-text assistant - * of each *completed* turn. A turn without a `turn/end` in the window is still - * producing steps, so its latest narration is not the settled answer and owns - * nothing; mid-turn narration of a completed turn stays chrome-free too. - * @param nodes - snapshot nodes (surface order). - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Seq values ChatView may pass as `time` into AssistantMarkdown. - */ -export function assistantActionsSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const lastByTurn = new Map() - for (const node of nodes) { - if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue - lastByTurn.set(node.turn, node.seq) - } - return new Set(lastByTurn.values()) -} - -/** - * Exact start time of the latest in-window turn without a matching end time. - * @param turnTimings - In-window turn timings in event order. - * @returns Unix epoch ms, or null when the running turn started outside the window. - */ -export function runningTurnStartTime( - turnTimings: ConversationSnapshot['turnTimings'], -): number | null { - let latest: number | null = null - for (const timing of turnTimings.values()) { - if (timing.endTime === undefined) latest = timing.startTime - } - return latest -} - -/** - * Seq set of assistant answers that may fork: the completed turn's transcript - * tail, when that tail is the turn's own content-text assistant. A later tool, - * reasoning, error, or other transcript node leaves the answer's branch action - * unavailable because the Host would include the whole turn. User and steering - * bubbles carry no branch action at all: a fork at their seq cuts at the same - * `turn/end` as the answer's, so the affordance lives only under the settled - * answer. - * @param nodes - snapshot nodes in event order. - * @param turnEnds - completed turn boundaries retained from the event window. - * @returns Assistant seq values whose visible position matches the fork boundary. - */ -export function assistantBranchSeqs( - nodes: readonly ConversationNode[], - turnEnds: ReadonlyMap, -): ReadonlySet { - const result = new Set() - const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) - let nodeIndex = 0 - for (const [turn, endSeq] of boundaries) { - let tail: ConversationNode | undefined - while (nodeIndex < nodes.length) { - const candidate = nodes[nodeIndex] - if (candidate === undefined || candidate.seq > endSeq) break - tail = candidate - nodeIndex++ - } - if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) { - result.add(tail.seq) - } - } - return result -} - -/** - * Group finalized nodes into the step-summary flow. - * @param nodes - snapshot nodes in human-transcript and durable-notice order. - * @returns flow items; consecutive tool results group and retry notices reuse their first key. - */ -export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { - const items: ChatFlowItem[] = [] - const pairs = commandCompactionPairs(nodes) - let group: ToolResultNode[] | null = null - for (const node of nodes) { - if (rendersNothing(node)) continue - if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) { - continue - } - if (node.kind === 'compaction') { - group = null - const command = pairs.byCompactionSeq.get(node.seq) - if (command !== undefined) { - items.push({ - kind: 'command-compaction', - key: `c${command.commandId}`, - command, - compaction: node, - }) - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - continue - } - if (node.kind === 'tool-result') { - if (group === null) { - group = [node] - items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group }) - } else { - group.push(node) - } - } else if (node.kind === 'model-retry') { - group = null - const previous = items[items.length - 1] - if ( - previous?.kind === 'node' - && previous.node.kind === 'model-retry' - ) { - items[items.length - 1] = { ...previous, node } - } else { - items.push({ kind: 'node', key: `n${node.seq}`, node }) - } - } else { - group = null - items.push({ - kind: 'node', - key: node.kind === 'command' && node.name === 'compact' - ? `c${node.commandId}` - : `n${node.seq}`, - node, - }) - } - } - return items -} - -/** - * Key projection for the list parent's selector (content-blind identity). - * @param items - derived flow items. - * @returns joined key string usable with Object.is short-circuiting. - */ -export function flowKeys(items: readonly ChatFlowItem[]): string { - return items.map(i => i.key).join('|') -} diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts new file mode 100644 index 0000000000..8926fc2a8e --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { NS } from '../locales.ts' +import { AssistantNodeView } from './AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from './MessageItem.tsx' +import { TurnTailNodeView } from './TurnTailNodeView.tsx' + +/** + * Register this package's business renderers behind the keyed Chat Node seat. + * @param ctx - owning UI Conversation context. + */ +export function registerChatNodeRenderers(ctx: Context): void { + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'command', + locale: NS, + children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } }, + }, CommandNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'turn-tail', + locale: NS, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + }, TurnTailNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView)) +} diff --git a/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts new file mode 100644 index 0000000000..dca9992b8d --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/tool-node-reader.ts @@ -0,0 +1,46 @@ +import type { + ConversationSnapshot, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' + +function toolNode(node: ReturnType): ChatNode<'tool-call'> | undefined { + return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined +} + +/** + * Read one root Tool lifecycle through the internal Chat Node index. + * @param snapshot - current Conversation snapshot. + * @param rootCallId - root call identity and Tool Context identity. + * @returns root lifecycle when it is materialized in the current window. + */ +export function rootToolCall( + snapshot: ConversationSnapshot, + rootCallId: string, +): ToolCallBlock | undefined { + return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root +} + +/** + * Find any root or nested Tool lifecycle through the internal Node store. + * @param snapshot - current Conversation snapshot. + * @param callId - root or nested call identity. + * @returns current Tool lifecycle when materialized in the loaded window. + */ +export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined { + const visit = (block: ToolCallBlock): ToolCallBlock | undefined => { + if (block.callId === callId) return block + for (const child of block.subCalls) { + const found = visit(child) + if (found !== undefined) return found + } + return undefined + } + for (const node of snapshot.chat.nodes.values()) { + const root = toolNode(node)?.data.root + if (root === undefined) continue + const found = visit(root) + if (found !== undefined) return found + } + return undefined +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-assistant.ts b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts new file mode 100644 index 0000000000..2abfff56b3 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/turn-assistant.ts @@ -0,0 +1,10 @@ +import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Collect visible prose from one Assistant lifecycle. + * @param blocks - Assistant content blocks. + * @returns concatenated text blocks. + */ +export function assistantText(blocks: readonly AssistantBlock[]): string { + return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('') +} diff --git a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts index b7cc5ddb72..b93bbfc8eb 100644 --- a/packages/client/ui-conversation/src/client/chat/turn-metrics.ts +++ b/packages/client/ui-conversation/src/client/chat/turn-metrics.ts @@ -1,6 +1,6 @@ // Latency/throughput folds shared by the settled turn footer and StatsLine. -import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client' /** Latency and decode-throughput readings for one turn's footer. */ export interface TurnMetrics { @@ -24,7 +24,7 @@ interface UsageLike { outputTokens?: number } -type AssistantNode = Extract +type AssistantNode = AssistantMessageNode function usageOutputTokens(usage: unknown): number | null { if (typeof usage !== 'object' || usage === null) return null @@ -67,7 +67,7 @@ interface TurnFold { * @param nodes - Snapshot nodes of the loaded window. * @returns Turn number → available metrics; turns with none are absent. */ -export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map { +export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map { const folds = new Map() for (const node of nodes) { if (node.kind !== 'assistant') continue diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts new file mode 100644 index 0000000000..3415c502a5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -0,0 +1,82 @@ +import type { + AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode, + CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Merge-extensible payload registry keyed by final Chat renderer kind. */ +export interface ChatNodeDataMap {} + +/** Renderer kinds contributed by the currently installed Chat business modules. */ +export type ChatNodeKind = keyof ChatNodeDataMap & string + +/** Final Chat Node narrowed to one registered renderer kind and payload. */ +export type ChatNode = { + [RegisteredKind in Kind]: ChatConversationViewNode & { + readonly kind: RegisteredKind + readonly data: ChatNodeDataMap[RegisteredKind] + } +}[Kind] + +/** Final Assistant row payload shared by streaming and settled states. */ +export interface AssistantChatData { + readonly status: 'running' | 'settled' | 'interrupted' + readonly turn: number + readonly step: number + readonly blocks: readonly AssistantBlock[] + readonly time: number + readonly usage?: unknown + readonly finalNode?: AssistantMessageNode +} + +/** Settled or interrupted Assistant payload with its durable presentation node. */ +export type FinalAssistantChatData = AssistantChatData & { + readonly finalNode: AssistantMessageNode +} + +/** Root Tool row payload; the root lifecycle owns all recursive subcalls. */ +export interface ToolChatData { + readonly root: ToolCallBlock +} + +/** One manual command and its correlated compaction transaction. */ +export interface ManualCompactionChatData { + readonly command: CommandNode + readonly compaction: CompactionSummaryNode | null +} + +/** One durable retry chain rendered as a single row. */ +export interface RetryChatData { + readonly attempts: readonly ModelRetryNode[] + readonly current: ModelRetryNode +} + +/** Turn-local footer row that owns actions and optional feature contributions. */ +export interface TurnTailChatData { + readonly turn: number + readonly seq: number + readonly time: number + /** Last finalized content-bearing Assistant in this Turn. */ + readonly closing: FinalAssistantChatData | null + /** Whether later Assistant/Step material makes the closing seq non-tail. */ + readonly branchUnavailable: boolean + readonly ttftMs?: number + readonly tokensPerSecond?: number +} + +/** + * Test whether a Tool root has settled. + * @param block - Tool root lifecycle value. + * @returns whether the root carries its final result. + */ +export function isSettledTool(block: ToolCallBlock): block is Extract { + return 'kind' in block +} + +/** + * Test whether a Tool root is still running. + * @param block - Tool root lifecycle value. + * @returns whether the root lacks a final result. + */ +export function isRunningTool(block: ToolCallBlock): block is RunningToolCall { + return !isSettledTool(block) +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a57bcbd5a7..474e7f9db5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,15 +1,21 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, + SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap, + ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, + TurnLocation, WorkspaceId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' +import type { ChatNode, ChatNodeKind } from './chat-nodes.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -31,13 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * conversation snapshot through the standard kit. */ 'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps } - /** - * One root Tool call at its ordered ChatFlow position. The chat view owns - * placement; ui-tool owns root/subcall composition and keyed dispatch. - * The filler preserves the call-anchor DOM contract documented by - * {@link ToolTreeOwnerProps} for every root and child wrapper. - */ - 'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps } + /** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */ + 'conversation.chat.node': { + kind: 'keyed' + scope: 'session' + owner: ChatNodeOwnerProps + keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } + hookContext: string + inject: ChatNodeTurnDataInjected + } /** * The chat view's per-command row hole: keyed dispatch on the command * name (`command/run.name`; a run-less cross-window node has none and @@ -171,7 +179,7 @@ export interface ConvViewOwnerProps { export interface ChatFileMentions { /** * Mention vocabulary for the closing message the owner currency names. - * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @param owner - Turn-tail owner currency (Turn data, closing seq, opener). * @returns The resolver MarkdownText consumes, or undefined when the turn * produced nothing worth linking. */ @@ -186,14 +194,13 @@ declare module 'cordis' { } /** - * Owner currency of the chat view's turn-tail hole: the finalized snapshot - * and the closing assistant's anchor. Registrants derive their own facts - * from the nodes (the owner never pre-chews a feature's vocabulary), and - * open files through the same opener the tool rows use. + * Owner currency of the chat view's turn-tail hole: the engine-owned Turn and + * the closing assistant's anchor. Registrants read their own typed Turn data + * and open files through the same opener the tool rows use. */ export interface TurnTailOwnerProps { - /** Finalized snapshot nodes in surface order. */ - nodes: readonly ConversationNode[] + /** Engine-owned closing Turn boundary. */ + turn: TurnLocation /** The closing assistant's seq — the anchor the tail renders under. */ seq: number /** @@ -203,34 +210,34 @@ export interface TurnTailOwnerProps { openFile: (path: string) => void } -/** - * Owner currency of the chat view's whole-Tool rendering seat. The filler - * wraps every rendered root and child with `data-chat-anchor-key="call:"` - * and `data-chat-call-id=""`, plus `data-selected="true"` for the selected - * call. ChatView consumes those anchors to restore prepend/paging position. - */ -export interface ToolTreeOwnerProps { - /** Root Tool call identity, stable across running → settled. */ - callId: CallId - /** Root wire Tool name. */ - toolName: string - /** Frozen root call slice: running call or settled result node. */ - block: ToolCallBlock - /** Selected call id; the Tool owner resolves whether it is root or child. */ - selectedCallId?: CallId | undefined - /** Session workspace root; path summaries display relative to it. */ - cwd?: string | undefined - /** - * Open a tool-arg filesystem path with the host OS default application. - * The conversation owner resolves relative paths against the session cwd. - */ - openFile: (path: string) => void - /** - * Jump to any call in this tree in the trajectory view. - */ - inspectCall: (callId: CallId) => void +/** Hook constrained to business data published on the current Chat Node's Turn. */ +export type UseChatNodeTurnData = ( + key: Key, +) => Readonly | undefined + +/** Slot-level Hook factory used by renderers reading their Node's Turn data. */ +export interface ChatNodeTurnDataInjected { + hooks: { + turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> + } } +/** Stable owner currency delivered to one keyed Chat business renderer. */ +export interface ChatNodeOwnerProps { + /** Selected Tool call, when the shared details store names one. */ + selectedCallId?: CallId | undefined + /** Session workspace root; Tool summaries display paths relative to it. */ + cwd?: string | undefined + openFile: (path: string) => void + inspectCall: (callId: CallId) => void + forkAt: (seq: number) => void + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined +} + +/** Full props of one registered keyed Chat business renderer. */ +export type ChatNodeViewProps = + PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'> + /** Owner currency of the details panel's Tool output renderer. */ export interface DetailsToolOwnerProps { /** Frozen selected call slice. */ @@ -555,7 +562,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts new file mode 100644 index 0000000000..2bdf960226 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -0,0 +1,316 @@ +import type { Context } from 'cordis' +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { AssistantChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Streaming, settled, or interrupted Assistant step. */ + 'assistant-step': AssistantChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + /** Streaming, settled, or interrupted Assistant material for this Step. */ + 'assistant-step': AssistantChatData + } +} + +interface AssistantState { + readonly turn: number + readonly step: number + readonly blocks: readonly (AssistantBlock | undefined)[] + readonly firstVisibleSeq: number | undefined + readonly firstVisibleTime: number | undefined + readonly firstTokenTime: number | undefined + readonly hidden: boolean + readonly final: ConversationMatch | undefined + readonly usage: unknown +} + +function initialState(turn: number, step: number): AssistantState { + return { + turn, + step, + blocks: [], + firstVisibleSeq: undefined, + firstVisibleTime: undefined, + firstTokenTime: undefined, + hidden: false, + final: undefined, + usage: undefined, + } +} + +function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] { + 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 hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function resetForRetry(state: AssistantState): AssistantState { + return { + ...initialState(state.turn, state.step), + firstTokenTime: state.firstTokenTime, + hidden: true, + } +} + +function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState { + if (match.event.type !== 'assistant/chunk') return state + const chunk = match.event.data.chunk + const blocks = [...state.blocks] + switch (chunk.type) { + case 'block-start': + blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + break + case 'text-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } + break + } + case 'reasoning-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text } + break + } + case 'tool-call-delta': { + const previous = blocks[chunk.index] + const base = previous?.kind === 'tool-call' + ? previous + : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' } + blocks[chunk.index] = { + kind: 'tool-call', + callId: base.callId || String(chunk.id), + name: chunk.name ?? base.name, + argsRaw: base.argsRaw + chunk.argumentsDelta, + } + break + } + case 'block-end': + blocks[chunk.index] = toAssistantBlock(chunk.block) + break + case 'usage': + return { ...state, usage: chunk.usage } + default: + return state + } + const visible = hasVisibleContent(compactBlocks(blocks)) + const firstToken = isTokenDelta(chunk) + return { + ...state, + blocks, + hidden: visible ? false : state.hidden, + ...visible && state.firstVisibleSeq === undefined + ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time } + : {}, + ...firstToken && state.firstTokenTime === undefined + ? { firstTokenTime: match.event.time } + : {}, + } +} + +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 + } + if ((location.kind === 'step' || location.kind === 'turn') + && location.turn.status === 'closed' && location.turn.end !== undefined) { + return location.turn.end + } + return undefined +} + +function finalNode( + state: AssistantState, + context: ConversationNodeContext, +): AssistantMessageNode | undefined { + const final = state.final + if (final?.event.type === 'assistant/message') { + const event = final.event + return { + kind: 'assistant', + seq: event.seq, + time: event.time, + turn: state.turn, + step: state.step, + blocks: toAssistantBlocks(event.data.message.content), + usage: event.data.usage, + timing: { + stepStartTime: context.start?.event.time ?? null, + firstTokenTime: state.firstTokenTime ?? null, + completedTime: event.time, + }, + } + } + const location = context.start?.location ?? context.matches.at(-1)?.location + const boundary = location === undefined ? undefined : closedBoundary(location) + const blocks = compactBlocks(state.blocks) + if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined + return { + kind: 'assistant', + seq: boundary.seq - 0.9, + time: boundary.time, + turn: state.turn, + step: state.step, + blocks, + interrupted: true, + } +} + +function fallbackState(context: ConversationNodeContext): AssistantState | undefined { + let state: AssistantState | undefined + for (const match of context.matches) { + if (match.event.type === 'assistant/chunk') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = updateChunk(state, match) + continue + } + if (match.event.type === 'assistant/message') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = { + ...state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + continue + } + if ((match.event.type as string) === 'llm/retry' && state !== undefined) { + state = resetForRetry(state) + } + } + return state +} + +interface AssistantProjection { + readonly data: AssistantChatData + readonly anchorSeq: number + readonly visible: boolean + readonly settled: AssistantMessageNode | undefined +} + +function projectAssistant(context: ConversationNodeContext): AssistantProjection | undefined { + const state = context.state ?? fallbackState(context) + if (state === undefined) return undefined + const settled = finalNode(state, context) + const blocks = settled?.blocks ?? compactBlocks(state.blocks) + const visible = hasVisibleContent(blocks) + const status = settled?.interrupted === true + ? 'interrupted' + : settled === undefined ? 'running' : 'settled' + const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0 + const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0 + return { + anchorSeq, + visible, + settled, + data: { + status, + turn: state.turn, + step: state.step, + blocks, + time, + ...state.usage === undefined ? {} : { usage: state.usage }, + ...settled === undefined ? {} : { finalNode: settled }, + }, + } +} + +/** Per-step Assistant streaming/final/interruption Definition. */ +export const assistantDefinition: ConversationNodeDefinition = { + kind: 'assistant-step', + match: (event) => { + if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + if (event.type === 'assistant/chunk' + || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } + } + if ((event.type as string) === 'llm/retry') { + const data = event.data as unknown as { turn: number; step: number } + return { id: `${data.turn}:${data.step}`, role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('assistant-step start requires step/start') + return initialState(match.event.data.turn, match.event.data.step) + }, + update: (context, match) => { + if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match) + if (match.event.type === 'assistant/message') { + return { + ...context.state, + blocks: toAssistantBlocks(match.event.data.message.content), + hidden: false, + final: match, + usage: match.event.data.usage, + } + } + if ((match.event.type as string) === 'llm/retry') { + return resetForRetry(context.state) + } + return context.state + }, + publication: (match) => { + if (match.event.type === 'step/start') return 'none' + if (match.event.type !== 'assistant/chunk') return 'immediate' + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + }, + buildLocationData: (context, scope) => { + if (scope !== 'step') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + return { + kind: 'step', + turn: projected.data.turn, + step: projected.data.step, + key: 'assistant-step', + value: projected.data, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const projected = projectAssistant(context) + if (projected === undefined) return null + if (projected.settled === undefined && !projected.visible) { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const current = context.current.get('chat') + if (!state.hidden || current === undefined || current === null) return null + } + return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, { + visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden', + }) + }, +} + +/** + * Register the Assistant lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerAssistantConversationNode(ctx: Context): void { + ctx.conversationEvents.register(assistantDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts new file mode 100644 index 0000000000..f9491d2d16 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -0,0 +1,456 @@ +import type { Context } from 'cordis' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationLocation, ConversationNode, ConversationTimelineSnapshot, + ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, + PartialAssistant, RunningToolCall, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isRunningTool } from '../contract/chat-nodes.ts' + +const EMPTY_KEYS: readonly string[] = [] +const EMPTY_TURNS: readonly number[] = [] +const EMPTY_LIST: readonly never[] = [] + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +class MutableChatNodeStore implements ChatNodeStore { + private readonly byKey = new Map() + private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST + private valuesDirty = false + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + if (this.valuesDirty) { + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + return this.valuesCache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.byKey.clear() + for (const node of nodes) this.byKey.set(node.key, node) + this.valuesCache = [...this.byKey.values()] + this.valuesDirty = false + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + let changed = false + for (const node of nodes) { + if (this.byKey.get(node.key) === node) continue + this.byKey.set(node.key, node) + changed = true + } + if (changed) this.valuesDirty = true + } +} + +class MutableChatLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + private steps = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY_KEYS + } + + getStep(turn: number, step: number): readonly string[] { + return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS + } + + rebuild(order: readonly string[], store: ChatNodeStore): void { + const turns = new Map() + const steps = new Map() + for (const key of order) { + const location = store.get(key)?.location + if (location === undefined) continue + const coordinates = locationCoordinates(location) + if (coordinates.turn === undefined) continue + const turnKeys = turns.get(coordinates.turn) ?? [] + turnKeys.push(key) + turns.set(coordinates.turn, turnKeys) + if (coordinates.step === undefined) continue + const step = stepKey(coordinates.turn, coordinates.step) + const stepKeys = steps.get(step) ?? [] + stepKeys.push(key) + steps.set(step, stepKeys) + } + this.turns = updateIndex(this.turns, turns) + this.steps = updateIndex(this.steps, steps) + } + + /** Invalidate aggregate readers when member data changes without moving. */ + touch(nodes: readonly ChatConversationViewNode[]): void { + const turns = new Set() + const steps = new Set() + for (const node of nodes) { + const coordinates = locationCoordinates(node.location) + if (coordinates.turn === undefined || !this.turns.get(coordinates.turn)?.includes(node.key)) continue + turns.add(coordinates.turn) + if (coordinates.step !== undefined) steps.add(stepKey(coordinates.turn, coordinates.step)) + } + for (const turn of turns) { + const keys = this.turns.get(turn) + if (keys === undefined) continue + this.turns.set(turn, [...keys]) + } + for (const step of steps) { + const keys = this.steps.get(step) + if (keys === undefined) continue + this.steps.set(step, [...keys]) + } + } +} + +function updateIndex( + previous: ReadonlyMap, + nextMutable: ReadonlyMap, +): Map { + const next = new Map() + const keys = new Set([...previous.keys(), ...nextMutable.keys()]) + for (const key of keys) { + const before = previous.get(key) ?? EMPTY_KEYS + const candidate = nextMutable.get(key) ?? EMPTY_KEYS + const value = sameReferences(before, candidate) ? before : candidate + if (candidate.length > 0) next.set(key, value) + } + return next +} + +function stepKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + +function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] { + return nodes + .filter(node => node.visibility === 'visible') + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) +} + +interface LegacyContribution { + readonly anchorSeq: number + readonly nodes: readonly ConversationNode[] + readonly partial: PartialAssistant | null + readonly running: RunningToolCall | null +} + +const EMPTY_CONTRIBUTION: LegacyContribution = { + anchorSeq: 0, + nodes: EMPTY_LIST, + partial: null, + running: null, +} + +function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { + const node = raw as ChatNode + if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION + switch (node.kind) { + case 'user': + case 'steering': + case 'context': + case 'command': + case 'compaction': + case 'turn-error': + case 'unknown': + return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null } + case 'assistant-step': { + const data = node.data + if (data.status === 'running') { + if (raw.visibility !== 'visible') return EMPTY_CONTRIBUTION + return { + anchorSeq: node.anchorSeq, + nodes: EMPTY_LIST, + partial: { turn: data.turn, step: data.step, blocks: data.blocks }, + running: null, + } + } + return { + anchorSeq: node.anchorSeq, + nodes: data.finalNode === undefined ? EMPTY_LIST : [data.finalNode], + partial: null, + running: null, + } + } + case 'tool-call': { + const root = node.data.root + return isRunningTool(root) + ? { anchorSeq: node.anchorSeq, nodes: EMPTY_LIST, partial: null, running: root } + : { anchorSeq: node.anchorSeq, nodes: [root], partial: null, running: null } + } + case 'manual-compaction': { + const data = node.data + return { + anchorSeq: node.anchorSeq, + nodes: data.compaction === null ? [data.command] : [data.command, data.compaction], + partial: null, + running: null, + } + } + case 'model-retry': + return { + anchorSeq: node.anchorSeq, + nodes: node.data.attempts, + partial: null, + running: null, + } + case 'turn-tail': + return EMPTY_CONTRIBUTION + default: + return EMPTY_CONTRIBUTION + } +} + +function sameContribution(left: LegacyContribution | undefined, right: LegacyContribution): boolean { + return left !== undefined + && left.anchorSeq === right.anchorSeq + && left.partial?.blocks === right.partial?.blocks + && left.partial?.turn === right.partial?.turn + && left.partial?.step === right.partial?.step + && left.running === right.running + && sameReferences(left.nodes, right.nodes) +} + +/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */ +class LegacySliceBuilder { + private readonly contributions = new Map() + private readonly finalizedContributions = new Map() + private readonly runningContributions = new Map() + private readonly partialContributions = new Map() + private finalized: readonly ConversationNode[] = EMPTY_LIST + private runningCalls: readonly RunningToolCall[] = EMPTY_LIST + private partial: PartialAssistant | null = null + private timeline: ConversationTimelineSnapshot | undefined + private turnTimings: LegacyConversationSlice['turnTimings'] = new Map() + private turnEnds: LegacyConversationSlice['turnEnds'] = new Map() + + replace( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + this.contributions.clear() + this.finalizedContributions.clear() + this.runningContributions.clear() + this.partialContributions.clear() + for (const node of nodes) { + const contribution = legacyContribution(node) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + this.rebuildFinalized() + this.rebuildRunning() + this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + apply( + upserts: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, + ): LegacyConversationSlice { + let finalizedChanged = false + let runningChanged = false + let partialChanged = false + for (const node of upserts) { + const contribution = legacyContribution(node) + const previous = this.contributions.get(node.key) + if (sameContribution(previous, contribution)) continue + finalizedChanged ||= finalizedContributionChanged(previous, contribution) + runningChanged ||= runningContributionChanged(previous, contribution) + partialChanged ||= partialContributionChanged(previous, contribution) + this.contributions.set(node.key, contribution) + this.indexContribution(node.key, contribution) + } + if (finalizedChanged) this.rebuildFinalized() + if (runningChanged) this.rebuildRunning() + if (partialChanged) this.rebuildPartial() + this.updateTimeline(timeline) + return this.snapshot() + } + + private indexContribution(key: string, contribution: LegacyContribution): void { + updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0) + updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null) + updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null) + } + + private rebuildFinalized(): void { + const finalized = [...this.finalizedContributions.values()] + .flatMap(value => value.nodes) + .sort((left, right) => left.seq - right.seq) + if (!sameReferences(this.finalized, finalized)) this.finalized = finalized + } + + private rebuildRunning(): void { + const runningCalls = [...this.runningContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .flatMap(value => value.running === null ? [] : [value.running]) + if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls + } + + private rebuildPartial(): void { + const partial = [...this.partialContributions.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq) + .findLast(value => value.partial !== null)?.partial ?? null + if (this.partial?.blocks !== partial?.blocks + || this.partial?.turn !== partial?.turn + || this.partial?.step !== partial?.step) this.partial = partial + } + + private updateTimeline(timeline: ConversationTimelineSnapshot): void { + if (this.timeline === timeline) return + this.timeline = timeline + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, { + startTime: turn.start.time, + ...turn.end === undefined ? {} : { endTime: turn.end.time }, + }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + this.turnTimings = turnTimings + this.turnEnds = turnEnds + } + + private snapshot(): LegacyConversationSlice { + return { + nodes: this.finalized, + turnTimings: this.turnTimings, + turnEnds: this.turnEnds, + partial: this.partial, + runningCalls: this.runningCalls, + } + } +} + +function updateContributionIndex( + index: Map, + key: string, + contribution: LegacyContribution, + present: boolean, +): void { + if (present) index.set(key, contribution) + else index.delete(key) +} + +function finalizedContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + const previousNodes = previous?.nodes ?? EMPTY_LIST + return !sameReferences(previousNodes, next.nodes) + || ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq) +} + +function runningContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.running !== next.running + || ((previous.running !== null || next.running !== null) + && previous.anchorSeq !== next.anchorSeq) +} + +function partialContributionChanged( + previous: LegacyContribution | undefined, + next: LegacyContribution, +): boolean { + return previous?.partial?.blocks !== next.partial?.blocks + || previous?.partial?.turn !== next.partial?.turn + || previous?.partial?.step !== next.partial?.step + || (((previous?.partial ?? null) !== null || next.partial !== null) + && previous?.anchorSeq !== next.anchorSeq) +} + +/** Incremental keyed Chat builder registered under the `chat` target. */ +export class ChatSnapshotBuilder implements ConversationViewBuilder { + private readonly store = new MutableChatNodeStore() + private readonly locations = new MutableChatLocationIndex() + private readonly legacy = new LegacySliceBuilder() + private order: readonly string[] = EMPTY_KEYS + readonly empty: ChatSnapshot + + constructor() { + this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() }) + } + + replace(input: { + readonly nodes: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + this.store.replace(input.nodes) + this.order = orderedVisible(input.nodes).map(node => node.key) + this.locations.rebuild(this.order, this.store) + return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + } + + apply(input: { + readonly upserts: readonly ChatConversationViewNode[] + readonly timeline: ConversationTimelineSnapshot + }): ChatSnapshot { + let structural = false + const contentOnly: ChatConversationViewNode[] = [] + for (const node of input.upserts) { + const previous = this.store.get(node.key) + const nodeStructural = previous === undefined + || previous.anchorSeq !== node.anchorSeq + || previous.visibility !== node.visibility + || locationIdentity(previous.location) !== locationIdentity(node.location) + structural ||= nodeStructural + if (!nodeStructural) contentOnly.push(node) + } + this.store.upsert(input.upserts) + if (structural) { + const next = orderedVisible(this.store.values()).map(node => node.key) + this.order = sameReferences(this.order, next) ? this.order : next + this.locations.rebuild(this.order, this.store) + } + this.locations.touch(contentOnly) + return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + } + + private snapshot( + timeline: ConversationTimelineSnapshot, + legacy = this.legacy.replace(EMPTY_LIST, timeline), + ): ChatSnapshot { + return { + order: this.order, + nodes: this.store, + locations: this.locations, + timeline, + legacy, + } + } +} + +function locationIdentity(location: ConversationLocation): string { + const coordinates = locationCoordinates(location) + return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}` +} + +/** Chat target factory contributed to the Runtime view registry. */ +export const chatViewDefinition: ConversationViewDefinition = { + target: 'chat', + create: () => new ChatSnapshotBuilder(), +} + +/** + * Register the incremental Chat target builder. + * @param ctx - owning UI Conversation context. + */ +export function registerChatConversationView(ctx: Context): void { + ctx.conversationViews.register(chatViewDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts new file mode 100644 index 0000000000..3ca7976e32 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -0,0 +1,243 @@ +import type { Context } from 'cordis' +import type { + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary slash-command lifecycle. */ + command: CommandNode + /** Manual compact command combined with its compaction transaction. */ + 'manual-compaction': ManualCompactionChatData + } +} + +type CommandId = CommandNode['commandId'] + +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' + +interface CommandState { + readonly command: CommandNode + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CompactionEvidence { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +interface CommandRunData { + readonly commandId: CommandId + readonly name: string + readonly args?: string +} + +interface CommandDoneData { + readonly commandId: CommandId + readonly kind: 'success' | 'error' + readonly text?: string + readonly sourceEventSeq?: number +} + +function commandFromRun(match: ConversationMatch): CommandNode { + const data = match.event.data as unknown as CommandRunData + return { + kind: 'command', + seq: match.event.seq, + time: match.event.time, + commandId: data.commandId, + name: data.name, + args: data.args ?? null, + outcome: null, + } +} + +function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode { + const data = match.event.data as unknown as CommandDoneData + const sourceEventSeq = data.kind === 'success' + && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 + ? data.sourceEventSeq as number + : undefined + return { + kind: 'command', + seq: previous?.seq ?? match.event.seq, + time: previous?.time ?? match.event.time, + commandId: data.commandId, + name: previous?.name ?? null, + args: previous?.args ?? null, + outcome: { + kind: data.kind, + ...data.text === undefined ? {} : { text: data.text }, + ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, + }, + } +} + +/** + * Read correlation identity from a compaction replacement checkpoint. + * @param event - candidate Session event. + * @returns correlated compaction and optional command identity. + */ +function compactSource(event: Parameters[0]): { + compactionId: string + sourceCommandId?: CommandId +} | undefined { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined + const source = event.data.source as unknown as { + kind?: unknown + plugin?: unknown + compactionId?: unknown + sourceCommandId?: CommandId + } + if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined + return { + compactionId: source.compactionId, + ...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId }, + } +} + +/** + * Build the visible summary marker from optional lifecycle evidence. + * @param match - compact/summary Match, when loaded. + * @param checkpoint - replacement checkpoint Match. + * @returns final compaction summary Node data. + */ +function compactSummary(match: ConversationMatch | undefined, checkpoint: ConversationMatch): CompactionSummaryNode { + let summary: string | null = null + let shadowedItemCount: number | null = null + let shadowedTokenCount: number | null = null + if (match !== undefined) { + const data = match.event.data as unknown as { + summary?: unknown + shadowedSeqs?: unknown + shadowedTokenCount?: unknown + } + if (Array.isArray(data.summary)) { + const text = data.summary + .map((block: unknown) => { + const value = block as { type?: unknown; text?: unknown } + return value.type === 'text' && typeof value.text === 'string' ? value.text : '' + }) + .join('') + summary = text.trim() === '' ? null : text + } + shadowedItemCount = Array.isArray(data.shadowedSeqs) + && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0) + ? data.shadowedSeqs.length + : null + shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount) + && (data.shadowedTokenCount as number) >= 0 + ? data.shadowedTokenCount as number + : null + } + return { + kind: 'compaction', + seq: checkpoint.event.seq, + time: checkpoint.event.time, + summary, + summaryEventSeq: match?.event.seq ?? null, + shadowedItemCount, + shadowedTokenCount, + } +} + +function fallbackState(context: ConversationNodeContext): CommandState | undefined { + const done = context.matches.find(match => (match.event.type as string) === 'command/done') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const source = compactSource(checkpoint.event) + if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } + const fallbackCommand = done === undefined + ? { + kind: 'command' as const, + seq: checkpoint.event.seq, + time: checkpoint.event.time, + commandId: source.sourceCommandId, + name: 'compact', + args: null, + outcome: null, + } + : { ...commandFromDone(done), name: 'compact' } + return { + command: fallbackCommand, + checkpoint, + ...summary === undefined ? {} : { summary }, + } +} + +/** + * Fold shared compaction evidence into a Definition-owned State. + * @param state - current business State carrying optional compaction evidence. + * @param match - next compaction lifecycle Match. + * @returns adopted State, preserving reference identity when the Match adds no evidence. + */ +export function updateCompactionState( + state: State, + match: ConversationMatch, +): State { + if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match } + if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match } + return state +} + +/** Slash-command lifecycle, including integrated manual compaction, Definition. */ +export const commandDefinition: ConversationNodeDefinition = { + kind: 'command', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' } + } + if ((event.type as string) === 'command/done') { + return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' } + } + const checkpoint = compactSource(event) + if (checkpoint?.sourceCommandId !== undefined) { + return { id: String(checkpoint.sourceCommandId), role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { sourceCommandId?: CommandId } + if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' } + } + return null + }, + start: (_context, match) => ({ command: commandFromRun(match) }), + update: (context, match) => { + if ((match.event.type as string) === 'command/done') { + return { ...context.state, command: commandFromDone(match, context.state.command) } + } + return updateCompactionState(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + if (state.command.name !== 'compact') { + return chatNode(context, 'command', state.command.seq, state.command) + } + const compaction = state.checkpoint === undefined + ? null + : compactSummary(state.summary, state.checkpoint) + const data: ManualCompactionChatData = { command: state.command, compaction } + return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data) + }, +} + +/** + * Register the command lifecycle business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCommandConversationNode(ctx: Context): void { + ctx.conversationEvents.register(commandDefinition) +} + +/** Shared structural checkpoint recognizer for automatic compaction. */ +export { compactSource, compactSummary } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts new file mode 100644 index 0000000000..8e1d9d7bd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -0,0 +1,55 @@ +import type { + ConversationLocation, ConversationNodeContext, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatNode, ChatNodeDataMap, ChatNodeKind, +} from '../contract/chat-nodes.ts' + +/** + * Resolve one Context's best currently loaded event Location. + * @param context - assembled business Context. + * @returns start or first-match Location, otherwise unresolved. + */ +export function contextLocation(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +/** + * Build one final Chat target Node with the engine-owned stable key. + * @param context - assembled business Context. + * @param kind - Chat renderer dispatch key. + * @param anchorSeq - sortable render position. + * @param data - renderer-owned payload. + * @param options - optional Location and visibility overrides. + * @returns final Chat view Node. + */ +export function chatNode( + context: ConversationNodeContext, + kind: Kind, + anchorSeq: number, + data: ChatNodeDataMap[Kind], + options: { + readonly location?: ConversationLocation + readonly visibility?: 'visible' | 'hidden' + } = {}, +): ChatNode { + return { + key: context.key, + kind, + id: context.id, + target: 'chat', + anchorSeq, + location: options.location ?? contextLocation(context), + visibility: options.visibility ?? 'visible', + data, + } +} + +/** + * Read a finite non-negative integer from a structurally narrowed payload. + * @param value - untrusted payload field. + * @returns valid coordinate, otherwise undefined. + */ +export function coordinate(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts new file mode 100644 index 0000000000..0742ab6539 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -0,0 +1,63 @@ +import type { Context } from 'cordis' +import type { + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' +import { compactSource, compactSummary, updateCompactionState } from './command.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Automatic compaction checkpoint marker. */ + compaction: CompactionSummaryNode + } +} + +interface CompactionState { + readonly summary?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +function fallbackState(context: ConversationNodeContext): CompactionState { + const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) + return { + ...summary === undefined ? {} : { summary }, + ...checkpoint === undefined ? {} : { checkpoint }, + } +} + +/** Automatic compaction lifecycle and landed checkpoint Definition. */ +export const compactionDefinition: ConversationNodeDefinition = { + kind: 'compaction', + match: (event) => { + const checkpoint = compactSource(event) + if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { + return { id: checkpoint.compactionId, role: 'update' } + } + if ((event.type as string) === 'compact/start' + || (event.type as string) === 'compact/summary' + || (event.type as string) === 'compact/end') { + const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown } + if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null + return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' } + } + return null + }, + start: () => ({}), + update: (context, match) => updateCompactionState(context.state, match), + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state.checkpoint === undefined) return null + const marker = compactSummary(state.summary, state.checkpoint) + return chatNode(context, 'compaction', marker.seq, marker) + }, +} + +/** + * Register the automatic-compaction business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerCompactionConversationNode(ctx: Context): void { + ctx.conversationEvents.register(compactionDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts new file mode 100644 index 0000000000..a93fc8fdd4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -0,0 +1,40 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Generic presentation of an unclaimed append-surface event. */ + unknown: UnknownSurfaceNode + } +} + +/** Unclaimed append-surface fallback Definition. */ +export const unknownFallbackDefinition: ConversationNodeDefinition = { + kind: 'unknown-surface', + match: event => isAppendSurfaceEvent(event) + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => ({ + kind: 'unknown', + seq: match.event.seq, + time: match.event.time, + type: match.event.type, + data: match.event.data, + }), + update: context => context.state, + buildViewNode: (context, target) => target !== 'chat' || context.state === undefined + ? null + : chatNode(context, 'unknown', context.state.seq, context.state), +} + +/** + * Register the unmatched append-surface fallback contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerUnknownConversationFallback(ctx: Context): void { + ctx.conversationEvents.registerFallback(unknownFallbackDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts new file mode 100644 index 0000000000..092bcaaa59 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -0,0 +1,71 @@ +import type { Context } from 'cordis' +import type { + ConversationNodeDefinition, ConversationPreviousContext, +} from '@deepseek-ai/dsh-client-runtime/client' + +type InboxTarget = 'next-turn' | 'next-step' + +interface InboxIdentity { + readonly id: string +} + +interface InboxSplice { + readonly target: InboxTarget + readonly start: number + readonly removedCount?: number + readonly inserted: readonly InboxIdentity[] + readonly outcome?: 'canceled' +} + +/** Cumulative state after one durable inbox splice. */ +export interface InboxState { + readonly pending: readonly InboxIdentity[] + readonly claimed: ReadonlySet +} + +function applySplice( + previous: ConversationPreviousContext | undefined, + splice: InboxSplice, +): InboxState { + const pending = [...(previous?.state.pending ?? [])] + const claimed = new Set(previous?.state.claimed ?? []) + const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + for (const identity of splice.inserted) claimed.delete(identity.id) + if (splice.target === 'next-step' && splice.outcome !== 'canceled') { + for (const identity of removed) claimed.add(identity.id) + } + return { pending, claimed } +} + +function inboxDefinition(target: InboxTarget): ConversationNodeDefinition { + const kind = `inbox-${target}` + return { + kind, + match: event => (event.type as string) === 'agent/inbox/spliced' + && (event.data as unknown as { target?: unknown }).target === target + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => applySplice( + reader.previous(kind), + match.event.data as unknown as InboxSplice, + ), + update: context => context.state, + publication: () => 'none', + buildViewNode: () => null, + } +} + +/** Cumulative next-turn inbox splice Definition. */ +export const nextTurnInboxDefinition = inboxDefinition('next-turn') + +/** Cumulative next-step inbox splice Definition used to classify steering. */ +export const nextStepInboxDefinition = inboxDefinition('next-step') + +/** + * Register the two durable Inbox-state contributions. + * @param ctx - owning UI Conversation context. + */ +export function registerInboxConversationNodes(ctx: Context): void { + ctx.conversationEvents.register(nextTurnInboxDefinition) + ctx.conversationEvents.register(nextStepInboxDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts new file mode 100644 index 0000000000..91300944d7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -0,0 +1,83 @@ +import type { Context } from 'cordis' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxState } from './inbox.ts' +import { chatNode } from './common.ts' + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Ordinary turn-opening user message. */ + user: UserMessageNode + /** User message admitted into an active turn. */ + steering: SteeringMessageNode + /** Non-user context injected into model history. */ + context: ContextMessageNode + } +} + +function isCompactionCheckpoint(event: Parameters[0]): boolean { + if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false + const source = event.data.source + return source.kind === 'plugin' && source.plugin === 'compact' +} + +/** User, steering, and injected-context message classification Definition. */ +export const messageDefinition: ConversationNodeDefinition = { + kind: 'input-message', + match: event => event.type === 'user/message' + && isAppendSurfaceEvent(event) + && !isCompactionCheckpoint(event) + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') + const event = match.event + if (event.data.source.kind !== 'user') { + return { + kind: 'context', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + return claimed + ? { + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + : { + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + }, + update: context => context.state, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return chatNode(context, context.state.kind, context.state.seq, context.state) + }, +} + +/** + * Register the user, steering, and injected-context message contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerMessageConversationNode(ctx: Context): void { + ctx.conversationEvents.register(messageDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts new file mode 100644 index 0000000000..9102b1f2f4 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -0,0 +1,30 @@ +import type { Context } from 'cordis' +import { registerAssistantConversationNode } from './assistant.ts' +import { registerChatConversationView } from './chat-snapshot-builder.ts' +import { registerCommandConversationNode } from './command.ts' +import { registerCompactionConversationNode } from './compaction.ts' +import { registerUnknownConversationFallback } from './fallback.ts' +import { registerInboxConversationNodes } from './inbox.ts' +import { registerMessageConversationNode } from './message.ts' +import { registerRetryConversationNode } from './retry.ts' +import { registerToolConversationNode } from './tool.ts' +import { registerTurnErrorConversationNode } from './turn-error.ts' +import { registerTurnTailConversationNode } from './turn-tail.ts' + +/** + * Register the Chat business Definitions and target builder contributed by this package. + * @param ctx - owning UI Conversation context. + */ +export function registerConversationNodes(ctx: Context): void { + registerInboxConversationNodes(ctx) + registerMessageConversationNode(ctx) + registerAssistantConversationNode(ctx) + registerToolConversationNode(ctx) + registerCommandConversationNode(ctx) + registerCompactionConversationNode(ctx) + registerRetryConversationNode(ctx) + registerTurnErrorConversationNode(ctx) + registerTurnTailConversationNode(ctx) + registerUnknownConversationFallback(ctx) + registerChatConversationView(ctx) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts new file mode 100644 index 0000000000..fe95c0052d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -0,0 +1,116 @@ +import type { Context } from 'cordis' +import type { + ConversationLocation, ConversationNodeDefinition, ModelRetryNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { RetryChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Producer-correlated model retry chain. */ + 'model-retry': RetryChatData + } +} + +type WithoutRetryProjection = Node extends unknown + ? Omit + : never +type RetryEventData = WithoutRetryProjection + +/** Accumulated retry attempts sharing one producer-owned RetryId. */ +export interface RetryState { + readonly turn: number + readonly step: number + readonly attempts: readonly ModelRetryNode[] +} + +function retryData(value: unknown): RetryEventData | undefined { + if (value === null || typeof value !== 'object') return undefined + const data = value as Record + if (typeof data.retryId !== 'string' || data.retryId === '' + || !Number.isSafeInteger(data.turn) || (data.turn as number) < 0 + || !Number.isSafeInteger(data.step) || (data.step as number) < 0 + || !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0 + || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 + || typeof data.provider !== 'string' || typeof data.policyKey !== 'string' + || (data.mode !== 'normal' && data.mode !== 'always') + || data.failure === null || typeof data.failure !== 'object') return undefined + if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) { + return undefined + } + return data as unknown as RetryEventData +} + +function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined { + const data = retryData(event.data) + return data === undefined ? undefined : { + kind: 'model-retry', + seq: event.seq, + time: event.time, + retryState: 'scheduled', + ...data, + } +} + +function isClosed(location: ConversationLocation): boolean { + return (location.kind === 'step' && location.step.status === 'closed') + || ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed') +} + +/** Producer-correlated model retry chain Definition. */ +export const retryDefinition: ConversationNodeDefinition = { + kind: 'model-retry', + match: (event) => { + if ((event.type as string) === 'llm/retry') { + const data = retryData(event.data) + if (data === undefined) return null + return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' } + } + if ((event.type as string) === 'llm/retry-started') { + const data = event.data as unknown as { retryId?: unknown } + return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null + } + return null + }, + start: (_context, match) => { + const node = scheduledNode(match.event) + if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event') + return { turn: node.turn, step: node.step, attempts: [node] } + }, + update: (context, match) => { + if ((match.event.type as string) === 'llm/retry') { + const node = scheduledNode(match.event) + return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] } + } + if ((match.event.type as string) !== 'llm/retry-started') return context.state + const data = match.event.data as unknown as { retry: number } + return { + ...context.state, + attempts: context.state.attempts.map(attempt => + attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null + const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const } + const stateAttempts = context.state.attempts + const attempts = stateAttempts.map((attempt, index) => + index === stateAttempts.length - 1 + && attempt.retryState === 'scheduled' + && isClosed(location) + ? { ...attempt, retryState: 'cancelled' as const } + : attempt) + const current = attempts.at(-1) + if (current === undefined) return null + const data: RetryChatData = { attempts, current } + return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data) + }, +} + +/** + * Register the correlated model-retry business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerRetryConversationNode(ctx: Context): void { + ctx.conversationEvents.register(retryDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts new file mode 100644 index 0000000000..04a5770f5a --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -0,0 +1,271 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolChatData } from '../contract/chat-nodes.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Root Tool lifecycle with recursively nested subcalls. */ + 'tool-call': ToolChatData + } +} + +const MAX_DEPTH = 256 + +interface ToolState { + readonly root: ToolCallBlock + readonly children: ReadonlyMap + readonly parents: ReadonlyMap +} + +interface ProjectedBlockCache { + readonly children: readonly ToolCallBlock[] + readonly interruptionSeq: number | undefined + readonly interruptionTime: number | undefined + readonly value: ToolCallBlock +} + +const projectedBlocks = new WeakMap() + +function jsonArguments(value: unknown): string { + return JSON.stringify(value) +} + +function rootCall(match: ConversationMatch): RunningToolCall { + if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call') + return { + callId: String(match.event.data.callId), + name: match.event.data.name, + argsRaw: match.event.data.arguments, + turn: match.event.data.turn, + step: match.event.data.step, + time: match.event.time, + callView: match.view?.for === 'call' ? match.view.view : null, + subCalls: [], + } +} + +function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined { + if (match.event.type !== 'tool/result') return undefined + const result = match.event.data.message.content[0] + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: String(match.event.data.message.source.callId), + call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw }, + callTime: previous?.time ?? null, + content: result.content, + isError: result.isError === true, + ...match.event.data.error === undefined ? {} : { error: match.event.data.error }, + meta: match.event.data.meta, + callView: previous?.callView ?? null, + resultView: match.view?.for === 'result' ? match.view.view : null, + subCalls: [], + } +} + +interface DispatchData { + readonly parentCallId: string + readonly subCallId: string + readonly name: string + readonly arguments: unknown + readonly isError?: boolean + readonly content?: ToolResultNode['content'] +} + +function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { + return { + callId: data.subCallId, + name: data.name, + argsRaw: jsonArguments(data.arguments), + turn: locationTurn(match), + step: locationStep(match), + time: match.event.time, + callView: null, + subCalls: [], + } +} + +function childResult(match: ConversationMatch, data: DispatchData, previous?: ToolCallBlock): ToolResultNode { + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: jsonArguments(data.arguments) }, + callTime: previous?.time ?? null, + content: data.content ?? [], + isError: data.isError === true, + callView: null, + resultView: null, + subCalls: [], + } +} + +function locationTurn(match: ConversationMatch): number { + return match.location.kind === 'step' || match.location.kind === 'turn' ? match.location.turn.turn : 0 +} + +function locationStep(match: ConversationMatch): number { + return match.location.kind === 'step' ? match.location.step.step : 0 +} + +function acceptsEdge(state: ToolState, parent: string, child: string): boolean { + if (parent === child || state.parents.has(child)) return false + let cursor: string | undefined = parent + let parentDepth = 0 + const ancestors = new Set() + while (cursor !== undefined) { + if (cursor === child || ancestors.has(cursor)) return false + ancestors.add(cursor) + parentDepth++ + cursor = state.parents.get(cursor) + } + const pending = [{ callId: child, depth: 1 }] + const descendants = new Set() + let subtreeDepth = 0 + for (const candidate of pending) { + if (descendants.has(candidate.callId)) return false + descendants.add(candidate.callId) + subtreeDepth = Math.max(subtreeDepth, candidate.depth) + for (const nested of state.children.get(candidate.callId) ?? []) { + pending.push({ callId: nested.callId, depth: candidate.depth + 1 }) + } + } + return parentDepth + subtreeDepth <= MAX_DEPTH +} + +function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { + const data = match.event.data as unknown as DispatchData + const siblings = state.children.get(data.parentCallId) ?? [] + const index = siblings.findIndex(candidate => candidate.callId === data.subCallId) + if ((match.event.type as string) === 'tool/code-dispatch-start') { + if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const children = new Map(state.children) + children.set(data.parentCallId, [...siblings, childCall(match, data)]) + const parents = new Map(state.parents) + parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } + } + if ((match.event.type as string) !== 'tool/code-dispatch') return state + if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const previous = index < 0 ? undefined : siblings[index] + const settled = childResult(match, data, previous) + const children = new Map(state.children) + children.set(data.parentCallId, index < 0 + ? [...siblings, settled] + : siblings.map((child, at) => at === index ? settled : child)) + const parents = new Map(state.parents) + if (index < 0) parents.set(data.subCallId, data.parentCallId) + return { ...state, children, parents } +} + +function projectBlock( + block: ToolCallBlock, + state: ToolState, + interruptedAt: { seq: number; time: number } | undefined, + visited = new Set(), + depth = 1, +): ToolCallBlock { + if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } + const nextVisited = new Set(visited) + nextVisited.add(block.callId) + const children = (state.children.get(block.callId) ?? block.subCalls) + .map(child => projectBlock(child, state, interruptedAt, nextVisited, depth + 1)) + const interruptionSeq = 'kind' in block ? undefined : interruptedAt?.seq + const interruptionTime = 'kind' in block ? undefined : interruptedAt?.time + const cached = projectedBlocks.get(block) + if (cached !== undefined + && cached.interruptionSeq === interruptionSeq + && cached.interruptionTime === interruptionTime + && sameReferences(cached.children, children)) { + return cached.value + } + const projected: ToolCallBlock = 'kind' in block || interruptedAt === undefined + ? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children } + : { + kind: 'tool-result', + seq: interruptedAt.seq - 0.8, + time: interruptedAt.time, + callId: block.callId, + call: { name: block.name, argsRaw: block.argsRaw }, + callTime: block.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: block.callView, + resultView: null, + subCalls: children, + } + projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected }) + return projected +} + +function sameReferences(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function interruption(context: ConversationNodeContext): { seq: number; time: number } | undefined { + const location = context.start?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') && location.turn.status === 'closed') { + return location.turn.end + } + return undefined +} + +function fallbackState(context: ConversationNodeContext): ToolState | undefined { + const match = context.matches.find(candidate => candidate.event.type === 'tool/result') + const root = match === undefined ? undefined : rootResult(match) + if (root === undefined) return undefined + let state: ToolState = { root, children: new Map(), parents: new Map() } + for (const candidate of context.matches) state = updateDispatch(state, candidate) + return state +} + +/** Root Tool lifecycle and nested Code Dispatch Definition. */ +export const toolDefinition: ConversationNodeDefinition = { + kind: 'tool-call', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.message.source.callId), role: 'update' } + } + if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') { + const data = event.data as unknown as { rootCallId: string } + return { id: data.rootCallId, role: 'update' } + } + return null + }, + start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }), + update: (context, match) => { + if (match.event.type === 'tool/result') { + const running = 'kind' in context.state.root ? undefined : context.state.root + const result = rootResult(match, running) + return result === undefined ? context.state : { ...context.state, root: result } + } + return updateDispatch(context.state, match) + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const projected = projectBlock(state.root, state, interruption(context)) + const anchor = context.start?.event.seq + ?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0) + return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData) + }, +} + +/** + * Register the root Tool lifecycle and nested-subcall contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerToolConversationNode(ctx: Context): void { + ctx.conversationEvents.register(toolDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts new file mode 100644 index 0000000000..58725d8723 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -0,0 +1,112 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Terminal turn failure not superseded by retry. */ + 'turn-error': TurnErrorNode + } +} + +interface TurnErrorState { + readonly turn: number + readonly hidden: boolean + readonly failure?: { + readonly seq: number + readonly time: number + readonly message: string + readonly code?: string + } +} + +function lastStep(context: ConversationNodeContext): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return 0 + return location.turn.steps.at(-1)?.step ?? 0 +} + +function retryTurn(event: Parameters[0]): number | undefined { + if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined + const turn = (event.data as unknown as { turn?: unknown }).turn + return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined +} + +function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { + if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined + const failure = match.event.data.reason.error + return { + seq: match.event.seq, + time: match.event.time, + message: displayFailureMessage(failure), + code: failure.code, + } +} + +function fallbackState(context: ConversationNodeContext): TurnErrorState | undefined { + const end = context.matches.find(match => failureFrom(match) !== undefined) + if (end?.event.type !== 'turn/end') return undefined + const failure = failureFrom(end) + if (failure === undefined) return undefined + const turn = end.event.data.turn + return { + turn, + hidden: context.matches.some(match => retryTurn(match.event) === turn), + failure, + } +} + +/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ +export const turnErrorDefinition: ConversationNodeDefinition = { + kind: 'turn-error', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end' && event.data.reason.kind === 'error') { + return { id: String(event.data.turn), role: 'update' } + } + const turn = retryTurn(event) + return turn === undefined ? null : { id: String(turn), role: 'update' } + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start') + return { turn: match.event.data.turn, hidden: false } + }, + update: (context, match) => { + const failure = failureFrom(match) + if (failure !== undefined) return { ...context.state, failure } + return retryTurn(match.event) === context.state.turn + ? { ...context.state, hidden: true } + : context.state + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const state = context.state ?? fallbackState(context) + if (state?.failure === undefined) return null + const failure = state.failure + const node: TurnErrorNode = { + kind: 'turn-error', + seq: failure.seq, + time: failure.time, + turn: state.turn, + step: lastStep(context), + message: failure.message, + ...failure.code === undefined ? {} : { code: failure.code }, + } + if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node) + const current = context.current.get('chat') + return current === undefined || current === null + ? null + : chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' }) + }, +} + +/** + * Register the terminal Turn-error business contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnErrorConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnErrorDefinition) +} diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts new file mode 100644 index 0000000000..2a842a005d --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -0,0 +1,180 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantChatData, FinalAssistantChatData, TurnTailChatData, +} from '../contract/chat-nodes.ts' +import { deriveTurnMetrics } from '../chat/turn-metrics.ts' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Completed-turn actions and extension tail. */ + 'turn-tail': TurnTailChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Closing Assistant and footer facts derived for this completed Turn. */ + 'turn-tail': TurnTailChatData + } +} + +interface TurnTailState { + readonly turn: number + readonly end?: ConversationMatch +} + +interface StepEvidence { + readonly streamedText: boolean + readonly finalized: boolean +} + +function hasTextAssistant(event: Parameters[0]): boolean { + return event.type === 'assistant/message' + && isAppendSurfaceEvent(event) + && toAssistantBlocks(event.data.message.content) + .some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function chunkHasText(event: Parameters[0]): boolean { + if (event.type !== 'assistant/chunk') return false + const chunk = event.data.chunk + if (chunk.type === 'text-delta') return chunk.text.trim() !== '' + return chunk.type === 'block-end' + && chunk.block.type === 'text' + && chunk.block.text.trim() !== '' +} + +function turnCoordinates(event: Parameters[0]): { + readonly turn: number + readonly step?: number +} | undefined { + if (event.type === 'assistant/message' + || event.type === 'assistant/chunk' + || event.type === 'step/end') { + return { turn: event.data.turn, step: event.data.step } + } + if ((event.type as string) === 'llm/retry') { + return event.data as unknown as { turn: number; step: number } + } + return undefined +} + +function closingAnchor(context: ConversationNodeContext): number { + let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq + ?? context.start?.event.seq + ?? context.matches[0]?.event.seq + ?? 0 + const steps = new Map() + for (const match of context.matches) { + const event = match.event + if (event.type === 'turn/end') continue + const coordinates = turnCoordinates(event) + if (coordinates?.step === undefined) continue + const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false } + if (event.type === 'assistant/chunk') { + steps.set(coordinates.step, { + ...previous, + streamedText: previous.streamedText || chunkHasText(event), + }) + continue + } + if (event.type === 'assistant/message') { + steps.set(coordinates.step, { streamedText: false, finalized: true }) + if (hasTextAssistant(event)) anchor = event.seq + 0.1 + continue + } + if ((event.type as string) === 'llm/retry') { + steps.set(coordinates.step, { streamedText: false, finalized: false }) + continue + } + if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { + anchor = event.seq - 0.8 + } + } + return anchor +} + +function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { + const location = context.start?.location ?? context.matches[0]?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined +} + +function hasText(data: AssistantChatData): data is FinalAssistantChatData { + return data.finalNode !== undefined + && data.blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + +function tailData(context: ConversationNodeContext): TurnTailChatData | null { + const end = context.state?.end + ?? context.matches.find(match => match.event.type === 'turn/end') + if (end?.event.type !== 'turn/end') return null + const turn = turnLocation(context) + if (turn === undefined) return null + const assistants = turn.steps + .map(step => step.data.get('assistant-step')) + .filter((candidate): candidate is Readonly => candidate !== undefined) + const finalized = assistants + .filter((candidate): candidate is Readonly => candidate.finalNode !== undefined) + .sort((left, right) => left.finalNode.seq - right.finalNode.seq) + const closing = finalized.findLast(hasText) ?? null + const latest = finalized.at(-1) + const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn) + return { + turn: end.event.data.turn, + seq: end.event.seq, + time: end.event.time, + closing, + branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } +} + +/** Completed-turn footer Definition independent of any Assistant row. */ +export const turnTailDefinition: ConversationNodeDefinition = { + kind: 'turn-tail', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } + const coordinates = turnCoordinates(event) + if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start') + return { turn: match.event.data.turn } + }, + update: (context, match) => match.event.type === 'turn/end' + ? { ...context.state, end: match } + : context.state, + publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none', + buildLocationData: (context, scope) => { + if (scope !== 'turn') return null + const value = tailData(context) + return value === null ? null : { + kind: 'turn', + turn: value.turn, + key: 'turn-tail', + value, + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat') return null + const turn = turnLocation(context) + const data = turn?.data.get('turn-tail') + return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) + }, +} + +/** + * Register completed-Turn footer data and its Chat node contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnTailConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnTailDefinition) +} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a19cd77753..b4d7467170 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -5,6 +5,17 @@ */ export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' +export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts' +export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts' +export { registerCommandConversationNode } from './conversation-nodes/command.ts' +export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts' +export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts' +export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts' +export { registerMessageConversationNode } from './conversation-nodes/message.ts' +export { registerRetryConversationNode } from './conversation-nodes/retry.ts' +export { registerToolConversationNode } from './conversation-nodes/tool.ts' +export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts' +export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts' export type { IConversation } from './service.ts' export type { @@ -12,12 +23,16 @@ export type { } from './contract/views.ts' export type { ConversationKey } from './locales.ts' export type { - ChatFileMentions, + AssistantChatData, ChatNode, ChatNodeDataMap, ChatNodeKind, ManualCompactionChatData, + RetryChatData, ToolChatData, TurnTailChatData, +} from './contract/chat-nodes.ts' +export type { + ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, - ToolTreeOwnerProps, TurnTailOwnerProps, + TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx index a1a0a120f5..98e98f0f01 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -17,6 +17,7 @@ import { useMemo, useState } from 'react' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' +import { rootToolCall } from '../chat/tool-node-reader.ts' import css from './ApprovalPanel.module.css' /** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ @@ -40,8 +41,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined */ export function ApprovalPanel(props: ApprovalComposerProps) { const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) - const command = props.useSession(s => commandOf( - approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) + const command = props.useSession((snapshot) => { + if (approval.callId === undefined) return undefined + const root = rootToolCall(snapshot, approval.callId) + if (root === undefined) return undefined + return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined + }) return } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index a04e9ba9e8..1caefd2ecf 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -12,6 +12,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { findToolCall } from '../chat/tool-node-reader.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ @@ -40,30 +41,10 @@ function runningMaterial(call: RunningToolCall): CallMaterial { return { name: call.name, argsRaw: call.argsRaw, block: call } } -function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined { - if (block.callId === callId) return block - for (const child of block.subCalls) { - const found = findCall(child, callId) - if (found !== undefined) return found - } - return undefined -} - function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { - for (const node of s.nodes) { - if (node.kind !== 'tool-result') continue - const found = findCall(node, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - for (const root of s.runningCalls) { - const found = findCall(root, callId) - if (found !== undefined) { - return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) - } - } - return null + const found = findToolCall(s, callId) + if (found === undefined) return null + return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found) } function pretty(raw: string): string { diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index f9a7d46553..66c54081ab 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -17,7 +17,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the conversation service emits no cordis events, and * both rings this package owns (the 'conversation.view' tab ring and the - * 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger + * 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger * invariants live with the runtime slots package. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 81b2f61b79..fb8bdd0224 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,9 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' +import { + deliverablesDefinition, producedFileMentions, selectProducedFiles, +} from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -25,13 +27,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale'] +export const inject = ['slots', 'locale', 'conversationEvents'] /** * Client plugin body: register the dictionaries and the turn-tail entry. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( 'conversation.chat.turnTail', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 4316ddf8e6..0227b34261 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -1,12 +1,37 @@ /** - * Pure derivation of one turn's produced files from finalized snapshot - * nodes. Client-only and model-free: the vocabulary is the mutation tools' - * own follow-along `locations`, never the closing prose. + * Turn-scoped produced-file Definition and readers. Client-only and + * model-free: the vocabulary is the mutation tools' own follow-along + * `locations`, never the closing prose. */ -import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationNodeDefinition, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +interface ProducedPath { + readonly seq: number + readonly path: string +} + +/** Immutable produced-file facts published against one Turn. */ +export interface DeliverablesTurnData { + readonly produced: readonly ProducedPath[] +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationTurnDataMap { + /** Successful mutation paths accumulated in this Turn. */ + deliverables: DeliverablesTurnData + } +} + +interface DeliverablesState extends DeliverablesTurnData { + readonly turn: number + readonly calls: ReadonlyMap +} + /** * Paths a call view reports having created or changed, by render intent rather * than tool name: a diff card, or a generic card whose kind is `edit` (the @@ -23,9 +48,7 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { } /** - * Files produced by the turn the assistant at `seq` closes — the anchor the - * render site elects, so the row lands under the message that reports the - * work rather than after some mid-turn narration. + * Files produced by one Turn data value. * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model @@ -37,46 +60,26 @@ function producedPaths(view: ToolResultNode['callView']): readonly string[] { * failed calls. Paths keep first-seen order and appear once, so a file written * and then edited in the same turn is one entry. * - * Accumulation resets on the turn boundary — a user message, or a node - * reporting a different turn number — so a turn that mutates files and then - * ends without content text cannot spill its paths into the next turn's row, - * nor leave the dedup set suppressing a file the next turn legitimately - * rewrites. Tool results carry no turn of their own; the boundary is read off - * the nodes that do, and a user message resets the tracked turn to undefined - * because the next node to report one is stating the current turn, not - * entering a new one. - * @param nodes - snapshot nodes (surface order). - * @param seq - the closing assistant's seq (the render site's anchor). + * The Conversation Location index owns turn membership before this function + * runs, so paths cannot spill across turns and this derivation does not infer + * boundaries from neighboring presentation Nodes. + * @param data - engine-published Deliverables data for one Turn. + * @param seq - closing Assistant seq; later Tool settlements are excluded. * @returns Produced paths in first-seen order; empty when the turn wrote nothing. */ -export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { - let pending: string[] = [] - let seen = new Set() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind === 'assistant' && node.seq === seq) return pending +export function producedForClosing( + data: Readonly | undefined, + seq = Number.POSITIVE_INFINITY, +): readonly string[] { + if (data === undefined) return [] + const paths: string[] = [] + const seen = new Set() + for (const produced of data.produced) { + if (produced.seq > seq || seen.has(produced.path)) continue + seen.add(produced.path) + paths.push(produced.path) } - return [] + return paths } /** @@ -85,11 +88,55 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @returns Produced paths as the component's match, or null to decline before mount. */ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { - const { nodes, seq } = owner - const paths = producedForClosing(nodes, seq) + const paths = producedForClosing(owner.turn.data.get('deliverables'), owner.seq) return paths.length === 0 ? null : paths } +/** Turn-local successful mutation accumulator; it publishes no view Node. */ +export const deliverablesDefinition: ConversationNodeDefinition = { + kind: 'deliverables', + match: (event) => { + if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } + if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { + return { id: String(event.data.turn), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'turn/start') throw new Error('deliverables start requires turn/start') + return { turn: match.event.data.turn, calls: new Map(), produced: [] } + }, + update: (context, match) => { + if (match.event.type === 'tool/call') { + const calls = new Map(context.state.calls) + calls.set( + String(match.event.data.callId), + match.view?.for === 'call' ? match.view.view : null, + ) + return { ...context.state, calls } + } + if (match.event.type !== 'tool/result') return context.state + const result = match.event.data.message.content[0] + if (result.isError === true) return context.state + const callId = String(match.event.data.message.source.callId) + const additions = producedPaths(context.state.calls.get(callId) ?? null) + .map(path => ({ seq: match.event.seq, path })) + return additions.length === 0 + ? context.state + : { ...context.state, produced: [...context.state.produced, ...additions] } + }, + buildLocationData: (context, scope) => scope !== 'turn' || context.state === undefined + ? null + : { + kind: 'turn', + turn: context.state.turn, + key: 'deliverables', + value: { produced: context.state.produced }, + }, + buildViewNode: () => null, +} + /** * Trailing path segment, the part that identifies the file at a glance. * @param path - Slash- or backslash-separated path. diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 48a9a4c812..ec2f0b8ec1 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -20,8 +20,9 @@ export const inject = ['slots'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({ - name: 'conversation.chat.tool', + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'tool-call', locale: NS, children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 4b74055b2c..26039efc6f 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -30,8 +30,8 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> -/** Full props of the Tool call-tree renderer registered into the chat flow. */ -export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'> +/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ +export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 278be7dacf..db3ed0af06 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -88,8 +88,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, }: ToolTreeProps) { + const block = node.data.root return ( Date: Sun, 9 Aug 2026 15:48:56 +0800 Subject: [PATCH 05/20] test: update conversation assembly coverage and docs --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 27 +- ...26-07-19-gui-web-client-architecture.zh.md | 29 +- ...ient-tool-presentation-ownership.i18n.yaml | 4 +- ...8-08-client-tool-presentation-ownership.md | 101 +- ...8-client-tool-presentation-ownership.zh.md | 103 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 2 +- ...eb-transcript-log-ordered-projection.zh.md | 2 +- apps/web/tests/built-boot.snapshot.ts | 1 - .../tests/chat-continuous-conversation.e2e.ts | 20 +- apps/web/tests/chat-long-interactions.e2e.ts | 49 +- apps/web/tests/search-card.snapshot.ts | 4 +- apps/web/tests/seeded-history.e2e.ts | 17 +- apps/web/tests/skill-user-invoke.e2e.ts | 2 + .../snapshots/code-mode-round/session.jsonl | 8 +- apps/web/tests/todo-row.snapshot.ts | 2 +- docs/config-catalog.md | 4 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 249 ++--- docs/persistence-catalog.md | 54 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 6 +- docs/subsystems/commands.zh.md | 6 +- docs/subsystems/compaction.i18n.yaml | 4 +- docs/subsystems/compaction.md | 11 +- docs/subsystems/compaction.zh.md | 11 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 9 +- docs/subsystems/tools.zh.md | 9 +- .../advanced-toolchain/session.jsonl | 4 +- .../snapshots/both-mode-turn/session.jsonl | 4 +- .../snapshots/code-mode-turn/session.jsonl | 8 +- .../code-mode-workspace-context/session.jsonl | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../empty-response-retry/session.jsonl | 19 +- .../advanced-toolchain/session.jsonl | 4 +- .../stream-json.expected.jsonl | 4 +- .../compaction-recovery/session.jsonl | 20 +- .../stream-json.expected.jsonl | 8 +- .../provider-retry/stream-json.expected.jsonl | 19 +- packages/bash/bash-env/tests/bash-env.spec.ts | 1 + .../client/connection/src/client/fixture.ts | 48 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 10 +- packages/client/runtime/README.zh.md | 10 +- .../client/runtime/tests/client-apply.spec.ts | 29 +- .../tests/compact-checkpoint-pin.spec.ts | 52 - .../tests/conversation-assembler.spec.ts | 892 ++++++++++++++++++ .../tests/conversation-registry.spec.ts | 126 +++ packages/client/runtime/tests/event-script.ts | 4 +- .../client/runtime/tests/queue-store.spec.ts | 1 - packages/client/runtime/tests/session.spec.ts | 808 ++++------------ .../runtime/tests/transcript-adapter.spec.ts | 567 ----------- packages/client/test-runtime/src/fixtures.ts | 2 + packages/client/test-runtime/src/index.ts | 8 +- .../ui-conversation/tests/chat-apply.spec.tsx | 10 +- .../tests/chat-branch-tails.spec.tsx | 161 ++-- .../tests/chat-snapshot-fixture.ts | 295 ++++++ .../ui-conversation/tests/chat-stats.spec.tsx | 28 +- .../ui-conversation/tests/chat-view.spec.tsx | 487 ++++------ .../conversation-node-definitions.spec.ts | 720 ++++++++++++++ .../tests/gate-branch-tails.spec.tsx | 20 +- .../ui-conversation/tests/input-bar.spec.tsx | 5 +- .../tests/input-matrix.spec.tsx | 5 +- .../tests/input-scenarios.spec.tsx | 5 +- .../ui-conversation/tests/queue-dock.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 5 +- .../tests/produced-files.spec.tsx | 284 ++++-- .../client/ui-slots/tests/surface.spec.ts | 11 + .../client/ui-slots/tests/type-chain.spec.tsx | 60 +- .../ui-tool/tests/assembly-surfaces.spec.tsx | 3 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 10 +- .../client/ui-tool/tests/diff-card.spec.tsx | 7 +- .../client/ui-tool/tests/read-card.spec.tsx | 7 +- .../client/ui-tool/tests/search-card.spec.tsx | 7 +- .../ui-tool/tests/terminal-card.spec.tsx | 7 +- .../ui-tool/tests/tool-call-tree.spec.tsx | 15 +- .../ui-tool/tests/tool-details-render.tsx | 45 +- .../ui-tool/tests/toolview-slot.spec.tsx | 3 +- .../client/ui-tool/tests/web-card.spec.tsx | 7 +- .../web-react/tests/scoped-slots.spec.tsx | 77 +- .../tests/command-compact.spec.ts | 24 +- .../tests/loader-composition.spec.ts | 30 +- .../compact-basic/tests/compact-basic.spec.ts | 8 +- .../tests/manual-compact.spec.ts | 47 +- .../compact/compact/tests/compact.spec.ts | 15 +- .../compact/compact/tests/invariant.spec.ts | 194 +++- .../tests/workspace-context.spec.ts | 6 +- packages/core/tools/tests/code-mode.spec.ts | 5 +- packages/core/tools/tests/invariant.spec.ts | 105 ++- .../agent-spine-demo/tests/agent-core.spec.ts | 1 + .../llm/llm-retry/tests/invariant.spec.ts | 73 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 + packages/llm/llm-retry/tests/retry.spec.ts | 2 + .../context-breakdown-projection.spec.ts | 2 + .../tests/token-usage-projection.spec.ts | 2 + .../tool-cordis/src/api-catalog.ts | 16 +- .../llm-replay/tests/llm-replay.spec.ts | 16 +- pnpm-lock.yaml | 18 +- scripts/gen-cordis-catalog.ts | 1 + 101 files changed, 3970 insertions(+), 2295 deletions(-) delete mode 100644 packages/client/runtime/tests/compact-checkpoint-pin.spec.ts create mode 100644 packages/client/runtime/tests/conversation-assembler.spec.ts create mode 100644 packages/client/runtime/tests/conversation-registry.spec.ts delete mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts create mode 100644 packages/client/ui-conversation/tests/chat-snapshot-fixture.ts create mode 100644 packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b7e331c934..f0cc648a10 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 7567ac3cb8b1a580e145f8f0da49b6ec371a35bd -2026-07-19-gui-web-client-architecture.zh.md: 9b682febf1bd1aad1a767b8b2db4e83dd993cf2f +2026-07-19-gui-web-client-architecture.md: 5e927405e9012b5a56d82da8fcf600ca4aa1bd5b +2026-07-19-gui-web-client-architecture.zh.md: 4294af099dce4e1345e625694a054661483f8d12 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 7567ac3cb8..5e927405e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,35 +44,35 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. +There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). ## The data object layer (`packages/client/runtime/src/client/sessions/`) -Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable): +Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references. +- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. -- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. +- **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. - **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering RFC's territory. ## The React face (`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -109,13 +110,13 @@ Domain implementation files never import a sibling domain; shared surfaces route - **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically. - **A new slot**: see the [slot system standard RFC](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally. -- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept. +- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch. - **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)). - **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`. ## Consequences -Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. +Token streams no longer shake the render tree: Assistant chunks update one business Context and publish its keyed Node at most once per animation frame; unrelated rows' selector results retain their references, so those rows do not re-render. UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 9b682febf1..4294af099d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,35 +44,35 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`;ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。 +slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件和 view registry 是数据组装缝,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.md))。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 ## 数据对象层(`packages/client/runtime/src/client/sessions/`) -帧从这里进、快照从这里出、fold 坐在中间——React-free(零 React import,grep 可断言): +帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host 帧(ConnectionController 泵入,sinks 注入) +mux/host frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope - │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲) + │ session frames target existing instances (requested waits buffer) ▼ -Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) - │ │ 定稿事件 │ chunk - │ ▼ ▼ - │ TranscriptAdapter PartialAccumulator - │ (→ nodes) (→ partial) +Session.handleMuxEnvelope ──► contiguous Event window + │ │ replace / prepend / append + │ ▼ + │ ConversationNodeAssembler + │ Definitions -> Contexts -> view builders ▼ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):不可变快照约定——`nodes`(人类对话记录,日志序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用。 +- **ConversationSnapshot**(conversation.ts):顶层不可变快照契约。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费者使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 -- **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 +- **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。 - **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.md)载两个 server→client 象限,客户端类族归分层 RFC 属地。 ## React 面(`packages/client/web-react`) @@ -95,6 +95,7 @@ src/client/ contract/ shared slot and cross-domain types service.ts cross-domain orchestration skeleton/ conversation shell and details host + conversation-nodes/ independently registered business Definitions and Chat builder chat/ ordered conversation view input/ composer state machine queue/ queued-message presentation @@ -108,14 +109,14 @@ src/client/ ## 怎么开发 - **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。 -- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 -- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。 +- **新 slot**:见 [slot 体系标准 RFC](2026-07-22-slot-type-chain-implementation.md)——契约合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。 +- **消费新帧类型**:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。 - **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。 - **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。 ## Consequences -token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批)。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 +token 流不再震荡渲染树:Assistant chunk 只更新一个业务 Context,每 animation frame 最多发布一次对应 keyed Node;无关行的 selector 结果保持原引用,因此不会重渲染。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index 12e27bf58c..5cb66115e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml @@ -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-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: f12a116c34064186b6be1496e1071025221817c4 -2026-08-08-client-tool-presentation-ownership.zh.md: f5f7d71f1e05b27855d29acc223b340c8d89f1d8 +2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8 +2026-08-08-client-tool-presentation-ownership.zh.md: 031975fdebf5f72a19396f16b086a3abdcbca172 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index f12a116c34..3feefc3cfb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -6,100 +6,63 @@ English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md) ## Problem -The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel. +Client Runtime already paired Tool call/result events by `callId` and could recover root/subcall topology from Code Dispatch events, but the Chat view also owned Tool placement in the conversation flow, recursive call-tree composition, Tool-name dispatch, the Generic fallback, card models, and first-party Tool renderers. `ui-conversation` therefore had to interpret every business Tool name; moving individual React components did not change that ownership, and removing atomic renderers left subcalls without a presentation owner. -That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place. +Tool presentation needed an independent owner without adding a second registry beside Client slots or making every atomic Tool renderer understand root/subcall structure. ## Decision -Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map. +Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-ui-tool` owns root/subcall composition, atomic renderer dispatch by wire Tool name, the Generic fallback, card models, and details output. Business plugins register only their atomic Tool renderers and do not modify conversation or Session. -“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. +Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names. -`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists. -`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology. +A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools. -Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this slot. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. - -The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. - -The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation contract, and this package boundary adds no Tool projector/fold registry. +The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent. ## Runtime and render path -This boundary starts at the Client's `ConversationSnapshot`; the full render path is: - ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing. +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree. - -## Code and responsibility boundaries - -| Owner | Primary code | Owns | Explicitly does not own | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | -| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | -| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition | - -## Slot and owner contract - -A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots. - -The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions. - -The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers. - -Business plugins use one registration shape: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session. - -## Details path - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. +| Owner | Owns | Explicitly does not own | +|---|---|---| +| Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer | +| `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure | +| `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers | +| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering | +| Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors | ## Verification -Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle. +`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded. ## Alternatives considered -**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions. +**Keep atomic Tool slots under every conversation view.** Rejected: every view would repeat root/subcall composition and Tool registration would split by view. The whole Tool renderer occupies one business Node slot in a view, while Tool owns atomic dispatch. -**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility. +**Move only Tool React components and card models.** Rejected: conversation would still dispatch by Tool name and recurse through subcalls, so file movement would not create an ownership boundary. -**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. +**Create a Tool-specific projector/fold registry.** Rejected: the general Conversation assembler already owns Context identity, history windows, and publication. A second Runtime registry would create two lifecycle authorities. -**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`. +**Let every atomic Tool renderer recurse through its subcalls.** Rejected: an atomic registrant should understand one Tool call without knowing whether it is a root or child. `ToolCallTree` handles recursive structure once. -**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. +**Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior. ## Consequences -`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot contracts; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction. +`ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results. + +The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index f5f7d71f1e..031975fdeb 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -6,100 +6,63 @@ Status: implemented ## Problem -Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。 +Client Runtime 已经按 `callId` 配对 Tool call/result,并能从 Code Dispatch 事件恢复 root/subcall 拓扑,但 Chat view 曾同时拥有 Tool 在对话流中的放置、递归调用树、按 Tool 名称分发、Generic fallback、card model 和第一方 Tool renderer。`ui-conversation` 因此必须解释每个业务 Tool 名称;只移动单个 React 组件不会改变这层所有权,移走原子 renderer 后 subcall 也会成为无主逻辑。 -这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排约定。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。 +Tool presentation 需要一个独立所有者,同时不能建立与 Client slot 平行的第二套注册表,也不能让每个原子 Tool renderer 自己理解 root/subcall 结构。 ## Decision -Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。 +Tool 是 Client UI 的一级展示概念,由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有 root/subcall 编排、按 wire Tool name 的原子 renderer 分发、Generic fallback、card model 和 details output。业务插件只注册自己的原子 Tool renderer,不修改 conversation 或 Session。 -这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 +Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.md)。`ui-conversation` 的 Tool Definition 从 Session Event 配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方 Tool identity 和拓扑,不解释具体 Tool 名称的展示。 -`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat snapshot 的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。 -`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。 +业务 Tool 插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取 Session、Context 或 Conversation assembler。Skill 仍是普通 Tool;它和其他业务 Tool 使用同一 keyed slot 注册路径。 -业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 slot 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 +details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body;`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。 -details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 - -Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示约定,这个包边界不会增加 Tool projector/fold registry。 - -## Runtime 与渲染链路 - -这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下: +## Runtime and render path ```text -ConversationSnapshot.nodes - -> deriveChatFlow() - -> settled tool-group positions ----+ - | -ConversationSnapshot.runningCalls | - -> ChatView flow tail ---------------+-> ToolSeat - -> conversation.chat.tool - -> ToolCallTree - -> root ToolCallBlock - `- subCalls[] (recursive) - -> tool.call.toolview(entryKey = toolName) - |- registered atomic view - `- GenericToolCard fallback +Session Event window + -> Tool Definition -> tool-call Chat Node (recursive ToolCallBlock) + -> ChatView -> ChatNodeSeat(entryKey = tool-call) + -> ToolCallTree + -> root/subCalls[] recursion + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback ``` -Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。 +## Ownership boundary -[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。 - -## 代码与职责边界 - -| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | -|---|---|---|---| -| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 | -| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | -| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | -| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | -| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 | - -## Slot 与 owner 约定 - -slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。 - -整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。 - -席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 约定:`data-chat-anchor-key="call:"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。 - -业务插件遵循同一个注册形态: - -```text -ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ - name: 'tool.call.toolview', - key: '', - }, BusinessToolRow)) -``` - -`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。 - -## Details 路径 - -[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 +| 所有者 | 拥有 | 明确不拥有 | +|---|---|---| +| Client Runtime Conversation engine | Context identity、Location、历史重放、view Node 发布 | Tool 事件含义、调用树、Tool renderer | +| `ui-conversation` Tool Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | Tool 名称分发、card model、递归 React 结构 | +| `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | Tool lifecycle、subcall 组合、原子 Tool renderer | +| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | Session Event fold、Chat 排序 | +| 业务 Tool 插件 | 一个或多个 wire Tool name 的原子 renderer | root/subcall 位置、生命周期配对、Session projector | ## Verification -测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主约定;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。 +`ui-conversation` 测试固定 Tool Definition 的 call/result、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体 Tool card。组装后的 Web 测试覆盖两侧插件共同装载的路径。 ## Alternatives considered -**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。 +**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都要重复 root/subcall 编排,Tool 注册也会按 view 分裂。整个 Tool renderer 占据 view 的一个业务 Node slot,原子分发由 Tool 自己拥有。 -**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。 +**只移动 Tool React 组件与 card model。** 拒绝:conversation 仍会按 Tool 名称分发并递归 subcall,文件位置变化不产生所有权边界。 -**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 +**为 Tool 建立专属 projector/fold registry。** 拒绝:通用 Conversation assembler 已拥有 Context identity、历史窗口和发布;第二个 Runtime registry 会制造生命周期的双重权威。 -**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。 +**让每个原子 Tool renderer 递归自己的 subcall。** 拒绝:原子注册方只应理解一个 Tool call,不应知道自己是 root 还是 child。递归结构统一由 `ToolCallTree` 处理。 -**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 +**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转 feature 依赖并把 Tool 展示变成必选能力。slot 保留独立装载、生命周期和 fallback。 ## Consequences -`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两份跨包 slot 约定;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。 +`ui-conversation` 不再依赖 Tool 名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子 Tool renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallback,details 保留 raw result。 + +代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个 Tool 专属子 slot。Tool Definition 暂时位于 `ui-conversation`,因为本次没有拆 package;它以后可以沿 Conversation registry seam 移动,而不会改变本 Note 规定的展示所有权。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 231502dcb7..1ea341085d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: e261ccba597f27149a9527f99b93454dfe5fe5fb -2026-07-30-web-transcript-log-ordered-projection.zh.md: 5684a8157bb7e1b23cd868fa13ab201ffd07a274 +2026-07-30-web-transcript-log-ordered-projection.md: feefc951118c8511237c931a13070e1f7db1fd16 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 711e57b29742171d1861f25e63019b64356b4cad diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index e261ccba59..feefc95111 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it. +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it. The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 5684a8157b..711e57b297 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -39,7 +39,7 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 -`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。 +`packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 80661a0b13..be719bda15 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -38,7 +38,6 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) - // Resolve the resident approval so the ordinary composer bar (which owns // ContextMeter) resumes without replacing the session shell. This minimal // boot graph intentionally does not mount the separate question UI plugin. diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 61701c1cdc..a7b659dab2 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -12,6 +12,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { launchWebScaffold, watchConsole, @@ -160,6 +161,14 @@ function toolResultText(event: Extract): .join('') } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + describe('web e2e: continuous conversation grown through the composer', () => { let browser: Browser let page: Page @@ -222,6 +231,11 @@ describe('web e2e: continuous conversation grown through the composer', () => { const settled = scaffold.whenTurnSettled(60_000) await page.getByRole('button', { name: 'Send message', exact: true }).click() await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) + await expect.poll(() => sessionEvents.slice(eventStart).some(event => ( + event.type === 'user/message' + && event.data.source.kind === 'user' + && userText(event).includes(spec.userMarker) + )), { timeout: 15_000 }).toBe(true) const echoedUser = sessionEvents.slice(eventStart).find( (event): event is SessionEvent<'user/message'> => ( event.type === 'user/message' @@ -230,7 +244,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { ), ) if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`) + const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`) await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1) expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user') expect(await userRow.textContent()).toContain(spec.userMarker) @@ -274,9 +288,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } }) expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9)) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`) await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1) - expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await assistantRow.textContent()).toContain(spec.doneMarker) const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call') diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index b9bebbd897..cd1fcb2fa7 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, @@ -115,6 +116,18 @@ function requiredEvent( return event } +function messageKey(event: SessionEvent<'user/message'>): string { + return conversationContextKey('input-message', String(event.data.id)) +} + +function assistantKey(event: SessionEvent<'assistant/message'>): string { + return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`) +} + +function turnTailKey(turn: number): string { + return conversationContextKey('turn-tail', String(turn)) +} + describe('web e2e: long Chat interaction contract', () => { let browser: Browser let page: Page @@ -176,8 +189,10 @@ describe('web e2e: long Chat interaction contract', () => { const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) - const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`) - const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`) + const toolUserKey = messageKey(toolUserEvent) + const toolAssistantKey = assistantKey(toolAssistantEvent) + const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`) + const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`) const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`) const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`) @@ -186,28 +201,27 @@ describe('web e2e: long Chat interaction contract', () => { expect(await call1.count()).toBe(1) expect(await call2.count()).toBe(1) expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user') - expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') + expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step') expect(await toolUserRow.textContent()).toContain(toolUserMarker) expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker) expect(await call1.textContent()).toContain(toolMarker1) expect(await call2.textContent()).toContain(toolMarker2) const expectedOrder = [ - `node:${String(toolUserEvent.seq)}`, - `call:${TARGET_CALL_1}`, - `call:${TARGET_CALL_2}`, - `node:${String(toolAssistantEvent.seq)}`, + toolUserKey, + conversationContextKey('tool-call', TARGET_CALL_1), + conversationContextKey('tool-call', TARGET_CALL_2), + toolAssistantKey, ] const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => ( rows.map(row => (row as HTMLElement).dataset.chatAnchorKey) .filter((key): key is string => key !== undefined && keys.includes(key)) ), expectedOrder) expect(actualOrder).toEqual(expectedOrder) - const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => ( - element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null + const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => ( + element.closest('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null )))) - expect(groupKeys[0]).not.toBeNull() - expect(groupKeys[1]).toBe(groupKeys[0]) + expect(toolKinds).toEqual(['tool-call', 'tool-call']) const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') @@ -219,9 +233,12 @@ describe('web e2e: long Chat interaction contract', () => { expect(await summary1.getAttribute('aria-expanded')).toBe('false') await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 }) - await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100) - const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`) - const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`) + const branchUserKey = messageKey(branchUserEvent) + const branchAssistantKey = assistantKey(branchAssistantEvent) + await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100) + const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`) + const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`) + const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`) expect(await userRow.textContent()).toContain(branchUserMarker) expect(await assistantRow.textContent()).toContain(branchAssistantMarker) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) @@ -230,8 +247,8 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) .toBe(expectedUserText) - await assistantRow.hover() - await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() + await turnTailRow.hover() + await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), { timeout: 15_000 }, diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts index 8e6322c4af..8ac775be1d 100644 --- a/apps/web/tests/search-card.snapshot.ts +++ b/apps/web/tests/search-card.snapshot.ts @@ -2,7 +2,7 @@ // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless // FixtureApiClient transport (no API key, no model round), opens the fixture -// session, and pins the search card the `grep` turn (fixture turn 66) renders in +// session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // carries no behavior assertions by contract; this is the assembled-output check // that a broken SearchRow registration or a dropped card would fail — the @@ -51,7 +51,7 @@ describe('assembled search card', () => { const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) fireEvent.click(await within(tree).findByText('Fixture 历史会话')) // Wait for chat content to reach the fixture's later turns (the bash sample - // is turn 65, the grep card turn 66). + // is turn 66, the grep card turn 67). await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 072a322fb6..8a9ee26866 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -91,11 +91,15 @@ function withCompaction(raw: string, meter: TokenMeterService): string { return taken } const commandId = 'cmd-seeded-manual-compact' + const compactionId = 'compact-seeded-manual-compact' at({ type: 'command/run', data: { commandId, name: 'compact', args: '', source: { kind: 'user' } }, }) - const startSeq = at({ type: 'compact/start', data: { turn: null } }) + const startSeq = at({ + type: 'compact/start', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) // Load-bearing exactness: the projections subtract this count verbatim, so // it must equal what the host's fold prices for these nodes. The estimator // prices message CONTENT only, so a minimal wrapper per storage shape is @@ -124,6 +128,8 @@ function withCompaction(raw: string, meter: TokenMeterService): string { const summarySeq = at({ type: 'compact/summary', data: { + compactionId, + sourceCommandId: commandId, summary: [{ type: 'text', text: '## Cold resume compact summary\n\n- The exact summary remains available.', @@ -142,12 +148,17 @@ function withCompaction(raw: string, meter: TokenMeterService): string { type: 'text', text: 'Model-only compact checkpoint.', }], - source: { kind: 'plugin', plugin: 'compact' }, + source: { + kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId, + }, }, surfaceOp: { op: 'replace', start: first, end: last }, sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], }) - at({ type: 'compact/end', data: { turn: null } }) + at({ + type: 'compact/end', + data: { compactionId, sourceCommandId: commandId, turn: null }, + }) at({ type: 'command/done', data: { diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index 302aa062b0..8555ef7499 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -109,6 +109,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro { timeout: 10_000 }, ).toBe(1) + const settled = scaffold.whenTurnSettled() await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) await composer.press('Enter') @@ -135,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro // The injection started a turn; the replay adapter answers it. await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + await settled const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl index 9ff7af0110..09e2432e94 100644 --- a/apps/web/tests/snapshots/code-mode-round/session.jsonl +++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl @@ -14,10 +14,10 @@ {"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} {"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}} -{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} -{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} -{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} -{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} +{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} +{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}} +{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}} {"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"} {"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}} {"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}} diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c05057dddc..c5ded143b3 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 71, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5f3bc744b2..22e21edc0b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -917,7 +917,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:46`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:33`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -2325,7 +2325,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:623`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5b36725402..a8263b70c1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:174`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index e7d6b47e70..767ebfcfde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,9 +436,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_compact --> pkg_invariants - pkg_compact --> pkg_llm - pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -502,15 +499,11 @@ flowchart TD pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout - pkg_token_meter --> pkg_compact - pkg_token_meter --> pkg_invariants - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -550,10 +543,6 @@ flowchart TD pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session - pkg_llm_replay --> pkg_compact - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -674,14 +663,11 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_command_compact --> pkg_commands - pkg_command_compact --> pkg_compact - pkg_command_compact --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_compact - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session - pkg_compact_tool_result_prune --> pkg_token_meter + pkg_compact --> pkg_brand + pkg_compact --> pkg_commands + pkg_compact --> pkg_invariants + pkg_compact --> pkg_llm + pkg_compact --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -703,13 +689,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -742,6 +721,11 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_token_meter --> pkg_compact + pkg_token_meter --> pkg_invariants + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -790,13 +774,9 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_command_compact --> pkg_commands + pkg_command_compact --> pkg_compact + pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -857,33 +837,10 @@ flowchart TD pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_client_ui_primitives - pkg_client_ui_tool --> pkg_client_ui_slots - pkg_client_ui_tool --> pkg_invariants + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -990,6 +947,11 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1038,50 +1000,14 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_token_meter pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1105,6 +1031,14 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_commands + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_sdk_protocol @@ -1120,6 +1054,50 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1161,6 +1139,33 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1238,7 +1243,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1255,8 +1259,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1267,7 +1270,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1295,19 +1297,18 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | @@ -1315,7 +1316,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1325,10 +1326,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1347,6 +1345,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1355,20 +1354,26 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 614f6afbeb..76487ca3d4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interaction/commands/src/index.ts) #### `command/run` — log-only @@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:151`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interaction/commands/src/index.ts) ### `compact/*` @@ -241,10 +241,10 @@ Source: [`packages/interaction/commands/src/index.ts:144`](../packages/interacti * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ -'compact/end': { turn: number | null; error?: string } +'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -278,10 +278,10 @@ Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ -'compact/start': { turn: number | null } +'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -296,6 +296,8 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -331,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:31`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -412,29 +414,19 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- ```ts persistence-catalog /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ -'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure -} | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure -} +'llm/retry': LlmRetryEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src/index.ts) + +#### `llm/retry-started` — log-only + +```ts persistence-catalog +/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ +'llm/retry-started': LlmRetryStartedEventData +``` + +Source: [`packages/llm/llm-retry/src/index.ts:22`](../packages/llm/llm-retry/src/index.ts) ### `permission/*` @@ -656,7 +648,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } ``` Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) @@ -679,7 +671,7 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } ``` Types: [CallId](subsystems/core.md) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 5bc05d2916..9466165680 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -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/commands.md -commands.md: c71c19b8a3f94ba468e6871d5658c4eca1c6276d -commands.zh.md: 3f1df0e8aa40524160c8f770793b6064dd5988ae +commands.md: 7f4e4e87d7206be3e46c7076ee5e8ab6a7589f9a +commands.zh.md: 0f8ff31bca5f62824912f53aee8dbc32db4c331b diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index c71c19b8a3..7f4e4e87d7 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -49,6 +49,8 @@ The adapter owns cancellation and passes the exact target agent. `rawInput` begi ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 3f1df0e8aa..0f8ff31bca 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -49,6 +49,8 @@ interface CommandDefinition { ```ts type-equiv /** Invocation passed to one registered command handler. */ interface CommandInvocation { + /** Pairing id already written to this invocation's `command/run` event. */ + readonly commandId: CommandId /** Exact agent whose human-facing surface received the command. */ readonly agent: Agent /** Exact text following the registered command name, including separator whitespace. */ @@ -159,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -181,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:172`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 6445fa5d5c..fd8dbdd1b5 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -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/compaction.md -compaction.md: 942cc740db6b7006952bfc33a39ea9826d673e61 -compaction.zh.md: c5b65a95f9c0db8b02912c68ef93edce2ceb7c43 +compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70 +compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 942cc740db..1aca48d677 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -29,6 +29,10 @@ What a successful compaction returns to its caller: the bookkeeping-event seqs, ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index c5b65a95f9..e2cfabc089 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -29,6 +29,10 @@ ```ts type-equiv /** Result of a successful compaction operation. */ interface CompactionResult { + /** Stable identity shared by this compaction's complete durable lifecycle. */ + compactionId: CompactionId + /** Human command that initiated this compaction, when it was manual. */ + sourceCommandId?: CommandId /** The seq of the appended `compact/start` event. */ startSeq: number /** The seq of the appended `compact/summary` event. */ @@ -155,13 +159,14 @@ abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger * * @param agent - idle agent whose durable history should be compacted. * @param signal - cancellation scoped to this compaction request. + * @param sourceCommandId - initiating command identity for a manual compaction. * @returns the compaction result, or `null` when no safe useful range exists. * @throws {@link ManualCompactionError} for expected busy, agent-cancellation, * changed-span, summarization/shrink, commit-stage, or persistence failures; * an aborted request preserves its exact abort reason. Failed attempts remain * visible in the log. */ -abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise +abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -184,7 +189,9 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): P abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Source: [`packages/compact/compact/src/index.ts:93`](../../packages/compact/compact/src/index.ts) +Types: [CommandId](commands.md) + +Source: [`packages/compact/compact/src/index.ts:96`](../../packages/compact/compact/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 42f73824cf..d015cdb638 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -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/tools.md -tools.md: 87910dfbb86e9db0f0545bc2fe1135c4aefb2e69 -tools.zh.md: d312090b722de3fd1b8efc7703eafdf7f3c38682 +tools.md: f06db58b9b52fcb2c325ff813406cd2dc460ea7b +tools.zh.md: cca4bf9faa870d9414d95f785b67e0393e469cdd diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 87910dfbb8..f06db58b9b 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index d312090b72..cca4bf9faa 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -184,6 +184,11 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } */ interface ToolExecutionInput { readonly callId: CallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: CallId readonly name: string /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown @@ -280,6 +285,8 @@ interface CodeDispatchLog { * observers run. */ interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: CallId /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } @@ -547,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index f4169abe78..d6935b6c98 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -25,8 +25,8 @@ {"type":"assistant/chunk","seq":23,"time":1785730458465,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1785730458465,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e3061430-3f2d-4dd8-a3ee-c0fde800547d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730458465,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":26,"time":1785730458517,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":27,"time":1785730458518,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":28,"time":1785730458520,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"4dce223d-0097-4ac2-a717-d1c430240cef"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1785730458520,"data":{"turn":1,"step":2}} {"type":"step/start","seq":30,"time":1785730458527,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index a1b26a5af4..7490f9487f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -18,8 +18,8 @@ {"type":"assistant/chunk","seq":103,"time":1785730479356,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":104,"time":1785730479356,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."},{"type":"tool-call","id":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1cf31c-fd73-42fc-805d-a14d91228bd9"},"usage":{"inputTokens":10400,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103],"surfaceOp":"append"} {"type":"tool/call","seq":105,"time":1785730479356,"data":{"turn":1,"step":1,"callId":"call_00_Era4M5eh79bvNOIey5q90401","name":"run_code","arguments":"{\"code\": \"const result = await tools.bash({ command: \\\"echo BOTH_OK\\\", description: \\\"Print BOTH_OK\\\" });\\nreturn result.stdout.text;\", \"description\": \"Run echo BOTH_OK via tools.bash\"}"}} -{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} -{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} +{"type":"tool/code-dispatch-start","seq":106,"time":1785730479411,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"}}} +{"type":"tool/code-dispatch","seq":107,"time":1785730479421,"data":{"rootCallId":"call_00_Era4M5eh79bvNOIey5q90401","parentCallId":"call_00_Era4M5eh79bvNOIey5q90401","subCallId":"call_00_Era4M5eh79bvNOIey5q90401:code:1","name":"bash","arguments":{"command":"echo BOTH_OK","description":"Print BOTH_OK"},"isError":false,"content":[{"type":"text","text":"BOTH_OK\n"}]}} {"type":"tool/result","seq":108,"time":1785730479423,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Era4M5eh79bvNOIey5q90401"},"content":[{"type":"tool-result","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","content":[{"type":"text","text":"BOTH_OK\n"}],"isError":false}],"role":"user","id":"028e19dd-dcfc-4a67-a6e4-c9fa19716ea3"}},"sourceEventSeqs":[105],"surfaceOp":"append"} {"type":"step/end","seq":109,"time":1785730479423,"data":{"turn":1,"step":1}} {"type":"step/start","seq":110,"time":1785730479431,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 24e1525c79..a05477498a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -18,10 +18,10 @@ {"type":"assistant/chunk","seq":187,"time":1785730477079,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":188,"time":1785730477079,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."},{"type":"tool-call","id":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"59e638d7-2aa2-48a2-ae0e-5833b1152ce6"},"usage":{"inputTokens":6152,"outputTokens":214,"cacheReadTokens":0,"reasoningTokens":60}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187],"surfaceOp":"append"} {"type":"tool/call","seq":189,"time":1785730477080,"data":{"turn":1,"step":1,"callId":"call_00_UiQPVqoELyzBZCY5pm1z7875","name":"run_code","arguments":"{\"code\": \"\\nconst out1 = await tools.bash({command: \\\"echo CODE_ONE\\\", description: \\\"Print CODE_ONE\\\"});\\nconst out2 = await tools.bash({command: \\\"echo CODE_TWO\\\", description: \\\"Print CODE_TWO\\\"});\\nconsole.log(\\\"captured output\\\");\\nconst text1 = out1.stdout.text.trim();\\nconst text2 = out2.stdout.text.trim();\\nreturn text1 + \\\"+\\\" + text2;\\n\", \"description\": \"Run two echo commands and join outputs\"}"}} -{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} -{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} -{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} -{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} +{"type":"tool/code-dispatch-start","seq":190,"time":1785730477131,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"}}} +{"type":"tool/code-dispatch","seq":191,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Print CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}} +{"type":"tool/code-dispatch-start","seq":192,"time":1785730477144,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"}}} +{"type":"tool/code-dispatch","seq":193,"time":1785730477148,"data":{"rootCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","parentCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","subCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Print CODE_TWO"},"isError":false,"content":[{"type":"text","text":"CODE_TWO\n"}]}} {"type":"tool/result","seq":194,"time":1785730477150,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_UiQPVqoELyzBZCY5pm1z7875"},"content":[{"type":"tool-result","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","content":[{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}],"isError":false}],"role":"user","id":"e40c6472-d68e-4be1-963f-edb0edc80d82"}},"sourceEventSeqs":[189],"surfaceOp":"append"} {"type":"step/end","seq":195,"time":1785730477150,"data":{"turn":1,"step":1}} {"type":"step/start","seq":196,"time":1785730477158,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 368a2262c8..a4d4289725 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -16,8 +16,8 @@ {"type":"assistant/chunk","seq":14,"time":1785014475639,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785733131056,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9402d85-58bd-4881-b890-0b186f661671"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785733131056,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"run_code","arguments":"{\"code\":\"return await tools.read({ file_path: 'nested/task.txt' })\",\"description\":\"Read nested/task.txt\"}"}} -{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/code-dispatch-start","seq":17,"time":1785733131109,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} +{"type":"tool/code-dispatch","seq":18,"time":1785733131110,"data":{"rootCallId":"call_workspace_read","parentCallId":"call_workspace_read","subCallId":"call_workspace_read:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} {"type":"tool/result","seq":19,"time":1785733131112,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"bde1c12e-44d1-44f7-ba7e-868349ed2b05"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","seq":20,"time":1785733131112,"data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","seq":21,"time":1785733131112,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"29b0eb87-92d5-4915-ba64-7bd8133ed011"}]}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 9fcadd71d4..20f05623ad 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"847bf2e6-59da-4621-946d-06932a78f0ce"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl index b619d352e5..2c294b76d4 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl @@ -10,12 +10,13 @@ {"type":"request/context","seq":8,"time":1785730441192,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498788105,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}} {"type":"assistant/chunk","seq":10,"time":1785730441201,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}} -{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} -{"type":"assistant/chunk","seq":12,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":17,"time":1785730441209,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730441209,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":19,"time":1785730441209,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"llm/retry","seq":11,"time":1785730441201,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",2,[\"EMPTY_RESPONSE\",\"RATE_LIMIT\",\"SERVER\",\"TIMEOUT\",\"TRANSPORT\"],1,1,0]","retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}} +{"type":"llm/retry-started","seq":12,"time":1786238385130,"data":{"retryId":"dbe1e1b0-a914-48a4-ad9f-407b213a37ae","turn":1,"step":1,"retry":1}} +{"type":"assistant/chunk","seq":13,"time":1785498788113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}} +{"type":"assistant/chunk","seq":16,"time":1785730441209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":17,"time":1786238385135,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786238385135,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Recovered."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"422eae65-9975-4a95-8cde-1ddfe21fff4e"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786238385135,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786238385135,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 6769414d10..646110b6d9 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -24,8 +24,8 @@ {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} -{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} +{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} {"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index c342059741..817ee1e1a2 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -23,8 +23,8 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":29,"time":0,"data":{"turn":1,"step":3}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 38056019e2..9e39f76b11 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -1,32 +1,32 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"}]}} {"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"6335ca4a-a577-47dd-8219-aa81f39cdbc0"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compact/start and closes with compact/end; a successful auxiliary summary records compact/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"203f911d-905a-43e4-8e1c-fccfe78d445b"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} {"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e54f73a8-572a-40ee-b908-8a8a27b83bf8"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6e7dfb28-f033-4dbf-9fd9-6b2f71da6906"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"b4a6504e-f39d-40b0-b51a-b11fbd60b135"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"4fe4e523-3ac9-421c-8a73-1eb033ac8b09"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} -{"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} -{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} +{"type":"compact/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"b74220bf-4104-4c30-b967-e4570d5eba5d"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} +{"type":"compact/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} {"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} {"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"346742d0-50e3-4594-b53c-f26c7da82c56"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} +{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"103034c2-e156-4808-ba2e-8e17e7107c9e"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl index 62cc357cc7..c5bca170ba 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -17,10 +17,10 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"compactionId":"{{sessionId}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl index ca523f3665..3c6e45c243 100644 --- a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -7,13 +7,14 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":9,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"provider":"deepseek-official","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry-started","seq":10,"time":0,"data":{"retryId":"{{sessionId}}","turn":1,"step":1,"retry":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RETRY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"RETRY_OK","usage":{"inputTokens":4,"outputTokens":2}} diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts index c93a768f80..eb482fd9d3 100644 --- a/packages/bash/bash-env/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -23,6 +23,7 @@ function execution(sessionId?: string): ToolExecution { signal: testToolSignal, token: Symbol('bash-env-test') as ToolExecution['token'], callId: CallId('bash-env-call'), + rootCallId: CallId('bash-env-call'), name: 'bash', arguments: { command: 'true' }, ...(sessionId === undefined diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 24f5668d42..d755f73354 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -96,7 +96,7 @@ function sgr(code: number, body: string): string { } /** - * Terminal output sample for fixture turn 65, authored to carry every feature + * Terminal output sample for fixture turn 66, authored to carry every feature * the terminal card draws that turn 60's two prompt rows cannot reach: * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll @@ -141,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record, resultText: string, isError = false): void => { push({ type: 'tool/code-dispatch-start', - data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, + data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs }, }) push({ type: 'tool/code-dispatch', data: { - parentCallId: callId, subCallId: `${callId}:code:${n}`, name, + rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }], }, }) @@ -476,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 71: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -486,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] { { content: '跑后台构建', status: 'in_progress' }, { content: '浏览器验收', status: 'pending' }, ] - // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover — + // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd // whose prompt label is its last segment, and a non-zero exit authored beside // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no @@ -498,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] { // Ordered BEFORE the todo turn deliberately: the standing plan retires at the // next `turn/start`, so a turn appended after it would leave the dock's plan // strip empty and take the todo surfaces' own coverage with it. - toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) - // Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'` + // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'` // `shape: 'matches'` result view (grouped-by-file matches, truncated with a // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise // truncated). Both ride the keyed SearchRow registration under their own // names; the render-site fallback row is covered by the model derivation // tests, since every fixture search tool has a keyed row. Ordered before the // todo turn for the same standing-plan reason the bash turn is. - toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) - toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) + toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) - // Turn 68: the read sample — a WINDOW past an offset so the card draws file + // Turn 69: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path // highlights. Named `read`, so it exercises the keyed ReadRow registration. // The render-site fallback ROW SHAPE (a read call on the generic flattened - // path) is covered by the turn 64 run_code read sub-dispatches, which + // path) is covered by the turn 65 run_code read sub-dispatches, which // session.ts folds with resultView: null; the fallback-row + read-CARD // combination is pinned by the web_fetch case in read-card.spec.tsx, not by // this fixture. The read render intent is result-side only, so its pending // call stays a generic `kind: 'read'` card; presentResult carries the // structured window. - toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) - // Turns 69-70: the web render intent — a web_search whose result view carries + // Turns 70-71: the web render intent — a web_search whose result view carries // structured sources plus an answer (the citation list, one source lacking a // title so its hostname labels the link, the capped indicator on), and a // web_fetch whose result view carries the fetched URL and its HTTP status. // Both keep a generic pending call view and add the `web` card only at // result time, which is the contract's result-only web shape. Named after // the real tools so they hit the keyed WebRow registration. Ordered BEFORE - // the todo turn for the same reason turn 65 is: the standing plan retires at + // the todo turn for the same reason turn 66 is: the standing plan retires at // the next turn/start, so a turn after it would empty the dock's plan strip. - toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') - toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -578,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { case 'read': return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } case 'edit': - // The multi-hunk sample (turn 67) is keyed on its file_path, so the two + // The multi-hunk sample (turn 64) is keyed on its file_path, so the two // scattered hunks share one path header and the card draws the `⋯` gap. if (str(args.file_path) === 'src/config.ts') { return { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e1c07abae3..4aed1d0f36 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: bd8528e97b04d5b4b28922266306969e8f19295a -README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615 +README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e +README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index bd8528e97b..b2bb06e50e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -34,11 +34,13 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. -## The human transcript +## Conversation assembly -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view with the producer role and name: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the Service Definition's declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). +Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. -Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold. + +The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). ## Request inspection @@ -46,7 +48,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr ## Code Mode child-call tree -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9aea486fb1..72cd99ac76 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -34,11 +34,13 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 -## 面向人的 transcript(文本记录) +## Conversation 组装 -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,每次落地的压缩(compaction)检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份包含生产者角色和名称的 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null,按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在 Service Definition 的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。 +每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 -由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。 + +Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 ## 请求检查 @@ -46,7 +48,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## Code Mode 子调用树 -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。 ## Session 标题投影 diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index b700c4c066..4f1ac11fcc 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -4,12 +4,14 @@ * fiber-scoped loop teardown. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as RuntimeClient from '../src/client/index.ts' +import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, ok } from './fake-api.ts' @@ -112,6 +114,31 @@ describe('runtime client apply', () => { expect(bench.api.callsOf('session.create')).toHaveLength(1) }) + it('wires registry changes into resident Sessions during the runtime apply pass', async () => { + const bench = await mount() + const sessions = bench.ctx.get('sessions') as SessionsService + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r-registry' as never, + payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never, + }) + await flushMicrotasks() + expect(sessions.binding('s-registry' as never)).toBeDefined() + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + const definition: ConversationNodeDefinition = { + kind: 'registry-probe', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + bench.ctx.conversationEvents.register(definition) + await flushMicrotasks() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) + it('stops the stream loop when the plugin fiber unloads', async () => { const bench = await mount() const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts deleted file mode 100644 index 1f969c5121..0000000000 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Behavioral half of the compaction-checkpoint drift trap. - * - * `TranscriptAdapter` pins its plugin literal to the Service Definition's declaration at - * compile time through a type-only import of `dsh-compact/checkpoint`, so - * renaming the Service Definition's plugin already fails `tsc`. This spec covers the same - * drift from the other side — end to end through the adapter, driving it with a - * checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` value and - * checking the Service Definition's predicate agrees. Both values come from the - * cordis-free checkpoint leaf, so the client test program never loads the host - * package root or its `Context` merges. - */ - -import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' - -/** A replacement user message stamped with the Service Definition's canonical source. */ -function canonicalCheckpoint(seq: number): SessionEvent { - return { - type: 'user/message', - seq, - time: 1_700_000_000_000 + seq, - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: COMPACT_CHECKPOINT_SOURCE, - }), - } as unknown as SessionEvent -} - -describe('compaction checkpoint recognition', () => { - it('recognizes a checkpoint carrying the seam-canonical source', () => { - const adapter = new TranscriptAdapter() - adapter.reset([canonicalCheckpoint(1)]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it("agrees with the seam's own predicate on the source it recognizes", () => { - // Both sides answer the same question about the same value: if the Service Definition - // renames its plugin, this equality is what breaks. - const checkpoint = canonicalCheckpoint(1) - expect(checkpoint.type === 'user/message' && isCompactCheckpointSource(checkpoint.data.source)).toBe(true) - expect(COMPACT_CHECKPOINT_SOURCE).toEqual({ kind: 'plugin', plugin: 'compact' }) - }) -}) diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts new file mode 100644 index 0000000000..6f4d8516dc --- /dev/null +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -0,0 +1,892 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts' +import type { + ConversationEventInput, ConversationMatch, ConversationNodeContext, + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' + +interface ScopeProbeStepData { + readonly value: number +} + +interface ScopeProbeTurnData { + readonly valueSeenFromStep: number +} + +declare module '../src/client/contract/conversation.ts' { + interface ConversationStepDataMap { + 'scope-probe': ScopeProbeStepData + } + + interface ConversationTurnDataMap { + 'scope-probe': ScopeProbeTurnData + } +} + +interface TestSnapshot { + readonly order: readonly string[] + readonly nodes: ReadonlyMap +} + +class TestEventDefinitions { + constructor( + readonly definitions: readonly ConversationNodeDefinition[], + readonly fallback?: ConversationNodeDefinition, + ) {} + + entries(): readonly ConversationNodeDefinition[] { + return this.definitions + } + + fallbackEntry(): ConversationNodeDefinition | undefined { + return this.fallback + } +} + +class TestViewDefinitions { + constructor(readonly definitions: readonly ConversationViewDefinition[]) {} + + entries(): readonly ConversationViewDefinition[] { + return this.definitions + } +} + +function testView( + apply = vi.fn(), +): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + let current: TestSnapshot = { order: [], nodes: new Map() } + return { + empty: current, + replace: ({ nodes }) => { + current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) } + return current + }, + apply: ({ upserts }) => { + apply(upserts) + const nodes = new Map(current.nodes) + const order = [...current.order] + for (const node of upserts) { + if (!nodes.has(node.key)) order.push(node.key) + nodes.set(node.key, node) + } + current = { order, nodes } + return current + }, + } + }, + } +} + +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, view: undefined } +} + +function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined { + return assembler.snapshot('chat') as TestSnapshot | undefined +} + +function node(context: Parameters[0], data: unknown): ConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'chat', + data, + } +} + +describe('ConversationNodeAssembler', () => { + it('appends through an exact business-id Context without replaying unrelated Contexts', () => { + const starts = vi.fn(( + _context: ConversationNodeContext<{ callSeq: number; results: number }>, + match: ConversationMatch, + ) => ({ callSeq: match.event.seq, results: 0 })) + const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({ + ...context.state, + results: context.state.results + 1, + })) + const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })), + input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })), + ], false) + assembler.flush() + starts.mockClear() + + assembler.append(input(at(3, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))) + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledOnce() + const snapshot = chatSnapshot(assembler) + expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([ + { callSeq: 1, results: 1 }, + { callSeq: 2, results: 0 }, + ]) + }) + + it('keeps one Match collection while a long Context appends without replay', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const matchCollections = new Set() + const definition: ConversationNodeDefinition = { + kind: 'append-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: (context) => { + matchCollections.add(context.matches) + return starts() + }, + update: (context) => { + matchCollections.add(context.matches) + return updates(context) + }, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'linear/start', {}))], false) + starts.mockClear() + + for (let seq = 2; seq <= 1_001; seq++) { + assembler.append(input(at(seq, 'linear/update', {}))) + } + assembler.flush() + + expect(starts).not.toHaveBeenCalled() + expect(updates).toHaveBeenCalledTimes(1_000) + expect(matchCollections.size).toBe(1) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000) + }) + + it('merges an older page and replays its affected Context once', () => { + const starts = vi.fn(() => 0) + const updates = vi.fn((context: ConversationNodeContext & { readonly state: number }) => ( + context.state + 1 + )) + const definition: ConversationNodeDefinition = { + kind: 'prepend-linear', + match: (event) => { + const type: string = event.type + if (type === 'linear/start') return { id: 'one', role: 'start' } + if (type === 'linear/update') return { id: 'one', role: 'update' } + return null + }, + start: starts, + update: updates, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + const current = Array.from({ length: 100 }, (_, index) => ( + input(at(index + 102, 'linear/update', {})) + )) + assembler.replaceWindow(current, true) + assembler.flush() + expect(starts).not.toHaveBeenCalled() + expect(updates).not.toHaveBeenCalled() + + const older = [ + input(at(1, 'linear/start', {})), + ...Array.from({ length: 100 }, (_, index) => ( + input(at(index + 2, 'linear/update', {})) + )), + ] + assembler.prepend(older, false) + assembler.flush() + + expect(starts).toHaveBeenCalledOnce() + expect(updates).toHaveBeenCalledTimes(200) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200) + }) + + it('collects an update before its start and replays it once prepend supplies the start', () => { + const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true })) + const definition: ConversationNodeDefinition<{ settled: boolean }> = { + kind: 'tool', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' } + return null + }, + start: () => ({ settled: false }), + update: updates, + buildViewNode: context => node(context, context.state ?? { pendingStart: true }), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'tool/result', { + turn: 1, + step: 1, + message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ pendingStart: true }) + + assembler.prepend([input(at(5, 'tool/call', { + turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}', + }))], false) + assembler.flush() + + expect(updates).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ settled: true }) + }) + + it('rejects a Definition whose declared start follows an update in log order', () => { + const definition: ConversationNodeDefinition = { + kind: 'invalid-lifecycle', + match: event => event.type === 'turn/end' + ? { id: 'one', role: 'start' } + : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + + expect(() => assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })), + ], false)).toThrow('received an update before its start Match') + }) + + it('replays a window-gap reader when prepend supplies a nearer predecessor', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), + update: context => context.state, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + + assembler.prepend([input(at(5, 'user/message', { + id: 'm1', value: 7, content: [], source: { kind: 'user' }, + }))], false) + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7) + }) + + it('keeps the predecessor index ordered across prepend and append', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: event => event.type === 'user/message' + ? { id: String(event.data.id), role: 'start' } + : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: () => null, + } + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })), + input(at(50, 'assistant/message', { + turn: 1, step: 1, message: { role: 'assistant', content: [] }, + })), + ], true) + assembler.flush() + + assembler.prepend([ + input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })), + input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })), + ], false) + assembler.flush() + assembler.append(input(at(60, 'user/message', { + id: 'm60', content: [], source: { kind: 'user' }, + }))) + assembler.append(input(at(70, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual([40, 60]) + }) + + it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => { + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(10, 'assistant/message', { + turn: 2, step: 1, message: { role: 'assistant', content: [] }, + }))], true) + assembler.flush() + + expect(assembler.prepend([], false)).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) + }) + + it('replays direct dependents when an append revises their predecessor Context', () => { + const source: ConversationNodeDefinition = { + kind: 'source', + match: (event) => { + if (event.type === 'user/message') return { id: 'one', role: 'start' } + if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' } + return null + }, + start: () => 1, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const consumerStart = vi.fn(( + _context: Parameters['start']>[0], + _match: Parameters['start']>[1], + reader: Parameters['start']>[2], + ) => reader.previous('source')?.state ?? -1) + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'assistant/message' + ? { id: 'one', role: 'start' } + : null, + start: consumerStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + ], false) + assembler.flush() + + expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate') + assembler.flush() + + expect(consumerStart).toHaveBeenCalledTimes(2) + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) + }) + + it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => { + const apply = vi.fn() + const starts = vi.fn(( + _context: Parameters['start']>[0], + match: Parameters['start']>[1], + ) => match.location.kind === 'step' ? match.location.step.status : 'missing') + const definition: ConversationNodeDefinition = { + kind: 'step', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: starts, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open') + + assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(starts).toHaveBeenCalledTimes(2) + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed') + }) + + it('lets one Context publish Step and Turn data in phase order', () => { + interface State { + readonly turn: number + readonly step: number + readonly value: number + } + + const definition: ConversationNodeDefinition = { + kind: 'scope-probe', + match: (event) => { + if (event.type === 'step/start') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + } + if ((event.type as string) === 'scope-probe/update') { + return { id: '1:1', role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start') + return { turn: match.event.data.turn, step: match.event.data.step, value: 1 } + }, + update: (_context, match) => ({ + turn: 1, + step: 1, + value: (match.event.data as unknown as { value: number }).value, + }), + buildLocationData: (context, scope) => { + const state = context.state + if (state === undefined) return null + if (scope === 'step') { + return { + kind: 'step', + turn: state.turn, + step: state.step, + key: 'scope-probe', + value: { value: state.value }, + } + } + const location = context.start?.location + const stepValue = location?.kind === 'step' + ? location.step.data.get('scope-probe')?.value + : undefined + return { + kind: 'turn', + turn: state.turn, + key: 'scope-probe', + value: { valueSeenFromStep: stepValue ?? -1 }, + } + }, + buildViewNode: (context) => { + const location = context.start?.location + if (location?.kind !== 'step') return null + return node(context, { + step: location.step.data.get('scope-probe')?.value, + turn: location.turn.data.get('scope-probe')?.valueSeenFromStep, + }) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 1, turn: 1 }) + + assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toEqual({ step: 2, turn: 2 }) + }) + + it('updates existing turn Locations when their Step membership changes', () => { + const apply = vi.fn() + const definition: ConversationNodeDefinition = { + kind: 'turn-probe', + match: event => event.type === 'turn/start' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind === 'turn' + ? context.start.location.turn.steps.length + : -1), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0) + + assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) + + it('publishes a changed timeline even when no business Definition claims the boundary', () => { + const apply = vi.fn() + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([]), + new TestViewDefinitions([testView(apply)]), + ) + assembler.replaceWindow([], false) + assembler.flush() + + assembler.append(input(at(1, 'turn/start', { turn: 1 }))) + assembler.flush() + + expect(apply).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('clears the prior Step at a new Turn and honors explicit session ownership', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: (event) => { + if ((event.type as string) === 'command/run') { + return { + id: (event.data as unknown as { commandId: string }).commandId, + role: 'start', + } + } + if ((event.type as string) === 'compact/start') { + return { + id: (event.data as unknown as { compactionId: string }).compactionId, + role: 'start', + } + } + return null + }, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + const data = location?.kind === 'step' + ? `step:${location.turn.turn}:${location.step.step}` + : location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind + return node(context, data) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + input(at(3, 'turn/start', { turn: 2 })), + input(at(4, 'command/run', { commandId: 'command', name: 'x' })), + input(at(5, 'compact/start', { compactionId: 'compact', turn: null })), + ], false) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['turn:2', 'session']) + }) + + it('assigns turn boundaries to the Turn even when a Step remains open', () => { + const definition: ConversationNodeDefinition = { + kind: 'turn-boundary-probe', + match: event => event.type === 'turn/end' + ? { id: String(event.data.turn), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: context => node(context, context.start?.location.kind), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'turn/start', { turn: 1 })), + input(at(2, 'step/start', { turn: 1, step: 1 })), + ], false) + assembler.flush() + + assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn') + }) + + it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => (event.type as string) === 'tool/code-dispatch-start' + ? { id: String(event.seq), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.turn}:${location.step.step}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })), + ], true) + assembler.flush() + + assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' }))) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)) + .toEqual(['2:3', '2:3']) + }) + + it('treats loaded end boundaries as closed when their starts precede the window', () => { + const definition: ConversationNodeDefinition = { + kind: 'location-probe', + match: event => event.type === 'tool/call' + ? { id: String(event.data.callId), role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: (context) => { + const location = context.start?.location + return node(context, location?.kind === 'step' + ? `${location.turn.status}:${location.step.status}` + : location?.kind) + }, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })), + input(at(11, 'step/end', { turn: 2, step: 3 })), + input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })), + ], true) + assembler.flush() + + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data) + .toBe('closed:closed') + }) + + it('restarts State creation from undefined when Location changes replay a Context', () => { + const seen = vi.fn((context: Parameters['start']>[0]) => { + expect(context.state).toBeUndefined() + return 1 + }) + const definition: ConversationNodeDefinition = { + kind: 'replay-probe', + match: event => event.type === 'step/start' + ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + : null, + start: seen, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false) + assembler.flush() + + assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + + expect(seen).toHaveBeenCalledTimes(2) + }) + + it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed', + match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const fallback: ConversationNodeDefinition = { + kind: 'fallback', + match: event => ({ id: String(event.seq), role: 'start' }), + start: fallbackStart, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallback), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).not.toHaveBeenCalled() + expect(chatSnapshot(assembler)?.order).toEqual([]) + }) + + it('rejects withdrawing a previously materialized Node during an incremental update', () => { + const definition: ConversationNodeDefinition = { + kind: 'toggle', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => false, + buildViewNode: context => context.state === true ? node(context, true) : null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + + assembler.append(input(at(2, 'toggle/hide', {}))) + expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/) + + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('fails loud when a Definition returns undefined State', () => { + const startUndefined: ConversationNodeDefinition = { + kind: 'undefined-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: () => undefined, + update: context => context.state, + buildViewNode: () => null, + } + const startAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([startUndefined]), + new TestViewDefinitions([testView()]), + ) + expect(() => startAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false)).toThrow(/Definition "undefined-start" returned undefined from start/) + + const updateUndefined: ConversationNodeDefinition = { + kind: 'undefined-update', + match: (event) => { + if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' } + if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' } + return null + }, + start: () => true, + update: () => undefined as never, + buildViewNode: context => node(context, context.state), + } + const updateAssembler = new ConversationNodeAssembler( + new TestEventDefinitions([updateUndefined]), + new TestViewDefinitions([testView()]), + ) + updateAssembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + expect(() => updateAssembler.append( + input(at(2, 'command/done', { commandId: 'one', kind: 'success' })), + )).toThrow(/Definition "undefined-update" returned undefined from update/) + }) + + it('rejects a duplicate start before mutating the existing Context', () => { + const definition: ConversationNodeDefinition = { + kind: 'single-start', + match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, + start: (_context, match) => match.event.seq, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([definition]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'command/run', { commandId: 'one', name: 'x' })), + ], false) + assembler.flush() + + expect(() => assembler.append( + input(at(2, 'command/run', { commandId: 'two', name: 'x' })), + )).toThrow(/received more than one start Match/) + assembler.flush() + expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + }) +}) diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts new file mode 100644 index 0000000000..19cbdf17f9 --- /dev/null +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -0,0 +1,126 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import { Session } from '../src/client/sessions/session.ts' +import { SessionsService } from '../src/client/sessions/service.ts' +import { FakeApiClient, ok } from './fake-api.ts' + +function eventDefinition(kind: string): ConversationNodeDefinition { + return { + kind, + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } +} + +function viewDefinition(target: string): ConversationViewDefinition { + return { + target, + create: () => ({ + empty: null, + replace: () => null, + apply: () => null, + }), + } +} + +async function bootRegistries(): Promise<{ + ctx: Context + events: ConversationEventRegistry + views: ConversationViewRegistry +}> { + const ctx = new Context() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() + const events = ctx.get('conversationEvents') as ConversationEventRegistry + const views = ctx.get('conversationViews') as ConversationViewRegistry + return { ctx, events, views } +} + +describe('Conversation registries', () => { + it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => { + const { events } = await bootRegistries() + const definition = eventDefinition('message') + const dispose = events.register(definition) + + expect(events.entries()).toEqual([definition]) + expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.entries()).toEqual([]) + }) + + it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => { + const { events } = await bootRegistries() + const fallback = eventDefinition('unknown') + const dispose = events.registerFallback(fallback) + + expect(events.fallbackEntry()).toBe(fallback) + expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/) + + dispose() + dispose() + expect(events.fallbackEntry()).toBeUndefined() + }) + + it('rejects duplicate view targets and disposes a view registration once', async () => { + const { views } = await bootRegistries() + const definition = viewDefinition('chat') + const dispose = views.register(definition) + + expect(views.entries()).toEqual([definition]) + expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/) + + dispose() + dispose() + expect(views.entries()).toEqual([]) + }) + + it('removes Event, fallback, and view contributions with their caller fiber', async () => { + const { ctx, events, views } = await bootRegistries() + const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => { + featureCtx.conversationEvents.register(eventDefinition('message')) + featureCtx.conversationEvents.registerFallback(eventDefinition('unknown')) + featureCtx.conversationViews.register(viewDefinition('chat')) + }) + await feature.await() + + expect(events.entries()).toHaveLength(1) + expect(events.fallbackEntry()).toBeDefined() + expect(views.entries()).toHaveLength(1) + + await feature.dispose() + expect(events.entries()).toEqual([]) + expect(events.fallbackEntry()).toBeUndefined() + expect(views.entries()).toEqual([]) + }) + + it('coalesces registry changes into one rebuild of every resident Session', async () => { + const { ctx, events, views } = await bootRegistries() + const api = new FakeApiClient() + const sessionId = 'resident' as SessionId + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId, updatedAt: 1, running: false, blank: true }], + }) as never) + const sessions = new SessionsService(ctx, api) + await sessions.refresh() + await Promise.resolve() + sessions.scope(sessionId) + const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') + + events.register(eventDefinition('message')) + views.register(viewDefinition('chat')) + await Promise.resolve() + + expect(rebuild).toHaveBeenCalledOnce() + rebuild.mockRestore() + }) +}) diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index de72d5b351..299e16785d 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -54,12 +54,12 @@ export const ev = { codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent => at(seq, { type: 'tool/code-dispatch-start', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args }, }), codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent => at(seq, { type: 'tool/code-dispatch', - data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, + data: { rootCallId: parentCallId, parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) }, }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 55e0929c30..1b6b721d25 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -158,7 +158,6 @@ describe('queue snapshot intake', () => { type: 'session/event', sessionId: SID, event: durable, }) expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'user')).toHaveLength(1) session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 834f37d434..def97002f0 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,15 +8,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' +import type { + ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, + ConversationEventInput, ConversationNode, ConversationNodeDefinition, + ConversationRuntime, ConversationSnapshot, ConversationTimelineSnapshot, + ConversationViewDefinition, +} from '../src/client/index.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId @@ -24,8 +26,142 @@ afterEach(() => { vi.unstubAllGlobals() }) +const EMPTY: readonly never[] = [] + +interface TestEventState extends ConversationEventInput {} + +class TestNodeStore implements ChatNodeStore { + private readonly nodes = new Map() + private cache: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.nodes.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.cache + } + + replace(nodes: readonly ChatConversationViewNode[]): void { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } + + upsert(nodes: readonly ChatConversationViewNode[]): void { + if (nodes.length === 0) return + for (const node of nodes) this.nodes.set(node.key, node) + this.cache = [...this.nodes.values()] + } +} + +const TEST_LOCATIONS: ChatLocationNodeIndex = { + getTurn: () => EMPTY, + getStep: () => EMPTY, +} + +function testLegacy( + nodes: readonly ChatConversationViewNode[], + timeline: ConversationTimelineSnapshot, +): ChatSnapshot['legacy'] { + const legacyNodes = nodes.flatMap((node): ConversationNode[] => { + const event = (node.data as TestEventState).event + if (event.type === 'user/message') return [{ kind: 'user', seq: event.seq } as ConversationNode] + if (event.type === 'assistant/message') return [{ kind: 'assistant', seq: event.seq } as ConversationNode] + return [] + }) + const turnTimings = new Map() + const turnEnds = new Map() + for (const turn of timeline.turns.values()) { + if (turn.start !== undefined) { + turnTimings.set(turn.turn, turn.end === undefined + ? { startTime: turn.start.time } + : { startTime: turn.start.time, endTime: turn.end.time }) + } + if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq) + } + return { nodes: legacyNodes, turnTimings, turnEnds, partial: null, runningCalls: EMPTY } +} + +function testViewDefinition(): ConversationViewDefinition { + return { + target: 'chat', + create: () => { + const store = new TestNodeStore() + let current: ChatSnapshot = { + order: EMPTY, + nodes: store, + locations: TEST_LOCATIONS, + timeline: { turnOrder: EMPTY, turns: new Map() }, + legacy: testLegacy(EMPTY, { turnOrder: EMPTY, turns: new Map() }), + } + const build = (timeline: ConversationTimelineSnapshot): ChatSnapshot => { + const nodes = [...store.values()].sort((left, right) => left.anchorSeq - right.anchorSeq) + current = { + order: nodes.map(node => node.key), + nodes: store, + locations: TEST_LOCATIONS, + timeline, + legacy: testLegacy(nodes, timeline), + } + return current + } + return { + empty: current, + replace: ({ nodes, timeline }) => { + store.replace(nodes) + return build(timeline) + }, + apply: ({ upserts, timeline }) => { + store.upsert(upserts) + return build(timeline) + }, + } + }, + } +} + +const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { + kind: 'runtime-test-event', + match: event => ({ id: String(event.seq), role: 'start' }), + start: (_context, match) => ({ event: match.event, view: match.view }), + update: context => context.state, + publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined || context.start === undefined) return null + return { + key: context.key, + kind: 'runtime-test-event', + id: context.id, + target: 'chat', + anchorSeq: context.start.event.seq, + location: context.start.location, + visibility: 'visible', + data: context.state, + } + }, +} + +const TEST_CONVERSATION: ConversationRuntime = { + events: { + entries: () => [TEST_EVENT_DEFINITION], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], +} + function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, api) } + return { api, session: new Session(SID, api, { conversation: TEST_CONVERSATION }) } +} + +function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] { + return snapshot.chat.order.map(key => snapshot.chat.nodes.get(key)?.data as TestEventState) +} + +function chatSeqs(snapshot: ConversationSnapshot): number[] { + return chatEvents(snapshot).map(item => item.event.seq) } function histResponse(events: SessionEvent[], hasMore = false) { @@ -34,6 +170,14 @@ function histResponse(events: SessionEvent[], hasMore = false) { } describe('open', () => { + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ blank: true, composerPhase: 'blank' }) + + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, composerPhase: 'active' }) + }) + it('installs the tail page: cold → loading → open with window and nodes in place', async () => { const { api, session } = makeSession() const page = plainTurn(10, 3, '问', '答') @@ -115,74 +259,28 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) - it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => { - // Live path: run mints an executing node, done settles it in the flow. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.commandRun(6, 'cmd-live', 'plan')) - let command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null }) - feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode')) - command = session.getSnapshot().nodes.at(-1) - expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } }) - - // Replay path (refresh): the same pair inside the history window folds identically. - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.commandRun(6, 'cmd-live', 'plan'), - ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'), - ]) - expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('command lifecycle rows alone keep the composer blank (hero survives a /permission or /plan switch)', async () => { - // A fresh session whose only window content is a command pair (plus the - // knob events a /permission switch appends — not surface-eligible, so - // they never become nodes) stays phase 'blank': selecting a preset from - // the hero must not enter the conversation view. + it('keeps the authoritative host blank bit across unrelated log events', async () => { const { session } = await opened([]) + session.handleBlank(true) expect(session.getSnapshot().composerPhase).toBe('blank') const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')) feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' }) + expect(chatSeqs(snapshot)).toEqual([0, 1]) expect(snapshot.composerPhase).toBe('blank') }) - it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '流式问')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '半截')) - let snapshot = session.getSnapshot() - expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] }) - feed(ev.chunkText(10, 1, '回复')) - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }]) - feed(ev.assistant(11, 1, '半截回复')) - feed(ev.turnEnd(12, 1)) - snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - const last = snapshot.nodes.at(-1) - expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] }) - expect((last as { interrupted?: true }).interrupted).toBeUndefined() - }) - - it('publishes cumulative chunks once per frame and lets finalization supersede the pending frame', async () => { + it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback) return frames.length }) const { session } = await opened() - const published: Array = [] + const published: number[][] = [] session.subscribe(() => { - const block = session.getSnapshot().partial?.blocks[0] - published.push(block?.kind === 'text' ? block.text : null) + published.push(chatSeqs(session.getSnapshot())) }) const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) @@ -195,356 +293,46 @@ describe('live event path', () => { expect(frames).toHaveLength(1) frames.shift()!(0) - expect(published).toEqual(['累计']) + expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]]) feed(ev.chunkText(9, 1, '完成')) feed(ev.assistant(10, 1, '累计完成')) await Promise.resolve() - expect(published).toEqual(['累计', null]) + expect(published).toEqual([ + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ]) frames.shift()!(0) - expect(published).toEqual(['累计', null]) + expect(published).toHaveLength(2) }) - it('retracts the failed-attempt partial and starts the retry on new chunk evidence', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const retryTurn = [ - ev.turnStart(6, 1), - ev.user(7, '请重试'), - ev.stepStart(8, 1), - ev.chunkStart(9, 1), - ev.chunkText(10, 1, '不完整回复'), - ev.retry(11, 1, 0, 1, 2, 450, '连接被重置'), - ev.chunkStart(12, 1), - ev.assistant(13, 1, '完整回复'), - ev.stepEnd(14, 1), - ev.turnEnd(15, 1), - ] - for (const event of retryTurn.slice(0, 6)) feed(event) - - let snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: 1, - maxRetries: 2, - delayMs: 450, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - }) - expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') - - for (const event of retryTurn.slice(6)) feed(event) - snapshot = session.getSnapshot() - expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) - expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false) - expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) - const retryStart = retryTurn.find(event => event.type === 'turn/start') - if (retryStart?.type !== 'turn/start') throw new Error('test fixture must include the retried turn start') - const retryEnd = retryTurn.find(event => - event.type === 'turn/end' && event.data.turn === retryStart.data.turn) - if (retryEnd?.type !== 'turn/end') throw new Error('test fixture must complete the retry turn') - expect(snapshot.turnTimings.get(retryStart.data.turn)).toEqual({ - startTime: retryStart.time, - endTime: retryEnd.time, - }) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) - expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings) - expect(replay.session.getSnapshot().partial).toBeNull() - }) - - it('projects unretried terminal failures at turn/end and reproduces them from history', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + it('publishes a timeline-only boundary even when no Definition claims the event', async () => { + const api = new FakeApiClient() + api.onHistory = () => histResponse([]) + const conversation: ConversationRuntime = { + events: { + entries: () => [], + fallbackEntry: () => undefined, + } as unknown as ConversationRuntime['events'], + views: { + entries: () => [testViewDefinition()], + } as unknown as ConversationRuntime['views'], } - const failedTurns = [ - ev.turnStart(6, 1), - ev.user(7, '鉴权失败'), - ev.stepStart(8, 1), - at(9, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, - }, - }), - ev.turnStart(10, 2), - ev.user(11, '内部失败'), - ev.stepStart(12, 2, 1), - at(13, { - type: 'turn/end', - data: { turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } }, - }), - ] - for (const event of failedTurns) feed(event) + const session = new Session(SID, api, { conversation }) + await session.open() + const snapshots: ConversationSnapshot[] = [] + session.subscribe(() => { snapshots.push(session.getSnapshot()) }) - const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error') - expect(errors).toMatchObject([ - { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' }, - // Every failed turn carries a structured failure; unstructured errors - // flatten to the UNKNOWN code. - { seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' }, - ]) - - const replay = makeSession() - replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns]) - await replay.session.open() - expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes) - }) - - it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) - feed(ev.chunkText(8, 1, '仍在生成')) - const valid = { - turn: 1, step: 0, - provider: 'fake', mode: 'normal', policyKey: 'fake-normal', - retry: 1, maxRetries: 2, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'temporary failure' }, - } - const invalid = [ - { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, provider: '' }, - { ...valid, policyKey: '' }, - { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, - { ...valid, delayMs: -1 }, - { ...valid, delayMs: Number.POSITIVE_INFINITY }, - { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, - { ...valid, failure: { ...valid.failure, message: '' } }, - { ...valid, failure: { ...valid.failure, code: '' } }, - { ...valid, failure: { ...valid.failure, status: '429' } }, - { ...valid, failure: { ...valid.failure, status: 99 } }, - { ...valid, failure: { ...valid.failure, status: 429.5 } }, - { ...valid, failure: { ...valid.failure, status: 600 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, - { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, - { ...valid, failure: { ...valid.failure, requestId: 1 } }, - { ...valid, failure: { ...valid.failure, requestId: '' } }, - ] - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - for (const [index, data] of invalid.entries()) { - feed(at(9 + index, { type: 'llm/retry', data })) - } - expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) - expect(errorSpy).toHaveBeenCalledTimes(invalid.length) - expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') - } finally { - errorSpy.mockRestore() - } - }) - - it('accepts complete retry payloads at the producer field boundaries', async () => { - const { session } = await opened() - session.handleMuxEnvelope('r' as never, { + session.handleMuxEnvelope('timeline' as never, { type: 'session/event', sessionId: SID, - event: at(6, { - type: 'llm/retry', - data: { - turn: Number.MAX_SAFE_INTEGER, - step: Number.MAX_SAFE_INTEGER, - provider: 'fake', - mode: 'normal', - policyKey: 'fake-normal', - retry: Number.MAX_SAFE_INTEGER, - maxRetries: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { - code: 'RATE_LIMIT', - message: 'provider busy', - status: 599, - providerRetryAfterMs: Number.MIN_VALUE, - requestId: 'req-1', - }, - }, - }), + event: ev.turnStart(0, 1), }) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - retry: Number.MAX_SAFE_INTEGER, - delayMs: MAX_TIMER_DELAY_MS, - failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, - }) - }) + await Promise.resolve() - it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - feed(at(6, { - type: 'llm/retry', - data: { - turn: 1, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 3, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'retry forever' }, - }, - })) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - mode: 'always', - retry: 3, - }) - - feed(at(7, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'always', policyKey: 'fake-always', - retry: 4, maxRetries: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unexpected maximum' }, - }, - })) - feed(at(8, { - type: 'llm/retry', - data: { - turn: 2, step: 0, - provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown', - retry: 4, delayMs: 500, - failure: { code: 'TRANSPORT', message: 'unknown mode' }, - }, - })) - expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1) - expect(errorSpy).toHaveBeenCalledTimes(2) - } finally { - errorSpy.mockRestore() - } - }) - - it.each(['aborted', 'disposed'] as const)( - 'marks a scheduled retry as cancelled when its failed turn receives the %s cause', - async (reason) => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'scheduled', - }) - feed(ev.turnEnd(8, 1, reason)) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'cancelled', - }) - }, - ) - - it('marks a scheduled retry as started when its failed turn ends with an error', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) - } - feed(ev.turnStart(6, 1)) - feed(ev.retry(7, 1)) - feed(at(8, { - type: 'turn/end', - data: { turn: 1, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } }, - })) - - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'model-retry', - retryState: 'started', - }) - }) - - it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.user(7, '要被打断的')) - feed(ev.chunkStart(8, 1)) - feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.turnEnds.get(1)).toBe(10) - const frozen = snapshot.nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) - // Ordered inside the flow: after the user message (seq 7), before any later turn. - expect((frozen as { seq: number }).seq).toBeGreaterThan(7) - }) - - it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => { - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}')) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }]) - feed(ev.toolResult(8, 1, 'c1', 'ECHO')) - expect(session.getSnapshot().runningCalls).toEqual([]) - // Second call never resolves: turn/end freezes it as an error card. - feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls).toEqual([]) - expect(snapshot.nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' }, - }) - }) - - it('keeps compacted history and adds one marker, live and on replay alike', async () => { - // A landed compaction must not erase conversation the reader already saw: - // the shadowed messages stay at their own log positions and the checkpoint - // contributes one marker after them. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - const live = session.getSnapshot().nodes - expect(live.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(live.at(-1)).toMatchObject({ kind: 'compaction', summary: '压缩摘要' }) - - const replayed = await opened([ - ...plainTurn(0, 0, 'a', 'b'), - ev.compactSummary(6, '压缩摘要', 1, 3), - ev.compactCheckpoint(7, 6, 1, 3), - ]) - expect(replayed.session.getSnapshot().nodes).toEqual(live) - }) - - it('merges an interrupted frozen node by seq into the log-ordered transcript', async () => { - // The transcript array is seq-monotonic, so the frozen node's fractional - // seq lands it exactly where it happened — including after a compaction - // checkpoint whose own seq is higher than the range it shadowed. - const { session } = await opened() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.compactSummary(6, '压缩摘要', 1, 3)) - feed(ev.compactCheckpoint(7, 6, 1, 3)) - feed(ev.turnStart(8, 1)) - feed(ev.user(9, '压缩后的提问')) - feed(ev.chunkStart(10, 1)) - feed(ev.chunkText(11, 1, '说到一半')) - feed(ev.turnEnd(12, 1, 'aborted')) - expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([ - 'user', 'assistant', 'compaction', 'user', 'assistant', - ]) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ interrupted: true }) + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open') }) it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { @@ -578,11 +366,7 @@ describe('paging', () => { expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9]) }) - it('renders a page whose checkpoint shadows seqs below the window head, logging nothing', async () => { - // Pagination no longer spends maxMessages quota on replacement copies, so a - // page can carry a compaction checkpoint whose surfaceOp.start lies outside - // the window. The old surface fold rejected that range and degraded with a - // console error; the log-ordered transcript has no range to resolve. + it('installs a page without interpreting business replacement metadata', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse([ ev.compactSummary(80, '窗外范围的摘要', 3, 40), @@ -594,8 +378,7 @@ describe('paging', () => { await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') - expect(snapshot.nodes.map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(snapshot.nodes[0]).toMatchObject({ summary: '窗外范围的摘要' }) + expect(chatSeqs(snapshot)).toEqual([80, 81, 82]) expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() @@ -712,6 +495,7 @@ describe('prompt and cancel errors', () => { it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() + session.handleBlank(true) // The blank → engaging edge fires before the RPC settles: the first-send // flow reads the phase on the session area's first frame to keep the // guidance hero from flashing back in. @@ -730,6 +514,7 @@ describe('prompt and cancel errors', () => { it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => { const { api, session } = makeSession() + session.handleBlank(true) api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) @@ -969,33 +754,6 @@ describe('remaining branches', () => { } }) - it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.chunkStart(7, 1)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.partial).toBeNull() - expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) - }) - - it('turn/end sweeps only same-turn open calls; other turns keep running', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) - feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'aborted')) - const snapshot = session.getSnapshot() - expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) - expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) - }) - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { const { api, session } = makeSession() const stale = deferred>>() @@ -1065,28 +823,13 @@ describe('remaining branches', () => { expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) }) - it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => { + it('successful cancel leaves no promptError', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) expect(session.getSnapshot().promptError).toBeNull() - const callsBefore = session.getSnapshot().runningCalls - session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') }) - expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference - }) - - it('freezes a tool-call-only partial (visible through the non-text arm)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'aborted')) - const frozen = session.getSnapshot().nodes.at(-1) - expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) it('dispose is a reserved no-op on resident instances', () => { @@ -1094,7 +837,7 @@ describe('remaining branches', () => { expect(() => { session.dispose() }).not.toThrow() }) - it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => { + it('carries history-entry and mux-frame views into the business-neutral Event input', async () => { const { api, session } = makeSession() const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } } api.onHistory = () => Promise.resolve(ok({ @@ -1107,21 +850,23 @@ describe('remaining branches', () => { modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, })) await session.open() - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' }, - }) - // Live path: the frame's view slot reaches runningCalls, then the result node. + expect(chatEvents(session.getSnapshot()).slice(-2).map(item => item.view)).toEqual([ + callView, + { for: 'result', view: { card: 'generic', title: '历史果' } }, + ]) session.handleMuxEnvelope('rv1' as never, { type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'), view: { for: 'call', view: { card: 'generic', title: '直播卡' } }, } as never) - expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }]) + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'call', view: { card: 'generic', title: '直播卡' }, + }) session.handleMuxEnvelope('rv2' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'), view: { for: 'result', view: { card: 'generic', title: '直播果' } }, } as never) - expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ - kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' }, + expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({ + for: 'result', view: { card: 'generic', title: '直播果' }, }) }) }) @@ -1177,163 +922,27 @@ describe('resync', () => { }) -describe('nested run_code sub-dispatches', () => { - const subCallsOf = (session: Session, callId: string) => { - const snapshot = session.getSnapshot() - const running = snapshot.runningCalls.find(call => call.callId === callId) - if (running !== undefined) return running.subCalls - for (const node of snapshot.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls - } - return undefined - } - - it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' })) - feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' })) - const live = subCallsOf(session, 'p1') - expect(live).toHaveLength(2) - // Running shape (no 'kind'): the exact RunningToolCall form native rows use. - expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' }) - expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false) - // Settle out of order (parallel run): #2 first — replaces in place, keeping start order. - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha')) - const mixed = subCallsOf(session, 'p1') - expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true]) - expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] }) - // The settle carries the paired start's time as callTime (duration source). - feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done')) - const settled = subCallsOf(session, 'p1') - expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true]) - expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 }) - }) - - it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt')) - feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true)) - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(2) - expect(subs?.[0]).toMatchObject({ - kind: 'tool-result', callId: 'p1:code:1', - call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' }, - // The settle event carries no start time: callTime stays null (never a - // fabricated zero-duration). - callTime: null, - isError: false, content: [{ type: 'text', text: 'demo.txt' }], - }) - expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true }) - // No paired start in the window: duration is UNKNOWN (null), never a - // fabricated zero-duration span. - expect(subs?.[0]).toMatchObject({ callTime: null }) - // Sub-dispatches never join the surface flow. - expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false) - }) - - it('rebuilds the same nested tree from a history window (replay parity)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ - ...plainTurn(0, 0, '问', '答'), - ev.turnStart(6, 1), - ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'), - ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }), - ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), - ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'), - ev.toolResult(11, 1, 'p1', '{"done":true}'), - ev.turnEnd(12, 1), - ]) - await session.open() - const subs = subCallsOf(session, 'p1') - expect(subs).toHaveLength(1) - expect(subs?.[0]).toMatchObject({ - callId: 'p1:code:1', - call: { name: 'run_code' }, - subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }], - }) - }) - - it('keeps an unaffected root reference and path-copies it on a new child', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}')) - feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x')) - const before = session.getSnapshot() - const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')! - feed(ev.chunkStart(9, 1)) - feed(ev.chunkText(10, 1, '流式')) - const after = session.getSnapshot() - const afterRoot = after.runningCalls.find(call => call.callId === 'p1')! - expect(afterRoot).toBe(beforeRoot) - feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y')) - const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')! - expect(changedRoot).not.toBe(afterRoot) - expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0]) - expect(changedRoot.subCalls).toHaveLength(2) - }) - - it('path-copies only the owning branch when a nested child changes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(0, 0, '树', '结构')) - await session.open() - const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } - feed(ev.turnStart(6, 1)) - feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"first"}')) - feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}')) - feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child')) - feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling')) - feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two')) - const before = session.getSnapshot() - const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')! - const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')! - const beforeChild = beforeFirst.subCalls[0]! - const beforeSibling = beforeFirst.subCalls[1]! - - feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf')) - const after = session.getSnapshot() - const afterFirst = after.runningCalls.find(call => call.callId === 'p1')! - const afterSecond = after.runningCalls.find(call => call.callId === 'p2')! - - expect(afterFirst).not.toBe(beforeFirst) - expect(afterSecond).toBe(beforeSecond) - expect(afterFirst.subCalls[0]).not.toBe(beforeChild) - expect(afterFirst.subCalls[1]).toBe(beforeSibling) - expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([ - { callId: 'p1:code:1:code:1', call: { name: 'read' } }, - ]) - }) -}) - describe('reference stability (the memo contract)', () => { it('keeps unchanged node references across an append and swaps the snapshot object', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) await session.open() const before = session.getSnapshot() + const firstKey = before.chat.order[0]! + const secondKey = before.chat.order[1]! + const first = before.chat.nodes.get(firstKey) + const second = before.chat.nodes.get(secondKey) session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') }) const after = session.getSnapshot() expect(after).not.toBe(before) // top-level swap on change - expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity - expect(after.nodes[1]).toBe(before.nodes[1]) - expect(after.nodes).toHaveLength(3) + expect(after.chat.nodes.get(firstKey)).toBe(first) + expect(after.chat.nodes.get(secondKey)).toBe(second) + expect(after.chat.order).toHaveLength(7) // No change → same snapshot reference. expect(session.getSnapshot()).toBe(after) }) - it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => { + it('keeps unrelated Session arrays and settled Chat Nodes stable across Event updates', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座')) await session.open() @@ -1343,20 +952,19 @@ describe('reference stability (the memo contract)', () => { feed(ev.toolCall(8, 1, 'c1', 'echo', '{}')) session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) const before = session.getSnapshot() - // A chunk storm touches partial/nodes only: unrelated projections keep identity. + const settledKey = before.chat.order[0]! + const settledNode = before.chat.nodes.get(settledKey) feed(ev.chunkStart(9, 1)) feed(ev.chunkText(10, 1, '与工具无关的流式')) const after = session.getSnapshot() expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) - expect(after.turnTimings).toBe(before.turnTimings) - expect(after.turnEnds).toBe(before.turnEnds) - // And a mutation on the tracked domain swaps that array. + expect(after.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() - expect(resolved.runningCalls).not.toBe(after.runningCalls) expect(resolved.pending).toBe(after.pending) + expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode) feed(ev.assistant(12, 1, '完成')) expect(session.getSnapshot()).not.toBe(resolved) }) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts deleted file mode 100644 index b161046f84..0000000000 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * TranscriptAdapter over the raw append-only window: log-ordered projection of - * append-origin events, one marker per landed compaction, replacement copies - * hidden, command-lifecycle folding, node/array identity, call pairing, and - * host-provided wire views. - */ - -import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' -import { ev, plainTurn } from './event-script.ts' - -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - -/** A `compact/summary` event (log-only, no surfaceOp). */ -function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent { - return at(seq, { - type: 'compact/summary', - data: { - summary, - shadowedRange: { start: 1, end: 3 }, - shadowedSeqs: [1, 3], - shadowedTokenCount: 100, - provider: 'fake', - model: 'compact-1', - }, - }) -} - -/** The replacement user message a compaction backend lands (the checkpoint). */ -function checkpoint( - seq: number, - summarySeq: number, - { start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: { - start?: number - end?: number - sourceEventSeqs?: number[] - } = {}, -): SessionEvent { - return at(seq, { - type: 'user/message', - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs, - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - }) -} - -describe('TranscriptAdapter', () => { - it('projects a window starting past seq 0 at its own log positions', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(100, 5, '偏移问', '偏移答')) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]]) - }) - - it('appends incrementally keeping old node references (materialize-once identity)', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const first = adapter.nodes() - adapter.append(ev.user(6, '追加')) - const second = adapter.nodes() - expect(second).toHaveLength(3) - expect(second[0]).toBe(first[0]) - expect(second[1]).toBe(first[1]) - expect(second).not.toBe(first) // a real change swaps the array - }) - - it('keeps the array reference across a chunk storm and swaps it when a node lands', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const settled = adapter.nodes() - adapter.append(ev.chunkStart(6, 1)) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.chunkText(7, 1, '流式')) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.assistant(8, 1, '流式完成')) - const finalized = adapter.nodes() - expect(finalized).not.toBe(settled) - expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 }) - }) - - it('materializes every append-origin variant with field mapping', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: '插话' }], - source: { kind: 'user' }, - }) - adapter.reset([ - ev.user(0, '用户'), - ev.assistant(1, 0, '助手'), - at(2, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(4, { type: 'user/message', surfaceOp: 'append', data: steering }), - at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' }, - }) }), - ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'), - ev.toolResult(7, 0, 'c1', '结果'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result']) - expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id }) - expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({ - callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false, - }) - }) - - it('identifies steering on the live append path', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: 'live steer' }], - source: { kind: 'user' }, - }) - adapter.reset([]) - adapter.append(at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } })) - adapter.append(at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } })) - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering })) - expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }]) - }) - - it('does not mark queued, canceled, or non-user next-step messages as steering', () => { - const adapter = new TranscriptAdapter() - const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) - const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } }) - const context = createUserMessage({ - content: [{ type: 'text', text: 'context' }], - source: { kind: 'plugin', plugin: 'test' }, - }) - adapter.reset([ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, inserted: [queued], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: queued }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [canceled], - } }), - at(4, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', - } }), - at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }), - at(6, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [context], - } }), - at(7, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(8, { type: 'user/message', surfaceOp: 'append', data: context }), - ]) - expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) - }) - - it('materializes a skill-invocation injection as a named instructions context', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '/hidden-demo check the fixture' }], - source: { kind: 'user' }, - }) }), - at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, - }) }), - ]) - const nodes = adapter.nodes() - // The gesture stays a user bubble; the injected body folds to a context - // row named after the skill, presented as instructions. - expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) - expect(nodes[1]).toMatchObject({ - provenance: { role: 'inject', label: 'hidden-demo' }, - form: 'instructions', - }) - }) - - it('skips events core does not call surface-eligible, marker or not', () => { - // The transcript is the append-origin surface, so log-only events (a chunk, - // a turn boundary, a `compact/*` record) and a future type core - // has not admitted contribute no node. - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 1), - at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }), - compactSummary(2), - ev.user(3, '唯一的一条'), - ev.turnEnd(4, 1), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]]) - }) - - describe('compaction markers', () => { - it('keeps the original messages and full tool output, hiding replacement copies', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '原始问题'), - ev.assistant(1, 0, '原始回答'), - ev.toolCall(4, 0, 'c1', 'echo', '{}'), - ev.toolResult(5, 0, 'c1', '完整工具输出'), - // A pruned tool/result copy: rewrites one node for the model, marks nothing. - at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }), - } }), - compactSummary(7), - checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }), - // A regenerated assistant/message: also a silent model-only rewrite. - at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: { - turn: 0, step: 0, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: '通用 replacement 副本' }], - source: { kind: 'model', ...{ provider: 'x', model: 'copy' } }, - }), - } }), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([ - ['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8], - ]) - expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] }) - expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' }) - }) - - it('adds one marker per landed compaction, in log order', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, 'a'), - compactSummary(1, [{ type: 'text', text: 'first' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ev.user(3, 'b'), - compactSummary(4, [{ type: 'text', text: 'second' }]), - checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), - ]) - expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - { - kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', - summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('renders the marker when the shadowed range is outside the window and logs nothing', () => { - // The pagination hole A1 left open: quota is no longer spent on - // replacement copies, so a page can carry a checkpoint whose - // surfaceOp.start lies below the window head. The old surface fold threw - // on the missing range and degraded with a console error; a log-ordered - // projection has no range to resolve. - const adapter = new TranscriptAdapter() - const noise = { error: console.error, warn: console.warn } - const logged: unknown[] = [] - console.error = (...args: unknown[]) => logged.push(args) - console.warn = (...args: unknown[]) => logged.push(args) - try { - adapter.reset([ - compactSummary(80, [{ type: 'text', text: '窗外范围' }]), - checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }), - ev.user(82, '压缩后的新问题'), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' }) - } finally { - console.error = noise.error - console.warn = noise.warn - } - expect(logged).toEqual([]) - }) - - it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => { - // A session-reference card carries the same plugin source shape; only the - // replacement marker makes an event a checkpoint. - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, - }) }), - ]) - expect(adapter.nodes()).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'compact' }, - form: 'instructions', - }]) - }) - - it('ignores a foreign plugin s replacement user/message', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '保留'), - at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({ - content: [{ type: 'text', text: '别的插件重写' }], - source: { kind: 'plugin', plugin: 'not-compact' }, - }) }), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]]) - }) - - it.each([ - ['absent summary event', undefined], - ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])], - ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])], - ['an empty summary array', compactSummary(1, [])], - ['a non-array summary', compactSummary(1, 'plain string')], - ])('degrades %s to a non-expandable marker', (_label, summary) => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ...(summary === undefined ? [] : [summary]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toMatchObject([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, - ]) - }) - - it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => { - // ContentBlock is merge-extensible and the payload type is ContentBlock[], - // so a non-text block must not discard recoverable text beside it. - const adapter = new TranscriptAdapter() - adapter.reset([ - compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('leaves the summary null when the checkpoint cites no source events', () => { - const adapter = new TranscriptAdapter() - adapter.reset([at(2, { - type: 'user/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - data: createUserMessage({ - content: [{ type: 'text', text: 'x' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - })]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it('skips a cited non-summary seq before reaching the summary event', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '被压缩的问题'), - at(1, { type: 'compact/start', data: { turn: 0 } }), - compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }), - ]) - expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' }) - }) - - it('resolves the summary once an older page supplies the cited summary event', () => { - const adapter = new TranscriptAdapter() - const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] }) - adapter.reset([landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null }) - adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' }) - }) - - it('creates the marker on the live append path', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }])) - adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] })) - const nodes = adapter.nodes() - // The compacted history is still there; the marker is one more row after it. - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' }) - }) - }) - - it('returns call:null for a tool-result whose call fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) - }) - - it('materializes a tool-result error field when present', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'tool/result', surfaceOp: 'append', data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }), - error: { name: 'Boom', code: 'boom' }, - } }), - ]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) - }) - - it('attaches wire views to the materialized result node', () => { - const adapter = new TranscriptAdapter() - const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } } - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } } - adapter.reset([ - ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'), - ev.toolResult(1, 1, 'c1', 'listing'), - ], [callView, resultView] as never) - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' }, - }) - }) - - it('attaches views on the live append path and defaults to null without views', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument - adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never) - adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { title: '回声' }, resultView: null, - }) - }) - - it('leaves callView null when the paired call fell outside the window (cross-page break)', () => { - const adapter = new TranscriptAdapter() - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } } - adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' }, - }) - }) - - describe('command lifecycle nodes', () => { - it('folds a run/done pair into one settled node merged into flow order by seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '先说话'), - ev.commandRun(1, 'cmd-1', 'plan'), - ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'), - ev.assistant(3, 0, '然后回答'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]]) - expect(nodes[1]).toMatchObject({ - kind: 'command', commandId: 'cmd-1', name: 'plan', args: '', - outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('renders a run with no done as still executing (outcome null)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null }) - }) - - it('represents command input omitted by the host as null', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', name: 'feedback', args: null, outcome: null, - }) - }) - - it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null, - outcome: { kind: 'error', text: '失败了' }, - }) - }) - - it('settles a live-appended done in place, keeping the node at the run seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'q', 'a')) - adapter.append(ev.commandRun(6, 'cmd-4', 'clear')) - const running = adapter.nodes().find(n => n.kind === 'command') - expect(running).toMatchObject({ outcome: null }) - adapter.append(ev.commandDone(7, 'cmd-4')) - const settled = adapter.nodes().find(n => n.kind === 'command') - expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } }) - // Settlement replaced the node object rather than mutating the published one. - expect(settled).not.toBe(running) - }) - - it('tails command nodes whose seq is past every transcript node', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')]) - expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) - }) - - it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '压缩前的问题'), - ev.commandRun(1, 'cmd-compact', 'compact'), - compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ - name: 'compact', - outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, - }) - expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) - }) - }) - - describe('assistant timing', () => { - const base = 1_700_000_000_000 - - it('derives step timing across a window rebuild (start + first token + completion)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 0), - ev.user(1, '问'), - ev.stepStart(2, 0), - ev.chunkStart(3, 0), - ev.chunkText(4, 0, '答'), - ev.chunkText(5, 0, '案'), - ev.assistant(6, 0, '答案'), - ev.turnEnd(7, 0), - ]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, - }) - }) - - it('derives the same timing on the live append path, first token winning once', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问')]) - adapter.append(ev.stepStart(1, 0)) - adapter.append(ev.chunkText(2, 0, '首')) - adapter.append(ev.chunkText(3, 0, '次')) - adapter.append(ev.assistant(4, 0, '首次')) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, - }) - }) - - it('soft-falls to null boundaries when the step opening fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, - }) - }) - }) -}) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index ea01893346..7f65a0b5a3 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -2,6 +2,7 @@ import type { ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -45,6 +46,7 @@ export interface SessionFixture { export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { return { sessionId, + chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 3005c061e7..0b8c732683 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -22,7 +22,9 @@ import { act, render, within } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import type { queries } from '@testing-library/dom' import type { BoundFunctions } from '@testing-library/dom' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import type { ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike, @@ -142,7 +144,7 @@ export class TestRoot { */ async declare( children: D, - frame: SlotComponent & keyof SlotMap & string, undefined, object>>, + frame: SlotComponent & keyof SlotMap & string, undefined, object>>, ): Promise { await this.stabilize(() => { // Erased hop (same pattern as SlotsService's own implementation arm); @@ -218,6 +220,8 @@ export class SlotTestRuntime { const ctx = new Context() const fiber = ctx.plugin(SlotsService) await fiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService) } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 00001275d2..3481ee758e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -53,7 +53,7 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => { + it('registers the chat view and its keyed business-node seat', async () => { const b = await bench() const entries = b.slots.entries('conversation.view') expect(entries.map(e => e.options.id)).toEqual(['chat']) @@ -62,7 +62,7 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' }) + expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' }) await b.runtime.dispose() }) @@ -95,7 +95,7 @@ describe('apply wiring', () => { // file-mutation registrant claims both write and edit for the diff card; the // one search row registers under both grep and glob; the web rows register // one component under both web tool names. - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) + expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call') // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() @@ -108,8 +108,8 @@ describe('apply wiring', () => { // The declared ring collapses with its declaring entry, and the chat // entry's keyed hole (with the sample's registration) collapses with it. expect(b.slots.entries('conversation.view')).toHaveLength(0) - expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0) - expect(b.slots.spec('conversation.chat.tool')).toBeUndefined() + expect(b.slots.entries('conversation.chat.node')).toHaveLength(0) + expect(b.slots.spec('conversation.chat.node')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 0362c3ee4b..6037405336 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -10,13 +10,21 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { + ChatConversationViewNode, ConversationNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '../src/client/contract/slots.ts' import { formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay, } from '../src/client/chat/message-chrome.ts' -import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, + UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { @@ -33,7 +41,44 @@ afterEach(() => { }) // Mirrors the real lookup chain (conversation namespace, then common). -const t: MessageItemProps['t'] = makeTranslate(zh, commonZh) +const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +const RETRY_ID = 'retry-fixture' as Extract['retryId'] + +interface MessageItemProps { + readonly node: ConversationNode + readonly t: ChatNodeViewProps['t'] +} + +/** Legacy-node fixture adapter for the independently registered renderers. */ +function MessageItem({ node, t: translate }: MessageItemProps) { + const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind + const viewNode: ChatConversationViewNode = { + key: `fixture:${node.kind}:${node.seq}`, + kind, + id: String(node.seq), + target: 'chat', + anchorSeq: node.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, + } + const props = { node: viewNode, t: translate } as ChatNodeViewProps + switch (node.kind) { + case 'user': + case 'steering': + return } /> + case 'context': + return } /> + case 'compaction': + return } /> + case 'model-retry': + return } /> + case 'unknown': + return } /> + default: + throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`) + } +} describe('MessageItem arms', () => { it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { @@ -727,9 +772,9 @@ describe('MessageItem arms', () => { const view = render( { view.rerender( { view.rerender( { view.rerender( { view.rerender( { expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') }) - it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => { - vi.useFakeTimers() - vi.setSystemTime(10_000) - const node = { - kind: 'model-retry', - seq: 5, - time: 10_000, - retryState: 'scheduled', - turn: 1, - step: 0, - provider: 'mock', - mode: 'normal', - policyKey: 'mock-normal', - retry: 1, - maxRetries: 2, - delayMs: 5_000, - failure: { code: 'TRANSPORT', message: '连接被重置' }, - } as const - const view = render() - expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') - - act(() => { vi.advanceTimersByTime(4_200) }) - view.rerender() - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') - }) - }) describe('formatMessageClock', () => { @@ -932,84 +954,13 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) - it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => { - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }) - const now = new Date() - const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() - const onFork = vi.fn() - const settled = render( - , - ) - expect(settled.getByText('14:24')).toBeTruthy() - expect(settled.getByRole('button', { name: '复制' })).toBeTruthy() - expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() - fireEvent.click(settled.getByRole('button', { name: '复制' })) - expect(writeText).toHaveBeenCalledWith('answer body') - fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) - expect(onFork).toHaveBeenCalledWith(3) - settled.unmount() - - const thinkOnly = render( - , - ) - expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull() - expect(thinkOnly.queryByText('14:24')).toBeNull() - thinkOnly.unmount() - - const streaming = render( - , - ) - expect(streaming.queryByRole('button', { name: '复制' })).toBeNull() - expect(streaming.queryByText('14:24')).toBeNull() - }) - - it('keeps an unavailable branch focusable and explains why without sending a fork', () => { - const onFork = vi.fn() - render( - , - ) - const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement - expect(branch.disabled).toBe(false) - expect(branch.getAttribute('aria-disabled')).toBe('true') - const reasonId = branch.getAttribute('aria-describedby') - expect(reasonId).not.toBeNull() - expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') - fireEvent.click(branch) - expect(onFork).not.toHaveBeenCalled() - fireEvent.focus(branch) - expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') - }) - it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // Cache hit is null only when all three prompt buckets are zero (pure // output accounting) — any billed input makes it a real 0%. - const snap = { - nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }], - } + const nodes = [{ + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 }, + }] as const + const snap = { chat: chatSnapshotFixture({ nodes }), nodes } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( (left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function nodeSource(node: ChatConversationViewNode): unknown { + if (node.kind === 'assistant-step') { + const data = node.data as ReturnType + return data.finalNode ?? data.blocks + } + if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root + if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current + if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq + return node.data +} + +class FixtureNodeStore implements ChatNodeStore { + private byKey = new Map() + private list: readonly ChatConversationViewNode[] = EMPTY + + get(key: string): ChatConversationViewNode | undefined { + return this.byKey.get(key) + } + + values(): readonly ChatConversationViewNode[] { + return this.list + } + + replace(candidates: readonly ChatConversationViewNode[]): void { + const next = new Map() + const list = candidates.map((candidate) => { + const previous = this.byKey.get(candidate.key) + const node = previous !== undefined + && previous.kind === candidate.kind + && previous.anchorSeq === candidate.anchorSeq + && previous.visibility === candidate.visibility + && nodeSource(previous) === nodeSource(candidate) + ? previous + : candidate + next.set(node.key, node) + return node + }) + this.byKey = next + this.list = sameValues(this.list, list) ? this.list : list + } +} + +class FixtureLocationIndex implements ChatLocationNodeIndex { + private turns = new Map() + + getTurn(turn: number): readonly string[] { + return this.turns.get(turn) ?? EMPTY + } + + getStep(): readonly string[] { + return EMPTY + } + + replace(next: ReadonlyMap): void { + const stable = new Map() + for (const [turn, keys] of next) { + const previous = this.turns.get(turn) ?? EMPTY + stable.set(turn, sameValues(previous, keys) ? previous : keys) + } + this.turns = stable + } +} + +class FixtureTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +function assistantData(node: AssistantMessageNode) { + return { + status: node.interrupted === true ? 'interrupted' as const : 'settled' as const, + turn: node.turn, + step: node.step, + blocks: node.blocks, + time: node.time, + finalNode: node, + } +} + +function settledNode( + node: ConversationNode, + turns: ReadonlyMap, +): ChatConversationViewNode { + const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined + const base = { + key: `fixture:${node.kind}:${node.seq}`, + id: String(node.seq), + target: 'chat' as const, + anchorSeq: node.seq, + location: turn === undefined + ? { kind: 'session' as const } + : { kind: 'turn' as const, turn }, + visibility: 'visible' as const, + } + switch (node.kind) { + case 'assistant': + return { ...base, kind: 'assistant-step', data: assistantData(node) } + case 'tool-result': + return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } } + case 'model-retry': + return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } } + default: + return { ...base, kind: node.kind, data: node } + } +} + +/** Build the canonical Chat fixture corresponding to one legacy test slice. */ +export function chatSnapshotFixture(input: { + readonly nodes?: readonly ConversationNode[] + readonly partial?: PartialAssistant | null + readonly runningCalls?: readonly RunningToolCall[] + readonly turnTimings?: LegacyConversationSlice['turnTimings'] + readonly turnEnds?: LegacyConversationSlice['turnEnds'] +} = {}, previous?: ChatSnapshot): ChatSnapshot { + const legacy: LegacyConversationSlice = { + nodes: input.nodes ?? EMPTY, + partial: input.partial ?? null, + runningCalls: input.runningCalls ?? EMPTY, + turnTimings: input.turnTimings ?? new Map(), + turnEnds: input.turnEnds ?? new Map(), + } + const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()]) + for (const node of legacy.nodes) { + if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn) + } + if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn) + for (const call of legacy.runningCalls) turnNumbers.add(call.turn) + const turns = new Map() + const turnData = new Map() + for (const turn of [...turnNumbers].sort((left, right) => left - right)) { + const timing = legacy.turnTimings.get(turn) + const endSeq = legacy.turnEnds.get(turn) + const data = new FixtureTurnDataStore() + turnData.set(turn, data) + turns.set(turn, { + turn, + start: timing === undefined ? undefined : { + type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn, + } as never, + end: timing?.endTime === undefined || endSeq === undefined ? undefined : { + type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed', + } as never, + status: endSeq === undefined ? 'open' : 'closed', + steps: EMPTY, + data, + }) + } + const linkedCompactions = new Set() + const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => { + if (node.kind === 'command' && node.name === 'compact') { + const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined + const candidates = sourceSeq === undefined + ? [] + : legacy.nodes.filter((candidate): candidate is CompactionSummaryNode => + candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq) + const compaction = candidates.length === 1 ? candidates[0] : undefined + if (node.outcome === null || compaction !== undefined) { + if (compaction !== undefined) linkedCompactions.add(compaction) + const base = settledNode(node, turns) + return [{ + ...base, + key: `fixture:manual-compaction:${node.commandId}`, + kind: 'manual-compaction', + anchorSeq: compaction?.seq ?? node.seq, + data: { command: node, compaction: compaction ?? null }, + }] + } + } + if (node.kind === 'compaction' && linkedCompactions.has(node)) return [] + return [settledNode(node, turns)] + }) + if (legacy.partial !== null) { + const turn = turns.get(legacy.partial.turn) + nodes.push({ + key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`, + id: `${legacy.partial.turn}:${legacy.partial.step}`, + target: 'chat', + kind: 'assistant-step', + anchorSeq: Number.MAX_SAFE_INTEGER - 1, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { + status: 'running', + turn: legacy.partial.turn, + step: legacy.partial.step, + blocks: legacy.partial.blocks, + time: 0, + }, + }) + } + for (const call of legacy.runningCalls) { + const turn = turns.get(call.turn) + nodes.push({ + key: `fixture:tool:${call.callId}`, + id: call.callId, + target: 'chat', + kind: 'tool-call', + anchorSeq: Number.MAX_SAFE_INTEGER, + location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn }, + visibility: 'visible', + data: { root: call }, + }) + } + for (const [turnNumber, endSeq] of legacy.turnEnds) { + const turn = turns.get(turnNumber) + const dataStore = turnData.get(turnNumber) + if (turn === undefined || dataStore === undefined) continue + const closing = legacy.nodes + .filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant' + && candidate.turn === turnNumber + && candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')) + .map(assistantData) + .at(-1) ?? null + const preceding = nodes.findLast((candidate) => { + const location = candidate.location + return (location.kind === 'turn' || location.kind === 'step') + && location.turn.turn === turnNumber + }) + const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber) + const tailData = { + turn: turnNumber, + seq: endSeq, + time: turn.end?.time ?? 0, + closing, + branchUnavailable: closing === null + || preceding?.kind !== 'assistant-step' + || (preceding.data as ReturnType).finalNode.seq !== closing.finalNode.seq, + ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, + ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, + } + dataStore.set('turn-tail', tailData) + nodes.push({ + key: `fixture:turn-tail:${turnNumber}`, + id: String(turnNumber), + target: 'chat', + kind: 'turn-tail', + anchorSeq: endSeq, + location: { kind: 'turn', turn }, + visibility: 'visible', + data: tailData, + }) + } + const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore() + store.replace(nodes) + const byKey = new Map(store.values().map(node => [node.key, node])) + const nextOrder = nodes.map(node => node.key) + const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder + const byTurn = new Map() + for (const turn of turns.keys()) { + byTurn.set(turn, order.filter((key) => { + const location = byKey.get(key)?.location + return location?.kind === 'turn' && location.turn.turn === turn + || location?.kind === 'step' && location.turn.turn === turn + })) + } + const locations = previous?.locations instanceof FixtureLocationIndex + ? previous.locations + : new FixtureLocationIndex() + locations.replace(byTurn) + const timeline = previous !== undefined + && previous.legacy.turnTimings === legacy.turnTimings + && previous.legacy.turnEnds === legacy.turnEnds + ? previous.timeline + : { turnOrder: [...turns.keys()], turns } + return { + order, + nodes: store, + locations, + timeline, + legacy, + } +} diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index 78fbf43c5e..959a91c3ac 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { en, zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: StatsLineProps['t'] = makeTranslate(zh, commonZh) @@ -42,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } } function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture({ + nodes: initial.nodes, + partial: initial.partial, + runningCalls: initial.runningCalls, + turnTimings: initial.turnTimings, + turnEnds: initial.turnEnds, + }), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({ + nodes: merged.nodes, + partial: merged.partial, + runningCalls: merged.runningCalls, + turnTimings: merged.turnTimings, + turnEnds: merged.turnEnds, + })), + } for (const fn of [...subs]) fn() }, source: { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 9bb043df15..296dd85966 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -4,24 +4,33 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Profiler } from 'react' import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, - ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode, + ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' -import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' +import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' +import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' +import { + CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, + UnknownNodeView, UserMessageNodeView, +} from '../src/client/chat/MessageItem.tsx' +import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' afterEach(() => { cleanup() @@ -34,10 +43,11 @@ beforeEach(() => { }) const SID = 's1' as SessionId +type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot { /** Scripted snapshot source: set() swaps the top-level object like the real Session. */ function makeSource(init?: Partial) { - let snap: ConversationSnapshot = { ...snapshotBase(), ...init } + const initial = { ...snapshotBase(), ...init } + let snap: ConversationSnapshot = { + ...initial, + chat: init?.chat ?? chatSnapshotFixture(initial), + } const subs = new Set<() => void>() return { set: (next: Partial) => { - snap = { ...snap, ...next } + const merged = { ...snap, ...next } + snap = { + ...merged, + chat: Object.hasOwn(next, 'chat') && next.chat !== undefined + ? next.chat + : chatSnapshotFixture(merged, snap.chat), + } for (const fn of [...subs]) fn() }, source: { @@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], }) const retry = (seq: number): ModelRetryNode => ({ - kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0, + kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'], + seq, time: seq * 1_000, turn: 1, step: 0, retryState: 'scheduled', provider: 'mock', mode: 'normal', policyKey: 'mock-normal', retry: 1, maxRetries: 2, delayMs: 450, @@ -139,25 +160,97 @@ function makeHarness(init?: Partial) { // production; the view reads it through the PropsStore useStore share). const chat = createChatStore().create() const t = makeTranslate(zh, commonZh) - const toolOwners: ToolTreeOwnerProps[] = [] - const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { - if (key !== 'conversation.chat.tool') return opts?.fallback ?? null - const tool = owner as ToolTreeOwnerProps - toolOwners.push(tool) - // Tool providers own their subtree. The host double carries only the - // semantic anchor required by ChatView's prepend-position contract. - return ( -
- {tool.toolName || '(unnamed)'}:{tool.callId} -
+ const toolOwners: Array<{ + callId: string + toolName: string + block: ToolCallBlock + selectedCallId: string | undefined + openFile: ChatNodeOwnerProps['openFile'] + inspectCall: ChatNodeOwnerProps['inspectCall'] + }> = [] + const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as React.ComponentProps['renderSlot'] + const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as + React.ComponentProps['renderSlotChain'] + const renderTurnTailSlot = (() => null) as unknown as + React.ComponentProps['renderSlot'] + const renderSlot = ((key: string, owner: object, opts?: { + fallback?: React.ReactNode + hookContext?: unknown + }) => { + if (key !== 'conversation.chat.node') return opts?.fallback ?? null + const nodeOwner = owner as RoutedChatNodeOwner + const nodeKey = opts?.hookContext as string | undefined + const useTurnData = ((dataKey: string) => props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey as never) + : undefined + })) as UseChatNodeTurnData + const nodeProps = (): ChatNodeViewProps => ( + { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) + switch (nodeOwner.node.kind) { + case 'user': + case 'steering': + return ()} /> + case 'context': + return ()} /> + case 'assistant-step': + return ()} /> + case 'command': + return ( + ()} + renderSlot={renderCommandSlot} + SessionProvider={props.SessionProvider} + /> + ) + case 'manual-compaction': + return ()} /> + case 'compaction': + return ()} /> + case 'model-retry': + return ()} /> + case 'turn-error': + return ()} /> + case 'turn-tail': + return ( + ()} + renderSlot={renderTurnTailSlot} + renderSlotChain={renderTurnTail} + SessionProvider={props.SessionProvider} + /> + ) + case 'unknown': + return ()} /> + case 'tool-call': { + const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root + const toolName = 'kind' in block ? block.call?.name ?? '' : block.name + const tool = { + callId: block.callId, + toolName, + block, + selectedCallId: nodeOwner.selectedCallId, + openFile: nodeOwner.openFile, + inspectCall: nodeOwner.inspectCall, + } + toolOwners.push(tool) + return ( +
+ {tool.toolName || '(unnamed)'}:{tool.callId} +
+ ) + } + default: + return opts?.fallback ?? null + } }) as unknown as ChatViewSlotProps['renderSlot'] - const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => - opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)} @@ -172,7 +265,6 @@ function makeHarness(init?: Partial) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, - renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -218,139 +310,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien } } -describe('chat-flow derivation', () => { - it('groups consecutive tool results and keeps stable keys', () => { - const nodes: ConversationNode[] = [ - user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'), - assistant(5, 'found'), toolResult(6, 'c'), - ] - const items = deriveChatFlow(nodes) - expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group']) - const group = items[2]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') - expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') - }) - - it('reuses one stable row for consecutive retry turns', () => { - const first = retry(2) - const second = { ...retry(3), turn: 2, retry: 2 } - const initial = deriveChatFlow([user(1, 'try'), first]) - const updated = deriveChatFlow([user(1, 'try'), first, second]) - expect(flowKeys(initial)).toBe('n1|n2') - expect(flowKeys(updated)).toBe('n1|n2') - expect(updated).toHaveLength(2) - expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second) - }) - - it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => { - const running = command({ - seq: 1, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: null, - }) - expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact') - - const settled = { - ...running, - outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 }, - } - const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 }) - const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint]) - expect(flowKeys(items)).toBe('n0|n2|ccmd-compact') - expect(items.at(-1)).toEqual({ - kind: 'command-compaction', - key: 'ccmd-compact', - command: settled, - compaction: checkpoint, - }) - }) - - it('does not split adjacent tool results around a folded /compact command', () => { - const folded = command({ - seq: 2, - commandId: 'cmd-compact' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 4 }, - }) - const items = deriveChatFlow([ - toolResult(1, 'a'), - folded, - toolResult(3, 'b'), - compaction({ seq: 5, summaryEventSeq: 4 }), - ]) - expect(flowKeys(items)).toBe('g1|ccmd-compact') - expect( - items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId), - ).toEqual(['a', 'b']) - }) - - it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => { - const automatic = compaction({ seq: 2, summaryEventSeq: 1 }) - expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2') - - const first = command({ - seq: 3, - commandId: 'cmd-a' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const second = command({ - seq: 4, - commandId: 'cmd-b' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 9 }, - }) - const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 }) - expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10') - - const sole = command({ - seq: 11, - commandId: 'cmd-sole' as CommandNode['commandId'], - name: 'compact', - outcome: { kind: 'success', sourceEventSeq: 12 }, - }) - const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 }) - const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 }) - expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14') - }) - - it('skips render-nothing assistant nodes so tool runs stay one group', () => { - // A tool-call-only step message (and blank text/reasoning) renders nothing: - // it must not split the run into two groups with an empty line between. - const headsOnly: AssistantMessageNode = { - kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, - blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], - } - const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) - expect(flowKeys(items)).toBe('g3') - const group = items[0]! - expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) - // Interrupted and visible-content nodes still render (已停止 marker / prose). - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') - expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') - }) - - it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => { - const thinkOnly: AssistantMessageNode = { - kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'planning' }], - } - const nodes: ConversationNode[] = [ - user(1, 'hi'), - assistant(2, 'looking', 1), - thinkOnly, - toolResult(4, 'a'), - assistant(5, 'done', 1), - user(6, 'again'), - assistant(7, 'second turn', 2), - ] - expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7]) - // Turn 2 is still producing steps: its latest narration owns nothing, and - // the settled turn 1 keeps its seat. - expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) - }) +describe('Chat node rendering', () => { it('threads the injected file-mention vocabulary into the closing prose only', () => { const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ @@ -391,17 +351,6 @@ describe('chat-flow derivation', () => { expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000 }], - ]))).toBe(6_000) - expect(runningTurnStartTime(new Map([ - [1, { startTime: 1_000, endTime: 5_000 }], - [2, { startTime: 6_000, endTime: 9_000 }], - ]))).toBeNull() - }) - it('formatRunDuration localizes units and floors partial seconds', () => { const t = makeTranslate(zh, commonZh) expect(formatRunDuration(0, t)).toBe('0秒') @@ -410,24 +359,6 @@ describe('chat-flow derivation', () => { expect(formatRunDuration(125_000, t)).toBe('2分05秒') }) - it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => { - const interruptedThink: AssistantMessageNode = { - kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, - blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, - } - const nodes: ConversationNode[] = [ - user(1, 'first'), - assistant(2, 'answer before tools'), - toolResult(3, 'a'), - interruptedThink, - user(6, 'second'), - assistant(7, 'clean tail', 2), - user(10, 'user-only tail'), - user(13, 'steering tail'), - ] - const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]])) - expect([...seqs]).toEqual([7]) - }) }) describe('ChatView', () => { @@ -444,8 +375,8 @@ describe('ChatView', () => { const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement - const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement + const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement + const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement let firstTop = 100 let nextTop = 300 vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation( @@ -472,7 +403,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) - it('renders the fixture main line: bubble, narration, grouped tool rows', () => { + it('renders the fixture main line as independently keyed business nodes', () => { const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')], }) @@ -485,14 +416,18 @@ describe('ChatView', () => { key: row.getAttribute('data-chat-flow-key'), kind: row.getAttribute('data-chat-flow-kind'), }))).toEqual([ - { key: 'n1', kind: 'user' }, - { key: 'n2', kind: 'assistant' }, - { key: 'g3', kind: 'tool-group' }, + { key: 'fixture:user:1', kind: 'user' }, + { key: 'fixture:assistant:2', kind: 'assistant-step' }, + { key: 'fixture:tool:a', kind: 'tool-call' }, + { key: 'fixture:tool:b', kind: 'tool-call' }, ]) expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id'))) .toEqual(['a', 'b']) expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) - .toEqual(['node:1', 'node:2', 'call:a', 'call:b']) + .toEqual([ + 'fixture:user:1', 'fixture:assistant:2', + 'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b', + ]) }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { @@ -559,14 +494,13 @@ describe('ChatView', () => { act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) - // The completed turn's transcript tail is the steering bubble, not the - // narration, so the assistant's branch action stays unavailable and the - // steering bubble still offers none. + // The Turn Tail belongs to the closed Turn, independently of a later + // steering bubble's placement in the Chat list. const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) expect(branchButtons).toHaveLength(1) - expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true') + expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull() fireEvent.click(branchButtons[0]!) - expect(h.forkAt).not.toHaveBeenCalled() + expect(h.forkAt).toHaveBeenCalledWith(1) }) it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { @@ -607,7 +541,7 @@ describe('ChatView', () => { expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { - h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }) + h.set({ nodes: [user(1, 'try'), nextRetry] }) }) expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) @@ -617,7 +551,6 @@ describe('ChatView', () => { h.set({ nodes: [ user(1, 'try'), - retryNode, { ...nextRetry, retryState: 'started' }, context, assistant(5, 'done'), @@ -760,7 +693,7 @@ describe('ChatView', () => { turnEnds: new Map([[1, 2]]), }) const view = render() - // One scope per message row; the CSS reveal keys off this attribute. + // The user row and the settled assistant's Turn Tail each own one clock scope. expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2) }) @@ -787,7 +720,7 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { + it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -843,19 +776,13 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('h1')).toHaveLength(2) }) - it('streaming partial frames re-render only the tail (Profiler count)', () => { + it('streaming partial frames update the tail without replacing a sibling Tool row', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')], }) - let renders = 0 - const counting = ( - { renders += 1 }}> - - - ) - const view = render(counting) - const before = renders - const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML + const view = render() + const tool = view.getByTestId('tool-seat-a') + const beforeHtml = tool.innerHTML act(() => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } }) }) @@ -863,9 +790,8 @@ describe('ChatView', () => { h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } }) }) expect(view.getByText('streaming… more')).toBeTruthy() - // Each chunk commits exactly one profiler pass (the tail), never a full-tree storm. - expect(renders - before).toBe(2) - expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml) + expect(view.getByTestId('tool-seat-a')).toBe(tool) + expect(tool.innerHTML).toBe(beforeHtml) }) it('streaming leaves neighbor tool rows and history items at zero re-renders', () => { @@ -875,8 +801,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((key: string, _owner: object) => { - if (key !== 'conversation.chat.tool') return null + h.props.renderSlot = ((key: string, owner: object) => { + if (key !== 'conversation.chat.node' + || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null rowRenders += 1 return
}) @@ -908,6 +835,54 @@ describe('ChatView', () => { expect(view.getByRole('status').textContent).toBe('Deep diving...') }) + it('keeps the Tool renderer mounted when a running call settles into log order', () => { + const mounted = vi.fn() + const unmounted = vi.fn() + function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) { + useEffect(() => { + mounted() + return () => { unmounted() } + }, []) + const root = (node.data as { readonly root: ToolCallBlock }).root + return ( +
+ {root.callId} +
+ ) + } + + const h = makeHarness({ + nodes: [user(1, 'q'), assistant(4, 'later')], + runningCalls: [runningCall('r1')], + running: true, + }) + h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + const routed = owner as RoutedChatNodeOwner + return key === 'conversation.chat.node' && routed.node.kind === 'tool-call' + ? + : opts?.fallback ?? null + }) as ChatViewSlotProps['renderSlot'] + const view = render() + const tool = view.getByTestId('stateful-tool') + const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]') + expect(tool.dataset.state).toBe('running') + expect(mounted).toHaveBeenCalledTimes(1) + + act(() => { + h.set({ + nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')], + runningCalls: [], + running: false, + }) + }) + + expect(view.getByTestId('stateful-tool')).toBe(tool) + expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row) + expect(tool.dataset.state).toBe('settled') + expect(mounted).toHaveBeenCalledTimes(1) + expect(unmounted).not.toHaveBeenCalled() + }) + it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => { const startTime = Date.now() - 125_000 const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 } @@ -932,7 +907,7 @@ describe('ChatView', () => { expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/) }) - it('hands each ordered root call to the whole-Tool slot', () => { + it('hands each ordered root call to the keyed business-node slot', () => { const block = toolResult(3, 'a') const h = makeHarness({ nodes: [block] }) const calls: { key: string; owner: object; entryKey?: string }[] = [] @@ -943,14 +918,14 @@ describe('ChatView', () => { render() expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ - key: 'conversation.chat.tool', - owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined }, + key: 'conversation.chat.node', + owner: { node: { kind: 'tool-call' }, selectedCallId: undefined }, + entryKey: 'tool-call', }) - const owner = calls[0]?.owner as ToolTreeOwnerProps - expect(owner.block).toBe(block) + const owner = calls[0]?.owner as RoutedChatNodeOwner + expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) expect(owner.openFile).toBe(h.openFile) expect(owner.inspectCall).toBe(h.inspectCall) - expect(calls[0]?.entryKey).toBeUndefined() }) it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { @@ -960,7 +935,7 @@ describe('ChatView', () => { // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) - const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement + const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement let anchoredTop = 100 vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation( () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect), @@ -977,60 +952,6 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(1600) }) - it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => { - const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'call:late') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - // Total height grows by 500, but only 300 belongs before the call row. - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) }) - expect(scroller.scrollTop).toBe(380) - } finally { - rect.mockRestore() - } - }) - - it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => { - const h = makeHarness({ nodes: [retry(5)], hasMore: true }) - const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement - let prepended = false - const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:5') { - const top = prepended ? 400 : 100 - return { top, bottom: top + 40 } as DOMRect - } - return { top: 0, bottom: 200 } as DOMRect - }) - try { - Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) - Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) - readerScroll(scroller, 80) - fireEvent.click(view.getByText('加载更早')) - Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) - prepended = true - act(() => { h.set({ nodes: [retry(4), retry(5)] }) }) - expect(scroller.scrollTop).toBe(380) - expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull() - } finally { - rect.mockRestore() - } - }) - it('back-to-bottom cancels an in-flight paging anchor', () => { const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) const view = render() @@ -1177,7 +1098,7 @@ describe('ChatView', () => { () => ({ top: 0, bottom: 500 } as DOMRect), ) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') { + if (this.dataset.chatAnchorKey === 'fixture:user:1') { return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect } return { top: 0, bottom: 40 } as DOMRect @@ -1216,12 +1137,12 @@ describe('ChatView', () => { }) document.body.appendChild(host) const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect + if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect return { top: 0, bottom: 500 } as DOMRect }) try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) - h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 }) + h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 }) const view = render(, { container: host }) expect(host.scrollTop).toBe(1_500) expect(h.chatScroll.read()).toBeNull() diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts new file mode 100644 index 0000000000..3ae906087e --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -0,0 +1,720 @@ +import { describe, expect, it } from 'vitest' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationEventInput, + ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +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' +import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' +import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' +import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' +import { messageDefinition } from '../src/client/conversation-nodes/message.ts' +import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' +import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' +import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' +import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' +import type { + AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, +} from '../src/client/contract/chat-nodes.ts' + +const DEFINITIONS: readonly ConversationNodeDefinition[] = [ + nextTurnInboxDefinition, + nextStepInboxDefinition, + messageDefinition, + assistantDefinition, + toolDefinition, + commandDefinition, + compactionDefinition, + retryDefinition, + turnErrorDefinition, + turnTailDefinition, +] + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return DEFINITIONS + } + + fallbackEntry(): ConversationNodeDefinition { + return unknownFallbackDefinition + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [chatViewDefinition] + } +} + +function at( + seq: number, + type: string, + data: unknown, + extra: Record = {}, +): ConversationEventInput { + return { + event: { + seq, + time: 1_700_000_000_000 + seq, + type, + data, + ...extra, + } as unknown as ConversationEventInput['event'], + view: undefined, + } +} + +function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function snapshot(value: ConversationNodeAssembler): ChatSnapshot { + const current = value.snapshot('chat') as ChatSnapshot | undefined + if (current === undefined) throw new Error('chat view was not registered') + return current +} + +function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined { + return value.nodes.values().find(candidate => candidate.kind === kind) +} + +function textMessage(id: string, text: string) { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + } +} + +function assistantMessage(id: string, text: string) { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + } +} + +function toolResult(callId: string, text: string) { + return { + id: `result-${callId}`, + role: 'user', + source: { kind: 'tool', callId }, + content: [{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text }], + isError: false, + }], + } +} + +describe('built-in conversation node Definitions', () => { + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { + const value = assembler([ + 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: 'streaming' }, + }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'assistant-step') + expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] }) + const order = runningSnapshot.order + + value.append(at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'settled'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'assistant-step') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] }) + + const interruptedValue = assembler([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'assistant/chunk', { + turn: 2, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'partial' }, + }), + at(13, 'step/end', { turn: 2, step: 1 }), + ]) + const interrupted = node(snapshot(interruptedValue), 'assistant-step') + expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) + expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + + const hiddenValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'llm/retry', { + retryId: 'retry-hidden', + turn: 3, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + ]) + expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined() + + const toolOnlyValue = assembler([ + at(30, 'turn/start', { turn: 4 }), + at(31, 'step/start', { turn: 4, step: 1 }), + at(32, 'assistant/chunk', { + turn: 4, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' }, + }), + at(33, 'assistant/message', { + turn: 4, + step: 1, + message: { + ...assistantMessage('assistant-tool-only', ''), + content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], + }, + }, { surfaceOp: 'append' }), + ]) + const toolOnlySnapshot = snapshot(toolOnlyValue) + expect(toolOnlySnapshot.order).toEqual([]) + expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') + expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ + kind: 'assistant', + seq: 33, + timing: { firstTokenTime: 1_700_000_000_032 }, + }]) + + const interruptedToolOnlyValue = assembler([ + at(35, 'turn/start', { turn: 5 }), + at(36, 'step/start', { turn: 5, step: 1 }), + at(37, 'assistant/chunk', { + turn: 5, + step: 1, + chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' }, + }), + at(38, 'step/end', { turn: 5, step: 1 }), + ]) + const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step') + expect(interruptedToolOnly?.visibility).toBe('visible') + expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' }) + + const retryTimingValue = assembler([ + at(50, 'turn/start', { turn: 6 }), + at(51, 'step/start', { turn: 6, step: 1 }), + at(52, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first attempt' }, + }), + at(53, 'llm/retry', { + retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + }), + at(54, 'assistant/chunk', { + turn: 6, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'second attempt' }, + }), + at(55, 'assistant/message', { + turn: 6, + step: 1, + message: assistantMessage('assistant-retried', 'done'), + }, { surfaceOp: 'append' }), + ]) + const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode + expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052) + + const partialWindow = assembler([ + at(40, 'assistant/chunk', { + turn: 5, + step: 2, + chunk: { type: 'text-delta', index: 0, text: 'loaded partial' }, + }), + at(41, 'step/end', { turn: 5, step: 2 }), + ], true) + const recovered = node(snapshot(partialWindow), 'assistant-step') + expect(recovered?.data).toMatchObject({ + status: 'interrupted', + blocks: [{ kind: 'text', text: 'loaded partial' }], + }) + }) + + 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 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }), + ]) + const runningSnapshot = snapshot(value) + const running = node(runningSnapshot, 'tool-call') + expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' }) + const order = runningSnapshot.order + + value.append(at(4, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'done'), + }, { surfaceOp: 'append' })) + value.flush() + + const settledSnapshot = snapshot(value) + const settled = node(settledSnapshot, 'tool-call') + expect(settled?.key).toBe(running?.key) + expect(settledSnapshot.order).toBe(order) + expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' }) + + const history = assembler([ + at(14, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + }), + at(15, 'tool/code-dispatch', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + isError: false, + content: [{ type: 'text', text: 'contents' }], + }), + at(16, 'tool/result', { + turn: 2, + step: 1, + message: toolResult('history-root', 'root done'), + }, { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(history), 'tool-call') + expect((before?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + history.prepend([ + at(10, 'turn/start', { turn: 2 }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(13, 'tool/call', { + turn: 2, + step: 1, + callId: 'history-root', + name: 'code', + arguments: '{}', + }), + ], false) + history.flush() + + const after = node(snapshot(history), 'tool-call') + expect(after?.key).toBe(before?.key) + expect((after?.data as ToolChatData).root.subCalls).toMatchObject([ + { kind: 'tool-result', callId: 'child', call: { name: 'read' } }, + ]) + + const firstChild = (after?.data as ToolChatData).root.subCalls[0] + history.append(at(17, 'tool/code-dispatch-start', { + rootCallId: 'history-root', + parentCallId: 'history-root', + subCallId: 'second-child', + name: 'write', + arguments: { path: 'out.txt' }, + })) + history.flush() + const withSecondChild = node(snapshot(history), 'tool-call') + expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild) + }) + + it('prepends an older turn without replacing already materialized nodes', () => { + const value = assembler([ + at(20, 'turn/start', { turn: 2 }), + at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }), + at(22, 'step/start', { turn: 2, step: 1 }), + at(23, 'assistant/message', { + turn: 2, + step: 1, + message: assistantMessage('newer-assistant', 'newer answer'), + }, { surfaceOp: 'append' }), + at(24, 'step/end', { turn: 2, step: 1 }), + at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }), + ], true) + const before = snapshot(value) + const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '') + const store = before.nodes + + value.prepend([ + at(10, 'turn/start', { turn: 1 }), + at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }), + at(12, 'step/start', { turn: 1, step: 1 }), + at(13, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('older-assistant', 'older answer'), + }, { surfaceOp: 'append' }), + at(14, 'step/end', { turn: 1, step: 1 }), + at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ], false) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(store) + expect(after.nodes.get(existing?.key ?? '')).toBe(existing) + expect(after.order).toHaveLength(before.order.length + 3) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', + 'user', 'assistant-step', 'turn-tail', + ]) + }) + + it('appends a later turn without replacing nodes from the completed turn', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('first-assistant', 'first answer'), + }, { surfaceOp: 'append' }), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + const before = snapshot(value) + const oldOrder = before.order + const oldNodes = oldOrder.map(key => before.nodes.get(key)) + + value.append(at(7, 'turn/start', { turn: 2 })) + value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' })) + value.flush() + + const after = snapshot(value) + expect(after.nodes).toBe(before.nodes) + expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder) + expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes) + expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([ + 'user', 'assistant-step', 'turn-tail', 'user', + ]) + }) + + it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => { + const value = assembler([ + at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }), + ], true) + const before = node(snapshot(value), 'user') + expect(before).toBeDefined() + + value.prepend([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [textMessage('steer-1', 'change direction')], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + removedCount: 1, + inserted: [], + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'steering') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' }) + expect(node(snapshot(value), 'user')).toBeUndefined() + }) + + it('classifies appended producer context from durable source metadata', () => { + const value = assembler([ + at(1, 'user/message', { + ...textMessage('skill-context', 'follow these instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' }), + ]) + + expect(node(snapshot(value), 'context')?.data).toMatchObject({ + kind: 'context', + provenance: { role: 'inject', label: 'demo-skill' }, + form: 'instructions', + }) + }) + + it('keeps replacement copies out of Chat business nodes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'user/message', { + ...textMessage('replacement-user', 'model-only context'), + source: { kind: 'plugin', plugin: 'foreign' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('replacement-assistant', 'rewritten answer'), + }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }), + at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }), + at(6, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'pruned result'), + }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }), + ]) + + const current = snapshot(value) + expect(node(current, 'user')).toBeUndefined() + expect(node(current, 'context')).toBeUndefined() + expect(node(current, 'assistant-step')).toBeUndefined() + expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind') + }) + + it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => { + const retry = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }), + at(5, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ]) + const retryNode = node(snapshot(retry), 'model-retry') + const retryData = retryNode?.data as RetryChatData + expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled']) + expect(node(snapshot(retry), 'turn-error')).toBeUndefined() + + const compactions = assembler([ + at(10, 'command/run', { + commandId: 'command-1', + name: 'compact', + source: { kind: 'user' }, + }), + at(11, 'compact/start', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(12, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(13, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(14, 'compact/end', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + turn: null, + }), + at(15, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 12, + }), + at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }), + at(21, 'compact/summary', { + compactionId: 'automatic-1', + summary: [{ type: 'text', text: 'automatic summary' }], + shadowedSeqs: [3, 4], + shadowedTokenCount: 200, + }), + at(22, 'user/message', { + ...textMessage('automatic-checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' }, + }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }), + at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }), + ]) + + const manual = node(snapshot(compactions), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({ + summary: 'manual summary', + summaryEventSeq: 12, + }) + const automatic = node(snapshot(compactions), 'compaction') + expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 }) + expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1) + }) + + it('fills a landed compaction marker when an older page supplies its summary', () => { + const value = assembler([ + at(13, 'user/message', { + ...textMessage('checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }), + ], true) + const before = node(snapshot(value), 'compaction') + expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null }) + + value.prepend([ + at(9, 'compact/start', { compactionId: 'compact-1', turn: null }), + at(10, 'compact/summary', { + compactionId: 'compact-1', + summary: [ + { type: 'text', text: 'older ' }, + { type: 'image', data: 'ignored' }, + { type: 'text', text: 'summary' }, + ], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + ], false) + value.flush() + + const after = node(snapshot(value), 'compaction') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ + summary: 'older summary', + summaryEventSeq: 10, + shadowedItemCount: 3, + shadowedTokenCount: 42, + }) + }) + + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { + const value = assembler([ + at(5, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 2, + maxRetries: 2, + delayMs: 20, + failure: { code: 'TRANSPORT', message: 'second' }, + }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ], true) + + expect(node(snapshot(value), 'model-retry')).toBeUndefined() + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + + value.prepend([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'llm/retry', { + retryId: 'retry-paged', + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first' }, + }), + at(4, 'llm/retry-started', { + retryId: 'retry-paged', turn: 1, step: 1, retry: 1, + }), + ], false) + value.flush() + + const retry = node(snapshot(value), 'model-retry') + expect((retry?.data as RetryChatData).attempts).toHaveLength(2) + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + }) + + it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { + const value = assembler([ + at(12, 'tool/code-dispatch-start', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + }), + at(13, 'tool/code-dispatch', { + rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' }, + isError: false, content: [{ type: 'text', text: 'child result' }], + }), + at(14, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('root', 'root result'), + }, { surfaceOp: 'append' }), + at(20, 'compact/summary', { + compactionId: 'manual-1', + sourceCommandId: 'command-1', + summary: [{ type: 'text', text: 'manual summary' }], + shadowedSeqs: [1, 2], + shadowedTokenCount: 100, + }), + at(21, 'user/message', { + ...textMessage('manual-checkpoint', 'checkpoint'), + source: { + kind: 'plugin', + plugin: 'compact', + compactionId: 'manual-1', + sourceCommandId: 'command-1', + }, + }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }), + at(22, 'command/done', { + commandId: 'command-1', + kind: 'success', + sourceEventSeq: 20, + }), + ], true) + + const tool = node(snapshot(value), 'tool-call') + const root = (tool?.data as ToolChatData).root + expect(root.subCalls).toHaveLength(1) + expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' }) + const manual = node(snapshot(value), 'manual-compaction') + expect((manual?.data as ManualCompactionChatData)).toMatchObject({ + command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } }, + compaction: { summary: 'manual summary', summaryEventSeq: 20 }, + }) + }) +}) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3afe471d50..ff3a0f1598 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' @@ -15,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch import { StatsLine } from '../src/client/chat/StatsLine.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { zh } from '../src/client/locales.ts' +import { chatSnapshotFixture } from './chat-snapshot-fixture.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -40,14 +41,15 @@ const SessionProviderStub: SessionProviderComponent = ({ children }) => children /** Observe the owner currency without importing the Tool details renderer. */ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] { return (_key, owner) => { - owners?.push(owner as DetailsToolOwnerProps) + owners?.push(owner as unknown as DetailsToolOwnerProps) return
} } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -69,11 +71,16 @@ describe('render branch tails', () => { it('StatsLine counts window nodes but drops every token group without a projection', () => { // Node `usage` is deliberately ignored: billing rides the durable // tokenUsage projection, so an absent projection leaves counts only. + const nodes = [ + { kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] }, + { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, + { kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ] as const const snap = { + ...snapshotBase(), + chat: chatSnapshotFixture({ nodes }), nodes: [ - { kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] }, - { kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } }, - { kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, + ...nodes, ], } const source = { getSnapshot: () => snap, subscribe: () => () => {} } @@ -146,6 +153,7 @@ describe('render branch tails', () => { }], }], }] + snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls }) const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 1e7c3a1ba1..e9076fa994 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -35,7 +35,8 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f197528a2b..65fa5705ca 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -26,7 +26,8 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index aae5bb4818..94d348d17a 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -11,7 +11,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 826abaf846..31a6423f87 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' +import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 70b6d96710..bbabbaeb0c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 3bcaffe605..b7ca73a79f 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -8,15 +8,22 @@ import { Context } from 'cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationNodeAssembler, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { - AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, + ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition, + ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, + ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' -import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { + basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, + type DeliverablesTurnData, +} from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -24,101 +31,187 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) -const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', - seq, - time: seq * 1000, - content: [{ type: 'text', text }] as never, - source: null, +class TestTurnDataStore implements ConversationLocationDataStore { + private readonly values = new Map() + + get( + key: Key, + ): Readonly | undefined { + return this.values.get(key) as Readonly | undefined + } + + set(key: Key, value: ConversationTurnDataMap[Key]): void { + this.values.set(key, value) + } +} + +const turnLocation = (turn: number, deliverables?: DeliverablesTurnData): TurnLocation => { + const data = new TestTurnDataStore() + if (deliverables !== undefined) data.set('deliverables', deliverables) + return { turn, start: undefined, end: undefined, status: 'closed', steps: [], data } +} + +const produced = (...values: ReadonlyArray): DeliverablesTurnData => ({ + produced: values.map(([seq, path]) => ({ seq, path })), }) -const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ - kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], -}) -const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, time: seq * 1_000, callId, - call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, - callTime: seq * 1_000 - 500, - content: [], isError: false, callView: null, resultView: null, subCalls: [], -}) -const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { + +function tailOwner( + data: DeliverablesTurnData | undefined, + seq: number, + openFile: (path: string) => void = () => {}, + turn = 1, +): TurnTailOwnerProps { + return { seq, openFile, turn: turnLocation(turn, data) } +} + +interface TimelineSnapshot { + readonly timeline: ConversationTimelineSnapshot +} + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } + fallbackEntry(): undefined { return undefined } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { return [timelineViewDefinition] } +} + +const timelineViewDefinition: ConversationViewDefinition = { + target: 'test', + create: () => { + let current: TimelineSnapshot = { timeline: { turnOrder: [], turns: new Map() } } + return { + empty: current, + replace: ({ timeline }) => (current = { timeline }), + apply: ({ timeline }) => (current = { timeline }), + } + }, +} + +function at( + seq: number, + type: string, + data: unknown, + view?: ConversationEventInput['view'], +): ConversationEventInput { + return { + event: { + seq, time: seq * 1_000, type, data, + ...(type === 'tool/result' ? { surfaceOp: 'append' } : {}), + } as ConversationEventInput['event'], + view, + } +} + +function call( + seq: number, + callId: string, + view: ToolResultNode['callView'], + turn = 1, +): ConversationEventInput { + return at( + seq, + 'tool/call', + { turn, step: 1, callId, name: 'fixture', arguments: '{}' }, + { for: 'call', view: view ?? { card: 'generic', title: 'fixture' } }, + ) +} + +function result(seq: number, callId: string, isError = false, turn = 1): ConversationEventInput { + return at(seq, 'tool/result', { + turn, + step: 1, + message: { + source: { type: 'tool-result', callId }, + content: [{ type: 'tool-result', content: [], isError }], + }, + }) +} + +function diff(...paths: string[]): ToolResultNode['callView'] { + return { card: 'diff', title: `Write ${paths[0] ?? ''}`, diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), locations: paths.map(path => ({ path })), - }, -}) + } +} -describe('producedForClosing derivation', () => { - it('attributes each turn’s written files to the assistant that closes it', () => { - const nodes: ConversationNode[] = [ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ] - expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) - expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() - // A turn that produced nothing yields the empty list, and so does an - // anchor the window does not contain. - expect(producedForClosing(nodes, 9)).toEqual([]) - expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) - expect(producedForClosing(nodes, 999)).toEqual([]) +function edit(path: string): ToolResultNode['callView'] { + return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] } +} + +function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function deliverablesOf(value: ConversationNodeAssembler, turn = 1): Readonly | undefined { + const snapshot = value.snapshot('test') as TimelineSnapshot + return snapshot.timeline.turns.get(turn)?.data.get('deliverables') +} + +describe('produced-file Turn data', () => { + it('deduplicates paths in first-seen order and stops at the closing Assistant seq', () => { + const data = produced( + [3, 'out/index.html'], + [4, 'out/app.css'], + [4, 'out/index.html'], + [8, 'after.txt'], + ) + expect(producedForClosing(data, 6)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles(tailOwner(data, 6))).toEqual(['out/index.html', 'out/app.css']) + expect(producedForClosing(undefined)).toEqual([]) + expect(selectProducedFiles(tailOwner(undefined, 9, () => {}, 2))).toBeNull() }) + it('folds successful diff and generic-edit calls while ignoring reads, failures, and missing locations', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'write', diff('out/index.html', 'out/app.css')), + result(3, 'write'), + call(4, 'edit', edit('notes.md')), + result(5, 'edit'), + call(6, 'read', { card: 'generic', title: 'Read', locations: [{ path: 'input.txt' }] }), + result(7, 'read'), + call(8, 'failed', diff('broken.txt')), + result(9, 'failed', true), + call(10, 'locationless', { card: 'diff', title: 'Write', diffs: [] }), + result(11, 'locationless'), + ]) - it('counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const nodes: ConversationNode[] = [ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ] - expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and the dedup set did not suppress the - // rewrite of a path an earlier turn already touched. - expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) - expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + expect(producedForClosing(deliverablesOf(value))).toEqual([ + 'out/index.html', 'out/app.css', 'notes.md', + ]) }) - it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { - const nodes: ConversationNode[] = [ - user(1, 'go'), - // A turnless surface node neither tracks nor resets the boundary. - { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, - wrote(2, 'w', 'turn-one.txt'), - // A view-less result (window truncation) and cards without locations - // contribute nothing rather than crashing the walk. - toolResult(3, 'plain'), - { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, - { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, - assistant(6, 'mid narration', 1), - // Turn number advances with no user message in the window (truncated - // history): the accumulator must reset all the same. - assistant(7, 'closing', 2), - ] - expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) - expect(producedForClosing(nodes, 7)).toEqual([]) + it('replays a tail page once prepend supplies its missing Turn start', () => { + const value = assembler([ + call(10, 'late', diff('history.txt')), + result(11, 'late'), + ], true) + expect(deliverablesOf(value)).toBeUndefined() + + value.prepend([at(1, 'turn/start', { turn: 1 })], false) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['history.txt']) + }) + + it('extends the same Turn data incrementally on live append', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + call(2, 'first', diff('first.txt')), + result(3, 'first'), + ]) + const first = deliverablesOf(value) + expect(producedForClosing(first)).toEqual(['first.txt']) + + value.append(call(4, 'second', diff('second.txt'))) + value.append(result(5, 'second')) + value.flush() + expect(producedForClosing(deliverablesOf(value))).toEqual(['first.txt', 'second.txt']) }) }) @@ -190,6 +283,7 @@ describe('plugin registration', () => { it('registers the tail entry and fiber disposal removes it', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() + await ctx.plugin(ConversationEventRegistry).await() // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', @@ -204,17 +298,17 @@ describe('plugin registration', () => { // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. const opened: string[] = [] - const owner = { - nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], - seq: 3, - openFile: (path: string) => { opened.push(path) }, - } + const owner = tailOwner( + produced([2, 'site/report.html']), + 3, + (path) => { opened.push(path) }, + ) const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') const mentions = service?.forClosing(owner) mentions?.resolve('report.html')?.open() expect(opened).toEqual(['site/report.html']) // A turn that produced nothing yields no vocabulary at all. - expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined() await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/surface.spec.ts b/packages/client/ui-slots/tests/surface.spec.ts index 12a5eeba15..e85aa324e1 100644 --- a/packages/client/ui-slots/tests/surface.spec.ts +++ b/packages/client/ui-slots/tests/surface.spec.ts @@ -7,6 +7,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'surface.a': { kind: 'single'; scope: 'root' } 'surface.b': { kind: 'single'; scope: 'root' } + 'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } } } } @@ -28,6 +29,16 @@ describe('dynamic-key escape hatch', () => { expect(core.spec('surface.b')).toBeUndefined() }) + it('records the parent-declared Slot inject on the runtime spec', () => { + const core = new SlotCore() + const inject = { token: 'shared' } + core.register({ + name: 'root', + children: { 'surface.injected': { kind: 'single', scope: 'root', inject } }, + }, Comp as never) + expect(core.spec('surface.injected')?.inject).toBe(inject) + }) + it('entries/getVersion on an untouched key return the frozen empty array and 0', () => { const core = new SlotCore() expect(core.entries('surface.b')).toHaveLength(0) diff --git a/packages/client/ui-slots/tests/type-chain.spec.tsx b/packages/client/ui-slots/tests/type-chain.spec.tsx index b3a6aec873..64277edccc 100644 --- a/packages/client/ui-slots/tests/type-chain.spec.tsx +++ b/packages/client/ui-slots/tests/type-chain.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import type { - BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, + BoundActions, DefineStore, PropsRenderSlots, PropsRuntime, PropsStore, SlotComponent, SlotHookFactory, } from '@deepseek-ai/dsh-client-ui-slots' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' @@ -19,6 +19,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'chain.frame': { kind: 'single'; scope: 'root' } 'chain.side': { kind: 'single'; scope: 'root'; owner: { collapsed: boolean; width: number } } 'chain.conv': { kind: 'single'; scope: 'session' } + 'chain.context': { + kind: 'single' + scope: 'session' + hookContext: string + inject: ContextInjected + } 'chain.tools': { kind: 'keyed'; scope: 'session' } 'chain.takeover': { kind: 'chain'; scope: 'session'; owner: { items: readonly Item[] } } } @@ -62,6 +68,23 @@ type ConvProps = & PropsStore & { send: (t: string) => void } +interface TurnDataMap { tail: string; files: string } +type UseTurnData = (key: Key) => TurnDataMap[Key] | undefined +interface ContextInjected { + hooks: { + turnData: SlotHookFactory<'chain.context', UseTurnData> + } +} +type ContextProps = PropsRuntime<'chain.context'> +const CONTEXT_INJECT: ContextInjected = { + hooks: { + turnData: (_standard, hookContext) => { + const id: string = hookContext + return key => id === '' ? undefined : ({ tail: 'tail', files: 'files' })[key] + }, + }, +} + // Component fixtures (never rendered; the register call sites are the test). declare function Frame(props: FrameProps): ReactNode declare function Conv(props: ConvProps): ReactNode @@ -72,6 +95,8 @@ declare function NoDecl(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'c declare function Blind(props: PropsRuntime<'chain.frame'>): ReactNode declare function WrongStore(props: PropsRuntime<'chain.conv'> & PropsStore>): ReactNode declare function Needs(props: PropsRuntime<'chain.conv'> & { send: (t: string) => void }): ReactNode +declare function ContextOwner(props: PropsRuntime<'chain.frame'> & PropsRenderSlots<'chain.context'>): ReactNode +declare function ContextReader(props: ContextProps): ReactNode declare function Takeover(props: PropsRuntime<'chain.takeover'> & { matched: Item }): ReactNode declare function WideTakeover(props: PropsRuntime<'chain.takeover'> & { matched: Item | string }): ReactNode declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matched: { kind: 'q'; id: string; extra: number } }): ReactNode @@ -144,6 +169,24 @@ describe('terminal-design type chain', () => { chainSlots.renderSlotChain('chain.takeover', { items: [] }, { fallback: null }) chainSlots.renderSlot('chain.conv', {}) + // A parent registration declares the Slot inject once; every child + // entry receives the same custom Hook, bound to official standard props + // and each render occurrence's opaque context. + core.register({ + name: 'chain.frame', + children: { + 'chain.context': { kind: 'single', scope: 'session', inject: CONTEXT_INJECT }, + }, + }, ContextOwner) + core.register({ name: 'chain.context' }, ContextReader) + const contextProps: ContextProps = null as never + const tail: string | undefined = contextProps.useTurnData('tail') + const contextSlots: PropsRenderSlots<'chain.context'> = null as never + contextSlots.renderSlot('chain.context', {}, { + hookContext: 'turn:1', + }) + void tail + // ── negatives ────────────────────────────────────────────────── // children spec must match the SlotMap entry. core.register({ @@ -151,6 +194,11 @@ describe('terminal-design type chain', () => { // @ts-expect-error chain.conv is session-scoped in SlotMap children: { 'chain.conv': { kind: 'single', scope: 'root' } }, }, (() => null) as SlotComponent) + core.register({ + name: 'chain.frame', + // @ts-expect-error chain.context requires its Slot-level inject declaration + children: { 'chain.context': { kind: 'single', scope: 'session' } }, + }, ContextOwner) // renderSlot key set ⊄ children declaration. // @ts-expect-error component renderSlot keys exceed the declaration @@ -219,6 +267,16 @@ describe('terminal-design type chain', () => { // @ts-expect-error key not in this render share fp.renderSlot('chain.tools', {}) + // Contextual hooks preserve both the business key and value type. + // @ts-expect-error unknown Turn-data key + contextProps.useTurnData('other') + // @ts-expect-error a contextual slot requires its occurrence context + contextSlots.renderSlot('chain.context', {}) + // @ts-expect-error hookContext is the slot-declared string + const _wrongContextFactory: SlotHookFactory<'chain.context', UseTurnData> = + (_standard, _hookContext: number) => () => undefined + void _wrongContextFactory + // baked actions strip the draft parameter. acts.setDraft('x') // @ts-expect-error wrong payload type diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index 9cbdfe3f1d..8f4fb384b0 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -8,6 +8,7 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -74,7 +75,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S', cwd: '/proj' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 6c0bb956bd..3b70851a02 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -11,7 +11,9 @@ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, WorkspaceListState, @@ -21,6 +23,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -75,7 +78,8 @@ function snapshotWith( const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, + sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), + nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, @@ -97,6 +101,8 @@ async function bench(snapshot: ConversationSnapshot) { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) await slotsFiber.await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() const slots = ctx.get('slots') as SlotsService const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 7713bedaec..0cea47b572 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) @@ -342,8 +342,11 @@ describe('DetailsPanel diff Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 739a787a03..f74bbae6be 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -24,7 +24,7 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/t import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' afterEach(cleanup) @@ -288,8 +288,11 @@ describe('DetailsPanel Output section (read)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 51cab2d440..0016318b90 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ type SearchRowProps = Parameters[0] @@ -404,8 +404,11 @@ describe('DetailsPanel Output section (search)', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c9e2875a34..0dcb59d818 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -20,7 +20,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' type BashRowProps = Parameters[0] @@ -479,8 +479,11 @@ describe('DetailsPanel Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx index 393b9f8f46..44f4231802 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.spec.tsx @@ -29,12 +29,21 @@ function props( return { useSession, renderSlot, - callId: block.callId, - toolName: block.call?.name ?? '', - block, + node: { + key: `tool:${block.callId}`, + kind: 'tool-call', + id: block.callId, + target: 'chat', + anchorSeq: block.seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { root: block }, + }, selectedCallId, openFile: vi.fn(), inspectCall: vi.fn(), + forkAt: vi.fn(), + fileMentions: vi.fn(), t, } as unknown as ToolTreeProps } diff --git a/packages/client/ui-tool/tests/tool-details-render.tsx b/packages/client/ui-tool/tests/tool-details-render.tsx index 0aeb3c5321..b2b2cd9ac7 100644 --- a/packages/client/ui-tool/tests/tool-details-render.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.tsx @@ -1,5 +1,7 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, +} from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' @@ -7,6 +9,45 @@ import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId) +/** Build the canonical Chat slice consumed by Tool rows and details tests. */ +export function toolChatSnapshot( + settled: readonly ConversationNode[] = [], + running: readonly RunningToolCall[] = [], +): ChatSnapshot { + const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running] + const nodes: ChatConversationViewNode[] = roots.map(root => ({ + key: `tool:${root.callId}`, + kind: 'tool-call', + id: root.callId, + target: 'chat', + anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER, + location: { kind: 'session' }, + visibility: 'visible', + data: { root }, + })) + const byKey = new Map(nodes.map(node => [node.key, node])) + const empty: readonly string[] = [] + return { + order: nodes.map(node => node.key), + nodes: { + get: key => byKey.get(key), + values: () => nodes, + }, + locations: { + getTurn: () => empty, + getStep: () => empty, + }, + timeline: { turnOrder: [], turns: new Map() }, + legacy: { + nodes: settled, + runningCalls: running, + partial: null, + turnTimings: new Map(), + turnEnds: new Map(), + }, + } +} + /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. @@ -16,7 +57,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. - const details = owner as DetailsToolOwnerProps + const details = owner as unknown as DetailsToolOwnerProps return } } diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx index c927cc30ea..f1a9df3392 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx @@ -18,6 +18,7 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { toolChatSnapshot } from './tool-details-render.tsx' const SID = 's1' as SessionId @@ -71,7 +72,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.sessions.add({ id: SID, summary: { title: 'S', displayTitle: 'S' }, - snapshot: { nodes }, + snapshot: { nodes, chat: toolChatSnapshot(nodes) }, session: { loadOlder: vi.fn(), prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })), diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 7856ae9d6f..44f9e05090 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -23,7 +23,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' -import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' +import { renderToolDetails, SessionProviderStub, toolChatSnapshot } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' @@ -234,8 +234,11 @@ describe('DetailsPanel web Output section', () => { } function snapshot(over: Partial = {}): ConversationSnapshot { + const nodes = over.nodes ?? [] + const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 16334ff7c7..0688d409b9 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -90,6 +90,7 @@ function makeHost() { const provide = observable(absentInfo) let currentId: string | undefined const infos = new Map() + const sessionSources = new Map>>() const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) @@ -160,17 +161,24 @@ function makeHost() { bump(key) } }, - addSession: (id: string): SessionProvideInfo => { + addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. + const session = observable(initial) const info: SessionProvideInfo = { sessionId: id, - hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } }, + hooks: { session }, props: {}, } + sessionSources.set(id, session) infos.set(id, info) if (currentId === id) provide.set(info) return info }, + setSession: (id: string, snapshot: unknown) => { + const source = sessionSources.get(id) + if (source === undefined) throw new Error(`unknown test session: ${id}`) + source.set(snapshot) + }, } } @@ -635,6 +643,71 @@ describe('standard-kit synthesis', () => { expect(props['sessionId']).toBe('s1') }) + it('binds only function-valued inject hooks to the standard kit and render occurrence context', () => { + const h = makeHost() + const turnDataFactory = vi.fn((standard: AnyProps, turn: unknown) => (key: string) => { + const useSession = standard['useSession'] as (selector: (snapshot: unknown) => unknown) => unknown + return useSession((snapshot) => { + const value = snapshot as { turns: Record> } + return value.turns[turn as number]?.[key] + }) + }) + const sessionSpec: DeclaredSpec = { + kind: 'single', scope: 'session', inject: { hooks: { turnData: turnDataFactory } }, + } + h.declare('k.session', sessionSpec) + h.addSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 0, + }) + const hooks = new Map unknown>>() + let renders = 0 + h.add('k.session', { + component: ({ label, useTurnData }: { label: string; useTurnData: (key: string) => unknown }) => { + renders += 1 + const seen = hooks.get(label) ?? [] + seen.push(useTurnData) + hooks.set(label, seen) + return {String(useTurnData('tail'))} + }, + }) + const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => ( + {() => <> + {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })} + {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })} + } + + )) + act(() => { h.current.set('s1') }) + expect(view.container.textContent).toBe('onetwo') + expect(renders).toBe(2) + + // A session publication whose selected contextual value is identical is + // filtered by the framework-bound selector. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'one' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(renders).toBe(2) + + // Updating the selected contextual value re-renders through the returned + // custom Hook; the factory itself and its Hook identity stay stable. + act(() => { + h.setSession('s1', { + turns: { 1: { tail: 'updated' }, 2: { tail: 'two' } }, + unrelated: 1, + }) + }) + expect(view.container.textContent).toBe('updatedtwo') + expect(renders).toBe(3) + expect(hooks.get('one')![1]).toBe(hooks.get('one')![0]) + expect(hooks.get('two')).toHaveLength(1) + expect(hooks.get('one')![0]).not.toBe(hooks.get('two')![0]) + expect(turnDataFactory).toHaveBeenCalledTimes(2) + }) + it('hands the SessionProvider seat to entries declaring a session-scope child', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 6922778a26..0c419d6799 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -4,6 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, ManualCompactionError, type CompactAgentContext, @@ -14,7 +15,10 @@ import { import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandCompact from '@deepseek-ai/dsh-command-compact' +const COMPACTION_ID = CompactionId('command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -45,18 +49,28 @@ class StubCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { this.calls.push({ agent, signal }) if (this.operation !== undefined) return this.operation() return this.failure === undefined - ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result)) + ? Promise.resolve(this.result === null ? null : this.appendResult(agent, this.result, sourceCommandId)) // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise arbitrary backend rejection values. : Promise.reject(this.failure) } - private appendResult(agent: ManualCompactAgentContext, result: CompactionResult): CompactionResult { - agent.session.append('compact/start', { turn: null }) + private appendResult( + agent: ManualCompactAgentContext, + result: CompactionResult, + sourceCommandId: Parameters[2], + ): CompactionResult { + const provenance = { + compactionId: result.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: result.summary, shadowedRange: result.shadowedRange, shadowedSeqs: result.shadowedSeqs, @@ -64,8 +78,8 @@ class StubCompactService extends CompactService { provider: 'command-test', model: 'command-test', }) - agent.session.append('compact/end', { turn: null }) - return result + agent.session.append('compact/end', { ...provenance, turn: null }) + return { ...result, ...provenance } } } diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 5a5d37d8b1..5f7fd0b346 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import { + CompactionId, CompactService, type CompactAgentContext, type CompactionResult, @@ -18,7 +19,10 @@ import { import * as commandCompact from '@deepseek-ai/dsh-command-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' +const COMPACTION_ID = CompactionId('loader-command-compact-test') + const RESULT: CompactionResult = { + compactionId: COMPACTION_ID, startSeq: 1, summarySeq: 2, endSeq: 3, @@ -44,9 +48,15 @@ class LoaderCompactService extends CompactService { override compactNow( agent: ManualCompactAgentContext, _signal: AbortSignal, + sourceCommandId?: Parameters[2], ): Promise { - agent.session.append('compact/start', { turn: null }) + const provenance = { + compactionId: RESULT.compactionId, + ...sourceCommandId === undefined ? {} : { sourceCommandId }, + } + agent.session.append('compact/start', { ...provenance, turn: null }) agent.session.append('compact/summary', { + ...provenance, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -54,8 +64,8 @@ class LoaderCompactService extends CompactService { provider: 'loader-test', model: 'loader-test', }) - agent.session.append('compact/end', { turn: null }) - return Promise.resolve(RESULT) + agent.session.append('compact/end', { ...provenance, turn: null }) + return Promise.resolve({ ...RESULT, ...provenance }) } } @@ -132,11 +142,17 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/start', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'compact/summary', data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, summary: RESULT.summary, shadowedRange: RESULT.shadowedRange, shadowedSeqs: RESULT.shadowedSeqs, @@ -147,7 +163,11 @@ describe('command-compact real Loader composition', () => { }, { type: 'compact/end', - data: { turn: null }, + data: { + compactionId: COMPACTION_ID, + sourceCommandId: execution.commandId, + turn: null, + }, }, { type: 'command/done', diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 996d5d61e1..f97aaf52b9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,7 +4,7 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' -import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { CompactionId, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveCompactSpec, resolveConfig, @@ -945,7 +945,10 @@ describe('compaction region transaction', () => { )).rejects.toThrow(/no open turn/) const locked = conversation(1) - locked.append('compact/start', { turn: 2 }) + locked.append('compact/start', { + compactionId: CompactionId('locked-compaction'), + turn: 2, + }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( lockedNodes[0]!, @@ -1623,6 +1626,7 @@ describe('automatic listener and loader composition', () => { const compact = new TestCompactService(ctx) const session = conversation(2) const fakeResult: CompactionResult = { + compactionId: CompactionId('fake-compaction'), startSeq: 1, summarySeq: 2, endSeq: 3, diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 7544c3223b..0599d3f364 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -3,13 +3,14 @@ import { Context } from 'cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' import * as CompactBasicInvariant from '@deepseek-ai/dsh-compact-basic/invariant' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' -import { isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' +import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import { createAssistantMessage, @@ -23,7 +24,7 @@ import type { StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import LlmService from '@deepseek-ai/dsh-llm' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -394,22 +395,35 @@ describe('compactNow transaction and failure classification', () => { const { compact, flushes } = detachedService() const session = closedConversation(2, 7) const agent = fakeAgent(session, () => () => undefined) + const commandId = CommandId('manual-compact-command') - const result = await compact.compactNow(agent, SIGNAL) + const result = await compact.compactNow(agent, SIGNAL, commandId) expect(result).not.toBeNull() + expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - expect(session.events.findLast(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const summaryEvent = session.events.findLast(event => event.type === 'compact/summary') + const checkpoint = session.events.findLast( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && isCompactCheckpointSource(event.data.source), + ) + const end = session.events.findLast(event => event.type === 'compact/end') + const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } + expect(start?.data).toEqual({ ...correlated, turn: null }) + expect(summaryEvent?.data.sourceCommandId).toBe(commandId) + expect(checkpoint?.data.source).toMatchObject(correlated) + expect(end?.data).toEqual({ ...correlated, turn: null }) }) it('reports a live unmatched bracket as busy without summarizing', async () => { const { compact } = detachedService() const session = closedConversation(2) - session.append('compact/start', { turn: null }) + session.append('compact/start', { + compactionId: CompactionId('live-manual-compaction'), + turn: null, + }) const agent = fakeAgent(session, () => () => undefined) const error = await rejection(() => compact.compactNow(agent, SIGNAL)) @@ -421,7 +435,10 @@ describe('compactNow transaction and failure classification', () => { it('ignores an unmatched bracket inherited before a later end-seed marker', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('stale-manual-compaction'), + turn: null, + }) const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') const orphan = reloaded.events.find(event => event.type === 'compact/start') @@ -435,7 +452,10 @@ describe('compactNow transaction and failure classification', () => { it('scans a stale orphan independently of later repaired turn state', async () => { const { compact } = detachedService() const original = closedConversation(2) - original.append('compact/start', { turn: null }) + original.append('compact/start', { + compactionId: CompactionId('reloaded-manual-compaction'), + turn: null, + }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) @@ -664,7 +684,7 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(session.events.some(event => event.type === 'turn/start')).toBe(false) expect(session.events.find(event => event.type === 'compact/start')?.data) - .toEqual({ turn: null }) + .toEqual({ compactionId: result?.compactionId, turn: null }) }) it('classifies a durability failure after the standalone bracket committed', async () => { @@ -676,8 +696,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) - expect(session.events.findLast(event => event.type === 'compact/end')?.data) - .toEqual({ turn: null }) + const start = session.events.findLast(event => event.type === 'compact/start') + const end = session.events.findLast(event => event.type === 'compact/end') + expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => { diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 7e6915354f..74e2bd0270 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -2,8 +2,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { - COMPACT_CHECKPOINT_SOURCE, + CompactionId, CompactService, + compactCheckpointSource, isCompactCheckpointSource, } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' @@ -52,9 +53,11 @@ class StubCompactService extends CompactService { const endIndex = surface.indexOf(end) if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid') const shadowedSeqs = surface.slice(startIndex, endIndex + 1) + const compactionId = CompactionId('stub-compaction') // Minimal stub honoring the lock + log-only event contract. - const startEvent = session.append('compact/start', { turn: 0 }) + const startEvent = session.append('compact/start', { compactionId, turn: 0 }) const summaryEvent = session.append('compact/summary', { + compactionId, summary, shadowedRange: { start, end }, shadowedSeqs, @@ -64,13 +67,14 @@ class StubCompactService extends CompactService { }) session.append('user/message', createUserMessage({ content: summary, - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(compactionId), }), { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], }) - const endEvent = session.append('compact/end', { turn: 0 }) + const endEvent = session.append('compact/end', { compactionId, turn: 0 }) return { + compactionId, startSeq: startEvent.seq, summarySeq: summaryEvent.seq, endSeq: endEvent.seq, @@ -139,7 +143,8 @@ describe('CompactService seam', () => { expect(result.shadowedSeqs).toEqual([original.seq]) const checkpoint = session.events.find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) - expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE) + expect(checkpoint?.type === 'user/message' && checkpoint.data.source) + .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index c5a68c9de6..1637c16179 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import * as CompactInvariant from '@deepseek-ai/dsh-compact/invariant' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import InvariantService from '@deepseek-ai/dsh-invariants' async function setup(): Promise { @@ -12,7 +15,13 @@ async function setup(): Promise { return ctx } +const TEST_COMPACTION_ID = CompactionId('test-compaction') +const NEXT_COMPACTION_ID = CompactionId('next-test-compaction') +const TEST_COMMAND_ID = CommandId('test-command') +const NEXT_COMMAND_ID = CommandId('next-test-command') + const summary = (overrides: Record = {}) => ({ + compactionId: TEST_COMPACTION_ID, summary: [{ type: 'text' as const, text: 'short' }], shadowedRange: { start: 2, end: 4 }, shadowedSeqs: [2, 3, 4], @@ -31,33 +40,33 @@ describe('compaction invariants', () => { const ctx = await setup() const success = ctx.sessions.create() startTurn(success) - success.append('compact/start', { turn: 1 }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: 1 }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const failed = ctx.sessions.create() startTurn(failed, 2) - failed.append('compact/start', { turn: 2 }) - failed.append('compact/end', { turn: 2, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'provider failed' }) }) it('accepts standalone successful and failed compaction lifecycles between turns', async () => { const ctx = await setup() const success = ctx.sessions.create() - success.append('compact/start', { turn: null }) + success.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) success.append('compact/summary', summary()) - success.append('compact/end', { turn: null }) + success.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null }) const failed = ctx.sessions.create() - failed.append('compact/start', { turn: null }) - failed.append('compact/end', { turn: null, error: 'provider failed' }) + failed.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + failed.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'provider failed' }) }) it('clears an inherited open compaction trace at end-seed during replay', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { seed: source.events, }) @@ -68,8 +77,8 @@ describe('compaction invariants', () => { await ctx.plugin(CompactInvariant) expect(() => { - replayed.append('compact/start', { turn: null }) - replayed.append('compact/end', { turn: null, error: 'new attempt failed' }) + replayed.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null }) + replayed.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: null, error: 'new attempt failed' }) }).not.toThrow() }) @@ -78,7 +87,7 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-numbered-compaction-source')) startTurn(source) - source.append('compact/start', { turn: 1 }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { seed: source.events, }) @@ -98,7 +107,7 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('stale-repaired-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { @@ -124,10 +133,10 @@ describe('compaction invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) const source = Session.create(SessionId('closed-nested-compaction-source')) - source.append('compact/start', { turn: null }) + source.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) - source.append('compact/end', { turn: null, error: 'failed after crossing turn' }) + source.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { seed: source.events, }) @@ -143,10 +152,14 @@ describe('compaction invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await ctx.plugin(CompactInvariant) - expect(() => session.append('compact/end', { turn: 1, error: 'resume failed' })).not.toThrow() + expect(() => session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + turn: 1, + error: 'resume failed', + })).not.toThrow() session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) @@ -162,7 +175,8 @@ describe('compaction invariants', () => { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 }, }) ctx.emit('session/event', session, { - type: 'compact/start', seq: 2, time: 2, data: { turn: 1 }, + type: 'compact/start', seq: 2, time: 2, + data: { compactionId: TEST_COMPACTION_ID, turn: 1 }, }) }).not.toThrow() }) @@ -170,28 +184,30 @@ describe('compaction invariants', () => { it('rejects compaction outside or for a different open turn', async () => { const ctx = await setup() const session = ctx.sessions.create() - expect(() => session.append('compact/start', { turn: 1 })).toThrow(/outside any open turn/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) + .toThrow(/outside any open turn/) startTurn(session) - expect(() => session.append('compact/start', { turn: 2 })).toThrow(/but open turn is 1/) + expect(() => session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 2 })) + .toThrow(/but open turn is 1/) }) it('rejects a standalone bracket while a turn is open and a numbered bracket between turns', async () => { const ctx = await setup() const open = ctx.sessions.create() startTurn(open) - expect(() => open.append('compact/start', { turn: null })) + expect(() => open.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null })) .toThrow(/standalone but turn 1 is open/) const idle = ctx.sessions.create() - expect(() => idle.append('compact/start', { turn: 1 })) + expect(() => idle.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 })) .toThrow(/outside any open turn/) }) it('attributes a nested standalone start to the standalone owner', async () => { const ctx = await setup() const session = ctx.sessions.create() - session.append('compact/start', { turn: null }) - expect(() => session.append('compact/start', { turn: null })) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) + expect(() => session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: null })) .toThrow(/standalone compaction is still compacting/) }) @@ -201,7 +217,7 @@ describe('compaction invariants', () => { const session = ctx.sessions.create() startTurn(session) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) await ctx.plugin(InvariantService) await expect(ctx.plugin(CompactInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/) }) @@ -209,10 +225,10 @@ describe('compaction invariants', () => { it('rejects turn boundaries that cross live standalone or numbered compaction brackets', async () => { const ctx = await setup() const standalone = ctx.sessions.create() - standalone.append('compact/start', { turn: null }) + standalone.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: null }) expect(() => { startTurn(standalone) }) .toThrow(/turn\/start cannot cross an open standalone compaction/) - standalone.append('compact/end', { turn: null, error: 'cancelled' }) + standalone.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'cancelled' }) expect(() => { startTurn(standalone) standalone.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -220,53 +236,151 @@ describe('compaction invariants', () => { const numbered = ctx.sessions.create() startTurn(numbered) - numbered.append('compact/start', { turn: 1 }) + numbered.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).toThrow(/turn\/end cannot cross an open compaction for turn 1/) - numbered.append('compact/end', { turn: 1, error: 'cancelled' }) + numbered.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'cancelled' }) expect(() => numbered.append( 'turn/end', { turn: 1, reason: { kind: 'completed' } }, )).not.toThrow() }) + it('rejects a replacement checkpoint for another compaction transaction', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + const original = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(session) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary()) + + expect(() => session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(NEXT_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/compaction checkpoint id .* does not match compact\/start id/) + }) + + it('requires checkpoint provenance to name an open transaction', async () => { + const ctx = await setup() + const withoutStart = ctx.sessions.create() + const original = withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(() => withoutStart.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID), + }), { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/no matching compact\/start/) + + const emptyCommand = ctx.sessions.create() + const replaced = emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + startTurn(emptyCommand) + emptyCommand.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + expect(() => emptyCommand.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'checkpoint' }], + source: compactCheckpointSource(TEST_COMPACTION_ID, CommandId('')), + }), { + surfaceOp: { op: 'replace', start: replaced.seq, end: replaced.seq }, + sourceEventSeqs: [replaced.seq], + })).toThrow(/checkpoint sourceCommandId must be a non-empty string/) + }) + it.each([ + ['empty start id', (session: ReturnType) => { + session.append('compact/start', { compactionId: CompactionId(''), turn: 1 }) + }, /compact\/start compactionId must be a non-empty string/], + ['empty start source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + }) + }, /compact\/start sourceCommandId must be a non-empty string/], ['summary without start', (session: ReturnType) => { session.append('compact/summary', summary()) }, /no matching compact\/start/], ['nested start', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/start', { turn: 2 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/start', { compactionId: NEXT_COMPACTION_ID, turn: 2 }) }, /still compacting/], ['repeated summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary()) session.append('compact/summary', summary()) }, /repeated within one compaction/], + ['summary for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/summary', summary({ compactionId: NEXT_COMPACTION_ID })) + }, /compact\/summary id .* does not match compact\/start id/], + ['summary for another source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/summary', summary({ sourceCommandId: NEXT_COMMAND_ID })) + }, /compact\/summary sourceCommandId .* does not match compact\/start sourceCommandId/], ['empty shadow set', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedSeqs: [] })) }, /shadowedSeqs must be non-empty/], ['wrong endpoints', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedRange: { start: 1, end: 4 } })) }, /shadowedRange must match/], ['invalid token count', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) session.append('compact/summary', summary({ shadowedTokenCount: -1 })) }, /non-negative safe integer/], ['end without start', (session: ReturnType) => { - session.append('compact/end', { turn: 1, error: 'failed' }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) }, /no matching compact\/start/], ['wrong end turn', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 2, error: 'failed' }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 2, error: 'failed' }) }, /does not match/], + ['end for another compaction', (session: ReturnType) => { + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: NEXT_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end id .* does not match compact\/start id/], + ['end missing the source command', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1, error: 'failed' }) + }, /compact\/end sourceCommandId .* does not match compact\/start sourceCommandId/], + ['empty end source command id', (session: ReturnType) => { + session.append('compact/start', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: TEST_COMMAND_ID, + turn: 1, + }) + session.append('compact/end', { + compactionId: TEST_COMPACTION_ID, + sourceCommandId: CommandId(''), + turn: 1, + error: 'failed', + }) + }, /compact\/end sourceCommandId must be a non-empty string/], ['success without summary', (session: ReturnType) => { - session.append('compact/start', { turn: 1 }) - session.append('compact/end', { turn: 1 }) + session.append('compact/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) + session.append('compact/end', { compactionId: TEST_COMPACTION_ID, turn: 1 }) }, /requires one compact\/summary/], ])('rejects %s', async (_name, action, message) => { const ctx = await setup() diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index fcdcd7c422..585397d861 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -196,11 +196,15 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { } function stubToolExecution( - input: Omit & { token?: ToolExecutionToken }, + input: Omit & { + token?: ToolExecutionToken + rootCallId?: ToolExecution['rootCallId'] + }, ): ToolExecution { return { token: input.token ?? Symbol('workspace-context-test-execution') as ToolExecutionToken, ...input, + rootCallId: input.rootCallId ?? input.callId, } } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 06993467e8..2f3454b03c 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -785,11 +785,11 @@ describe('the run_code dispatch bridge', () => { const dispatches = events.filter(event => event.type === 'tool/code-dispatch') expect(dispatches.map(event => event.data)).toEqual([ { - parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, content: [{ type: 'text', text: 'echo:one' }], }, { - parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', + rootCallId: 'call-1', parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, content: [{ type: 'text', text: 'echo:two' }], }, ]) @@ -1526,6 +1526,7 @@ describe('the run_code dispatch bridge', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('tool/code-dispatch', { + rootCallId: CallId('p1'), parentCallId: CallId('p1'), subCallId: CallId('p1:code:1'), name: 'echo', diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 80ae299da7..8a60c41331 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -24,6 +24,7 @@ const execution = (overrides: Partial = {}): ToolExecution => ({ arguments: Object.freeze({ text: 'hi' }), ...overrides, signal: overrides.signal ?? testToolSignal, + rootCallId: overrides.rootCallId ?? overrides.callId ?? CallId('call-1'), }) const outcome = (): ToolExecutionResult => Object.freeze({ @@ -92,6 +93,7 @@ describe('tool-pipeline invariants', () => { const ctx = await setup() const session = ctx.sessions.create() const data = { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -103,12 +105,112 @@ describe('tool-pipeline invariants', () => { session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) }) + it('does not commit a rejected dispatch edge into the root index', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('rejected-root'), + parentCallId: CallId('rejected-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).toThrow(/outside any open turn/) + + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('accepted-root'), + parentCallId: CallId('accepted-root'), + subCallId: CallId('reused-child'), + name: 'echo', + arguments: {}, + })).not.toThrow() + }) + + it('rejects a nested code dispatch that changes its parent chain root before append', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'run_code', + arguments: {}, + }) + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('child'), + subCallId: CallId('grandchild'), + name: 'echo', + arguments: {}, + }) + + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('another-root'), + parentCallId: CallId('child'), + subCallId: CallId('invalid-grandchild'), + name: 'echo', + arguments: {}, + })).toThrow(/parentCallId child does not belong to rootCallId another-root/) + expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) + }) + + it('requires non-empty dispatch identities and keeps one subcall on one root', async () => { + const ctx = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1 }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId(''), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/must carry non-empty rootCallId/) + + session.append('tool/code-dispatch-start', { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }) + expect(() => session.append('tool/code-dispatch-start', { + rootCallId: CallId('other-root'), + parentCallId: CallId('other-root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + })).toThrow(/changed rootCallId for subCallId child/) + }) + + it('indexes dispatch records emitted for a bare session', async () => { + const ctx = await setup() + const session = Session.create(SessionId('bare-dispatch-session')) + session.append('turn/start', { turn: 1 }) + expect(() => { + ctx.emit('session/event', session as never, { + type: 'tool/code-dispatch-start', + seq: 1, + time: 1, + data: { + rootCallId: CallId('root'), + parentCallId: CallId('root'), + subCallId: CallId('child'), + name: 'echo', + arguments: {}, + }, + } as never) + }).not.toThrow() + }) + it('replays enclosed code-dispatch records on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('tool/code-dispatch', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', @@ -125,6 +227,7 @@ describe('tool-pipeline invariants', () => { const ctx = new Context() await ctx.plugin(SessionStore) ctx.sessions.create().append('tool/code-dispatch-start', { + rootCallId: CallId('parent'), parentCallId: CallId('parent'), subCallId: CallId('child'), name: 'echo', diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3224487b60..5757e5f245 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -574,6 +574,7 @@ describe('dsh-agent-spine-demo bundle', () => { signal: testToolSignal, token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], callId: CallId('agent-core-dsh-home'), + rootCallId: CallId('agent-core-dsh-home'), name: 'bash', arguments: { command: 'true' }, } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 1c478b226b..33ac3a19fa 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,6 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { @@ -38,6 +39,7 @@ function appendRetryTurn(session: Session, turn: number) { const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } const normal = { + retryId: RetryId('normal-retry-chain'), provider: 'mock', mode: 'normal' as const, policyKey: 'normal-policy', @@ -47,6 +49,7 @@ const normal = { failure, } const always = { + retryId: RetryId('always-retry-chain'), provider: 'mock', mode: 'always' as const, policyKey: 'always-policy', @@ -130,6 +133,7 @@ describe('llm-retry invariants', () => { }) it.each([ + ['empty-retry-id', { ...normal, retryId: RetryId('') }, /retryId must be a non-empty string/], ['retry-zero', { ...normal, retry: 0 }, /positive safe integer/], ['retry-fraction', { ...normal, retry: 1.5 }, /positive safe integer/], ['max-zero', { ...normal, maxRetries: 0 }, /positive safe maxRetries/], @@ -212,10 +216,65 @@ describe('llm-retry invariants', () => { reset.append('step/end', { turn: 1, step: 1 }) reset.append('step/start', { turn: 1, step: 2 }) expect(() => { - reset.append('llm/retry', { turn: 1, step: 2, ...normal }) + reset.append('llm/retry', { + turn: 1, + step: 2, + ...normal, + retryId: RetryId('reset-step-2-retry-chain'), + }) }).not.toThrow() }) + it('keeps one retry identity per provider-policy chain', async () => { + const ctx = await setup() + const changed = openStep(ctx, 'retry-invariant-changed-chain-id') + changed.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => changed.append('llm/retry', { + turn: 1, + step: 1, + ...normal, + retry: 2, + retryId: RetryId('changed-retry-chain'), + })).toThrow(/must preserve retryId/) + + const reused = openStep(ctx, 'retry-invariant-reused-chain-id') + reused.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => reused.append('llm/retry', { + turn: 1, + step: 1, + ...always, + retryId: normal.retryId, + })).toThrow(/already owned by another chain/) + }) + + it('validates retry-started correlation and uniqueness', async () => { + const ctx = await setup() + const empty = openStep(ctx, 'retry-started-empty-id') + expect(() => empty.append('llm/retry-started', { + retryId: RetryId(''), turn: 1, step: 1, retry: 1, + })).toThrow(/retryId must be a non-empty string/) + + const missing = openStep(ctx, 'retry-started-missing-schedule') + expect(() => missing.append('llm/retry-started', { + retryId: RetryId('missing-retry-chain'), turn: 1, step: 1, retry: 1, + })).toThrow(/pairs no prior scheduled attempt/) + + const mismatch = openStep(ctx, 'retry-started-location-mismatch') + mismatch.append('llm/retry', { turn: 1, step: 1, ...normal }) + expect(() => mismatch.append('llm/retry-started', { + retryId: normal.retryId, turn: 2, step: 1, retry: 1, + })).toThrow(/turn\/step must match/) + + const repeated = openStep(ctx, 'retry-started-repeated') + repeated.append('llm/retry', { turn: 1, step: 1, ...normal }) + repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + expect(() => repeated.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + })).toThrow(/repeats one scheduled attempt/) + }) + it('starts a fresh retry chain after incomplete predecessor boundaries', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -261,4 +320,16 @@ describe('llm-retry invariants', () => { await ctx.plugin(InvariantService) await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) }) + + it('accepts a scheduled and started attempt on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = openStep(ctx, 'retry-invariant-late-started') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + session.append('llm/retry-started', { + retryId: normal.retryId, turn: 1, step: 1, retry: 1, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).resolves.toBeDefined() + }) }) diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 36a1fb5dbe..3a605bd40e 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' import type {} from '../src/index.ts' const dirs: string[] = [] @@ -39,6 +40,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) reason: 'initial', }) const event = session.append('llm/retry', { + retryId: RetryId(`retry-${kind}-chain`), turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index ac0ed687fa..7268c19ec6 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -191,7 +191,9 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) const event = await scheduled + expect(event.data.retryId).toEqual(expect.any(String)) expect(event.data).toEqual({ + retryId: event.data.retryId, turn: 1, step: 1, provider: 'mock', diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 20cb2cc819..9251eef2c0 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,6 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, @@ -59,6 +60,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('context-breakdown-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 0307b96f46..65771fc877 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, @@ -81,6 +82,7 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end: const endIdx = nodes.findIndex(node => node.seq === end) const shadowed = nodes.slice(startIdx, endIdx + 1) session.append('compact/summary', { + compactionId: CompactionId('token-usage-summary'), summary: [{ type: 'text', text: 'summary' }], shadowedRange: { start, end }, shadowedSeqs: shadowed.map(node => node.seq), diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index ef97c945c9..b52000f554 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -277,8 +277,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */', }, { - signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, ): Promise', - jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', + signature: 'abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sourceCommandId?: CommandId, ): Promise', + jsDoc: '/**\n * Explicitly compact useful history even below automatic pressure thresholds.\n * Implementations synchronously start an idle task before any asynchronous\n * work, select a useful range without writing on a no-op, then\n * append a standalone `compact/start` before summarization. That durable\n * marker is the compaction lock until one `compact/end` attempt. Later waking\n * prompts remain accepted in FIFO order and start only after the optional\n * durability checkpoint and idle-task settlement. Context injected while the\n * summary runs may sit between the marker pair; only the selected span must\n * remain stable.\n *\n * @param agent - idle agent whose durable history should be compacted.\n * @param signal - cancellation scoped to this compaction request.\n * @param sourceCommandId - initiating command identity for a manual compaction.\n * @returns the compaction result, or `null` when no safe useful range exists.\n * @throws {@link ManualCompactionError} for expected busy, agent-cancellation,\n * changed-span, summarization/shrink, commit-stage, or persistence failures;\n * an aborted request preserves its exact abort reason. Failed attempts remain\n * visible in the log.\n */', }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', @@ -1841,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', @@ -1851,9 +1851,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactAgentContext', declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', }, + { + name: 'CompactionId', + declaration: 'export type CompactionId = Branded<\'CompactionId\'>;', + }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n compactionId: CompactionId;\n sourceCommandId?: CommandId;\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, { name: 'CompactionTrigger', @@ -3065,7 +3069,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecution', - declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n}', + declaration: 'export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n}', }, { name: 'ToolExecutionFailure', @@ -3073,7 +3077,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionInput', - declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', + declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', }, { name: 'ToolExecutionMode', diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index efdab32824..0276199e3e 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { CompactionId } from '@deepseek-ai/dsh-compact/brand' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, @@ -35,6 +36,8 @@ const TEXT_CHUNKS: StreamChunk[] = [ { type: 'finish', reason: { kind: 'stop' } }, ] +const COMPACTION_ID = CompactionId('replay-compaction') + /** Build a minimal session-JSONL string: a header line + the given events. */ function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string { const headerLine = JSON.stringify({ @@ -194,12 +197,18 @@ describe('deriveReplayScript', () => { let seq = 1 const events: SessionEvent[] = [ ...overflow.map(chunk => chunkEvent(seq++, 1, 2, chunk)), - { type: 'compact/start', seq: seq++, time: 0, data: { turn: 1 } }, + { + type: 'compact/start', + seq: seq++, + time: 0, + data: { compactionId: COMPACTION_ID, turn: 1 }, + }, { type: 'compact/summary', seq: seq++, time: 0, data: { + compactionId: COMPACTION_ID, summary: rawOutput, rawOutput, llmStreamCall: true, @@ -227,6 +236,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'template result' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], @@ -246,6 +256,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], shadowedRange: { start: 1, end: 1 }, @@ -267,6 +278,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'missing source events' }], llmStreamCall: true, shadowedRange: { start: 1, end: 1 }, @@ -289,6 +301,7 @@ describe('deriveReplayScript', () => { seq: 1, time: 0, data: { + compactionId: COMPACTION_ID, summary: [block], rawOutput: [block], llmStreamCall: true, @@ -343,6 +356,7 @@ describe('deriveReplayScript', () => { seq: 2, time: 0, data: { + compactionId: COMPACTION_ID, summary: [{ type: 'text', text: 'external checkpoint' }], shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da3703e5ff..f1ee272761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1504,9 +1504,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1680,6 +1677,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -2670,6 +2670,12 @@ importers: packages/compact/compact: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2704,6 +2710,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../compact @@ -4391,6 +4400,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fffde3ceb8..51fdc06f61 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -278,6 +278,7 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandId: 'commands.md', CommandResult: 'commands.md', CommandSurface: 'commands.md', LlmAdapter: 'llm-streaming.md', From 8723a0e15f791f4403327f809eb43bedc748f43d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:02:20 +0800 Subject: [PATCH 06/20] fix(client): address conversation assembly review findings --- .../client/sessions/conversation-assembler.ts | 12 ++-- .../runtime/src/client/sessions/session.ts | 1 + .../tests/conversation-assembler.spec.ts | 67 +++++++++++++++++++ .../client/conversation-nodes/turn-tail.ts | 19 +++++- .../ui-conversation/src/client/index.ts | 21 +++--- .../conversation-node-definitions.spec.ts | 26 ++++++- scripts/gen-cordis-catalog.ts | 2 + 7 files changed, 128 insertions(+), 20 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 3b6fbd6266..3a0c94ba3c 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -238,7 +238,7 @@ export class ConversationNodeAssembler { } this.applyPendingMatches(pending, affected) this.replayContexts(affected) - if ((fresh.length > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { + if ((this.revised.size > 0 || previousHasMore !== hasMore) && this.replayDependencies()) { publication = 'immediate' } if (changedLocations.size > 0) publication = 'immediate' @@ -563,18 +563,18 @@ export class ConversationNodeAssembler { private replayRevisedDependents(): boolean { const pending = [...this.revised] - const replayed = new Set() + const affected = new Set() for (let index = 0; index < pending.length; index++) { const dependency = pending[index] if (dependency === undefined) continue for (const dependent of this.dependents.get(dependency.key) ?? []) { - if (replayed.has(dependent)) continue - replayed.add(dependent) - this.replayContext(dependent) + if (affected.has(dependent)) continue + affected.add(dependent) pending.push(dependent) } } - return replayed.size > 0 + this.replayContexts(affected) + return affected.size > 0 } private readerFor( diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index a533f9dd6f..7c3d989248 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -344,6 +344,7 @@ export class Session implements SessionFace { // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) this.hasMore = false + this.conversation.prepend([], false) return } this.events = [...older.map(e => e.event), ...this.events] diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 6f4d8516dc..06dfd42567 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -458,6 +458,73 @@ describe('ConversationNodeAssembler', () => { expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2) }) + it('replays a transitive dependency closure in start order', () => { + const sourceA: ConversationNodeDefinition = { + kind: 'diamond-a', + match: (event) => { + if (event.type === 'user/message') return { id: 'one', role: 'start' } + if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' } + return null + }, + start: () => 1, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const sourceX: ConversationNodeDefinition = { + kind: 'diamond-x', + match: (event) => { + if (event.type === 'turn/start') return { id: 'one', role: 'start' } + if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' } + return null + }, + start: () => 10, + update: (_context, match) => (match.event.data as unknown as { value: number }).value, + buildViewNode: () => null, + } + const middle: ConversationNodeDefinition = { + kind: 'diamond-b', + match: event => event.type === 'assistant/message' + ? { id: 'one', role: 'start' } + : null, + start: (_context, _match, reader) => ( + (reader.previous('diamond-a')?.state ?? 0) + + (reader.previous('diamond-x')?.state ?? 0) + ), + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const consumer: ConversationNodeDefinition = { + kind: 'diamond-c', + match: event => event.type === 'tool/call' + ? { id: 'one', role: 'start' } + : null, + start: (_context, _match, reader) => ( + (reader.previous('diamond-a')?.state ?? 0) * 100 + + (reader.previous('diamond-b')?.state ?? 0) + ), + update: context => context.state, + buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([sourceA, sourceX, middle, consumer]), + new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([ + input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })), + input(at(2, 'turn/start', { turn: 1 })), + input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })), + input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })), + ], false) + + assembler.append(input(at(5, 'diamond/x', { value: 20 }))) + assembler.append(input(at(6, 'diamond/a', { value: 2 }))) + assembler.flush() + + const value = [...chatSnapshot(assembler)?.nodes.values() ?? []] + .find(candidate => candidate.kind === 'diamond-c') + expect(value?.data).toBe(222) + }) + it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => { const apply = vi.fn() const starts = vi.fn(( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 2a842a005d..346bd41bdf 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -122,14 +122,26 @@ function tailData(context: ConversationNodeContext): TurnTailChat .filter((candidate): candidate is Readonly => candidate.finalNode !== undefined) .sort((left, right) => left.finalNode.seq - right.finalNode.seq) const closing = finalized.findLast(hasText) ?? null - const latest = finalized.at(-1) + let latestTranscriptSeq = finalized.at(-1)?.finalNode.seq + for (const match of context.matches) { + const event = match.event + const candidate = event.type === 'tool/call' + || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) + || (event.type === 'turn/end' && event.data.reason.kind === 'error') + || (event.type as string) === 'llm/retry' + ? event.seq + : undefined + if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) { + latestTranscriptSeq = candidate + } + } const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn) return { turn: end.event.data.turn, seq: end.event.seq, time: end.event.time, closing, - branchUnavailable: closing === null || latest?.finalNode.seq !== closing.finalNode.seq, + branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq, ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs }, ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond }, } @@ -141,6 +153,9 @@ export const turnTailDefinition: ConversationNodeDefinition = { match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } + if (event.type === 'tool/call' || event.type === 'tool/result') { + return { id: String(event.data.turn), role: 'update' } + } const coordinates = turnCoordinates(event) if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' } return null diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index b4d7467170..e0471623f0 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -3,19 +3,18 @@ * between the independently implemented skeleton and chat domains; `apply.ts` * owns their slot assembly. */ +export type {} from './conversation-nodes/assistant.ts' +export type {} from './conversation-nodes/command.ts' +export type {} from './conversation-nodes/compaction.ts' +export type {} from './conversation-nodes/fallback.ts' +export type {} from './conversation-nodes/message.ts' +export type {} from './conversation-nodes/retry.ts' +export type {} from './conversation-nodes/tool.ts' +export type {} from './conversation-nodes/turn-error.ts' +export type {} from './conversation-nodes/turn-tail.ts' + export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' -export { registerAssistantConversationNode } from './conversation-nodes/assistant.ts' -export { registerChatConversationView } from './conversation-nodes/chat-snapshot-builder.ts' -export { registerCommandConversationNode } from './conversation-nodes/command.ts' -export { registerCompactionConversationNode } from './conversation-nodes/compaction.ts' -export { registerUnknownConversationFallback } from './conversation-nodes/fallback.ts' -export { registerInboxConversationNodes } from './conversation-nodes/inbox.ts' -export { registerMessageConversationNode } from './conversation-nodes/message.ts' -export { registerRetryConversationNode } from './conversation-nodes/retry.ts' -export { registerToolConversationNode } from './conversation-nodes/tool.ts' -export { registerTurnErrorConversationNode } from './conversation-nodes/turn-error.ts' -export { registerTurnTailConversationNode } from './conversation-nodes/turn-tail.ts' export type { IConversation } from './service.ts' export type { diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 3ae906087e..5346ad57f9 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -16,7 +16,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' import type { - AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, + AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, } from '../src/client/contract/chat-nodes.ts' const DEFINITIONS: readonly ConversationNodeDefinition[] = [ @@ -413,6 +413,30 @@ describe('built-in conversation node Definitions', () => { ]) }) + it('keeps branching unavailable when a tool result follows the closing Assistant', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-before-tool', 'running a tool'), + }, { surfaceOp: 'append' }), + at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }), + at(5, 'tool/result', { + turn: 1, + step: 1, + message: toolResult('late-tool', 'done'), + }, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData + expect(tail.closing?.finalNode.seq).toBe(3) + expect(tail.branchUnavailable).toBe(true) + }) + it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => { const value = assembler([ at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }), diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 51fdc06f61..662ad1df13 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -121,6 +121,8 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface', + conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', + conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface', models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface', From 990f700d3cd4c977a5cb1788ef7c9c5d138f918a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:28:42 +0800 Subject: [PATCH 07/20] fix(client): address conversation assembly review feedback --- ...lient-conversation-node-assembly.i18n.yaml | 4 ++-- ...08-09-client-conversation-node-assembly.md | 10 ++++---- ...09-client-conversation-node-assembly.zh.md | 10 ++++---- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/sessions/conversation-assembler.ts | 2 ++ .../src/client/sessions/conversation.ts | 24 ++++++++++++------- .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 4 ++-- packages/client/ui-conversation/README.zh.md | 4 ++-- .../src/client/contract/slots.ts | 9 ++++--- .../client/conversation-nodes/assistant.ts | 4 ++-- .../chat-snapshot-builder.ts | 5 +++- .../src/client/conversation-nodes/common.ts | 10 ++++++++ .../src/client/conversation-nodes/tool.ts | 4 ++-- .../client/conversation-nodes/turn-tail.ts | 8 ++++--- packages/client/ui-tool/README.i18n.yaml | 4 ++-- packages/client/ui-tool/README.md | 4 ++-- packages/client/ui-tool/README.zh.md | 4 ++-- 20 files changed, 73 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 953a049744..264b900b74 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -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: 0cee5449f651c568dbb87b9cb867eb77b6380184 -2026-08-09-client-conversation-node-assembly.zh.md: 146e8f68a9b1339934040cee62fd46e981145f2a +2026-08-09-client-conversation-node-assembly.md: f9768a652c7b0d2d29939fac83b201bc904e3210 +2026-08-09-client-conversation-node-assembly.zh.md: 408d61f73b3b317214cb10b26f522e7680de3f92 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 0cee5449f6..f9768a652c 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -148,7 +148,7 @@ The Assembler verifies `node.key === context.key` and `node.target === target`. `current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal. -A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory continues to consume the compatibility slice. +A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory remains on its independent `session-history` fold until it gains a registered target. #### No generic `end()` @@ -306,7 +306,7 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface 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`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice temporarily consumed by Trajectory. +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 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. @@ -328,7 +328,7 @@ When business logic deliberately changes a materialized Node to hidden, it leave 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 has no independent registered target yet. It continues to consume the legacy slice incrementally derived by the Chat Builder, while Session no longer runs a second transcript fold; a future migration does not change the Event Definition, Context, Reader, or Location contracts. +Trajectory has no registered target and does not consume the Chat Builder's legacy slice. Its activated `SessionHistoryInspection` keeps an independent history fold, while the ordinary Session snapshot no longer runs a second transcript fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; a future Trajectory migration does not change the Event Definition, Context, Reader, or Location contracts. ## Runtime and render path @@ -382,7 +382,7 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- **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. -**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory retains a compatibility slice until its actual migration. +**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory's independent history fold remains until it registers its own Builder. **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. @@ -404,4 +404,4 @@ Steps and Turns become stable homes for cross-business aggregates. Turn Tail and 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 until Trajectory migrates; built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. +`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, Trajectory still owns an independent history fold, and built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 146e8f68a9..408d61f73b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -148,7 +148,7 @@ Assembler 校验 Node `key === context.key` 且 Node `target === target`。业 `current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。 -Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder,Trajectory 仍通过兼容 slice 使用结果。 +Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder;在拥有注册 target 之前,Trajectory 继续使用独立的 `session-history` fold。 #### 不提供通用 `end()` @@ -306,7 +306,7 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 -[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline` 和 Trajectory 临时使用的 `legacy` slice。 +[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。 @@ -328,7 +328,7 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 -Trajectory 尚未注册独立 target。它继续消费 Chat Builder 增量派生的 legacy slice,Session 不再运行第二套 transcript fold;未来迁移不改变 Event Definition、Context、Reader 或 Location 契约。 +Trajectory 尚未注册 target,也不消费 Chat Builder 的 legacy slice。它已激活的 `SessionHistoryInspection` 继续维护独立 history fold,而普通 Session snapshot 不再运行第二套 transcript fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;未来迁移 Trajectory 不改变 Event Definition、Context、Reader 或 Location 契约。 ## Runtime and render path @@ -382,7 +382,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 -**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在真正迁移前保留兼容 slice。 +**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在注册自己的 Builder 之前继续使用独立 history fold。 **在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 @@ -404,4 +404,4 @@ Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverable 代价是 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 在 Trajectory 迁移前仍维护 legacy slice;内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 +`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 继续拥有独立 history fold,内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 4aed1d0f36..d47266cd8a 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: b2bb06e50ecd791d74cb609404dd219e5a21913e -README.zh.md: 72cd99ac765875c828cc9163d978e0b8fb7f44e4 +README.md: 46a383f22032c8da7e4bb9b5fb445980661c670f +README.zh.md: 73578a8f249472abfdd7c539e3eaee6de767ad60 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index b2bb06e50e..46a383f220 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -38,7 +38,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. `ConversationSnapshot.nodes`, `partial`, and `runningCalls` are compatibility slices derived from the same materialized Chat nodes for consumers that have not moved to `ConversationSnapshot.chat`; Session does not run a second business fold. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 72cd99ac76..73578a8f24 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -38,7 +38,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。`ConversationSnapshot.nodes`、`partial` 和 `runningCalls` 是从同一批已物化 Chat 节点派生的兼容切片,供尚未迁移到 `ConversationSnapshot.chat` 的消费者使用;Session 不再运行第二套业务 fold。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 3a0c94ba3c..811bd43ebf 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -743,6 +743,8 @@ export class ConversationNodeAssembler { context.locationData[scope] = data if (data !== null) entries.push({ owner: context.key, data }) } + // Turn publishers may read Step data from this same flush, so each phase + // installs the cumulative replacement before the next phase builds. this.locationIndex.replaceData(entries) } } diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 99daa7c8a5..628fe89aa8 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -1,7 +1,9 @@ // ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI. -// Immutability contract: every change swaps the top-level object; unchanged -// substructures keep their references (the React.memo premise). callId/approvalId stay plain -// string here (narrow to real brands when convenient). +// Publication contract: every change swaps the top-level object; unchanged +// substructures keep their references (the React.memo premise). Chat node and +// Location stores are stable live readers, so old snapshots are not time-point +// views. callId/approvalId stay plain string here (narrow to real brands when +// convenient). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' @@ -336,7 +338,10 @@ export interface PromptError { error: RpcError } -/** Stable per-key reader for final Chat view Nodes. */ +/** + * Stable live per-key reader. An old ChatSnapshot observes later flushes + * through this store. + */ export interface ChatNodeStore { /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ get(key: string): ChatConversationViewNode | undefined @@ -344,7 +349,10 @@ export interface ChatNodeStore { values(): readonly ChatConversationViewNode[] } -/** Stable per-Location membership index for turn-local and step-local consumers. */ +/** + * Stable live Location index. An old ChatSnapshot observes later membership + * changes through this index. + */ export interface ChatLocationNodeIndex { /** @param turn - owning turn. @returns ordered Chat Node keys in the turn. */ getTurn(turn: number): readonly string[] @@ -352,7 +360,7 @@ export interface ChatLocationNodeIndex { getStep(turn: number, step: number): readonly string[] } -/** Temporary projection consumed by Trajectory and unmigrated readers. */ +/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */ export interface LegacyConversationSlice { readonly nodes: readonly ConversationNode[] readonly turnTimings: ReadonlyMap @@ -361,7 +369,7 @@ export interface LegacyConversationSlice { readonly runningCalls: readonly RunningToolCall[] } -/** Incremental Chat target snapshot: stable keyed stores plus structural order. */ +/** Incremental Chat publication with immutable order and stable live keyed readers. */ export interface ChatSnapshot { readonly order: readonly string[] readonly nodes: ChatNodeStore @@ -399,7 +407,7 @@ export interface ConversationSnapshot { sessionId: SessionId /** Final Chat target assembled from independently registered business Definitions. */ chat: ChatSnapshot - /** Legacy Trajectory slice derived from the registered Chat Definitions. */ + /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ nodes: readonly ConversationNode[] /** Exact in-window `turn/start` time and optional matching `turn/end` time. */ turnTimings: ReadonlyMap diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2066b22ec5..bb15c65029 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 911bca28dcfb31b1d8ef9ea5458a0d2017d8b31d -README.zh.md: 25574421bbcc3992188e378a49a69acf25744af7 +README.md: 3570e814609af0bc4e7d048a55205af756fae843 +README.zh.md: e37f867c72d83b47924e87261e6ee5ff8b374a6f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 911bca28dc..3570e81460 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,7 +20,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. @@ -40,7 +40,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` contract — an absent service leaves the prose inert. +A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 25574421bb..e37f867c72 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留 Tool 的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染 Runtime 已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 @@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` 约定——service 缺席时正文保持死文本。 +完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 474e7f9db5..eec5450e2d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -56,11 +56,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * The chat view's turn-tail chain: rendered between a closing assistant - * message's body and its IconActions footer, once per turn (the render - * site elects the closing seq). Entries derive a match from the owner - * currency before mounting, so presentation components never mount only - * to return null; an all-declined chain renders nothing. + * The completed Turn Node's extension chain, rendered before that Node's + * IconActions. Entries derive a match from the engine-owned Turn and + * closing seq before mounting, so presentation components never mount + * only to return null; an all-declined chain renders nothing. */ 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } /** Selected Tool call output inside the details panel. */ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 2bdf960226..8a487c89ed 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -7,7 +7,7 @@ import { emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, } from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantChatData } from '../contract/chat-nodes.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -169,7 +169,7 @@ function finalNode( if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined return { kind: 'assistant', - seq: boundary.seq - 0.9, + seq: boundary.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant, time: boundary.time, turn: state.turn, step: state.step, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index f9491d2d16..f417c33b75 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -154,6 +154,9 @@ const EMPTY_CONTRIBUTION: LegacyContribution = { function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { const node = raw as ChatNode + // Content-free settled Assistants remain in the finalized compatibility + // stream so StatsLine preserves its pre-assembly step counts; hidden running + // attempts have no final Node to contribute. if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION switch (node.kind) { case 'user': @@ -221,7 +224,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon && sameReferences(left.nodes, right.nodes) } -/** Incremental compatibility projection retained solely for unmigrated Trajectory consumers. */ +/** Incremental compatibility projection for StatsLine and legacy top-level snapshot fields. */ class LegacySliceBuilder { private readonly contributions = new Map() private readonly finalizedContributions = new Map() diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts index 8e1d9d7bd4..b6bd01930b 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -5,6 +5,16 @@ import type { ChatNode, ChatNodeDataMap, ChatNodeKind, } from '../contract/chat-nodes.ts' +/** + * Relative positions in one durable event's seq neighborhood: interrupted + * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. + */ +export const CHAT_SYNTHETIC_SEQ_OFFSETS = { + interruptedAssistant: -0.9, + interruptedFollowup: -0.8, + finalizedFollowup: 0.1, +} as const + /** * Resolve one Context's best currently loaded event Location. * @param context - assembled business Context. diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index 04a5770f5a..23201fec3e 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -5,7 +5,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolChatData } from '../contract/chat-nodes.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -190,7 +190,7 @@ function projectBlock( ? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children } : { kind: 'tool-result', - seq: interruptedAt.seq - 0.8, + seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup, time: interruptedAt.time, callId: block.callId, call: { name: block.name, argsRaw: block.argsRaw }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 346bd41bdf..51cb348e6a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -7,7 +7,7 @@ import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' import { deriveTurnMetrics } from '../chat/turn-metrics.ts' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -85,7 +85,9 @@ function closingAnchor(context: ConversationNodeContext): number } if (event.type === 'assistant/message') { steps.set(coordinates.step, { streamedText: false, finalized: true }) - if (hasTextAssistant(event)) anchor = event.seq + 0.1 + if (hasTextAssistant(event)) { + anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup + } continue } if ((event.type as string) === 'llm/retry') { @@ -93,7 +95,7 @@ function closingAnchor(context: ConversationNodeContext): number continue } if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { - anchor = event.seq - 0.8 + anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup } } return anchor diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ca5f3cd25c..84c6fa42af 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: d6bc0f248cffaf4c65ecf6d97c7a4afb17fc8941 -README.zh.md: 6b6e2a153be6a3cad5b57379de6fdc0cd4a58ea4 +README.md: b33aef86a6ad161f70105968f308c5a587c25c24 +README.zh.md: 680a5149187bc43513c74134a9a58c4680af854c diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index d6bc0f248c..b33aef86a6 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. +Client Tool presentation plugin. `ui-conversation` dispatches each ordered `tool-call` Conversation Node through the matching key of `conversation.chat.node`; this package renders its root and Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card. Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement. @@ -10,7 +10,7 @@ Business UI packages register only their wire Tool names and atomic views. They `ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. -Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection. +Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6b6e2a153b..680a514918 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 +Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.node` 的同名 key 分发每个已排序的 `tool-call` Conversation Node;本包渲染其中的 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。 业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影;conversation view 继续负责 ChatFlow 位置。 @@ -10,7 +10,7 @@ Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交 `ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block,让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map。 -每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 约定,供分页和 selection 使用。 +每个 root 和 child wrapper 都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。 From 3d70889a8d7f19de7c4dc2ede606fe1c8bc618d9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:53:09 +0800 Subject: [PATCH 08/20] refactor(session): unify event map augmentation entrypoint --- packages/compact/compact/src/types.ts | 2 +- packages/core/agent/src/types.ts | 2 +- packages/core/session/tests/fork.spec.ts | 2 +- .../session/tests/gen-persistence-catalog.spec.ts | 2 +- packages/core/tools/src/code-mode.ts | 2 +- packages/feedback/command-feedback/src/index.ts | 2 +- packages/goal/goal/src/domain.ts | 2 +- packages/hooks/hook-protocol/src/types.ts | 2 +- packages/interaction/commands/src/index.ts | 2 +- packages/interaction/permission/src/index.ts | 2 +- packages/interaction/user-approval/src/index.ts | 2 +- packages/llm/llm-retry/src/index.ts | 13 ++----------- packages/llm/llm-retry/src/types.ts | 9 +++++++++ packages/plan/plan-mode/src/index.ts | 2 +- packages/sandbox/sandbox-policy/src/session-mode.ts | 2 +- .../session-projection-cache/tests/cache.spec.ts | 2 +- .../session-projection/tests/registry.spec.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 2 +- packages/session/session-title-llm/src/index.ts | 2 +- packages/session/session-title/src/index.ts | 2 +- packages/subagent/subagent/src/descriptor.ts | 2 +- packages/web/web-search-deepseek/src/provider.ts | 2 +- 22 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index c321be426e..df5e749cd8 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -11,7 +11,7 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionId } from './brand.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Marks the start of a compaction — log-only, holds the lock until diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d21f25cdc7..8decb9ba3f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -290,7 +290,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One normalized mutation of an agent's durable pending-message lists. diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 0e7a0629c3..58985cfb34 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -4,7 +4,7 @@ import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'test/log-only': { value: string } /** Stands in for a plugin's open/close bracket (`compact/start`). */ diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index 9a09cb3489..22bc09835f 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -35,7 +35,7 @@ const make = (files: Record): string => { /** A merge-form declaration file wrapping `members` in the session module. */ const merge = (members: string): string => - `declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n` + `declare module '@deepseek-ai/dsh-session/types' {\n interface SessionEventMap {\n${members}\n }\n}\n` afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 63c5b2cb9b..984fecab88 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -15,7 +15,7 @@ import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One sub-dispatch STARTING inside a `run_code` program: the parent diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index ae78b3cd4f..037a463104 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -15,7 +15,7 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback ' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One recorded human remark about this session. Log-only and independent diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 8c8c4de6b6..90f22a1602 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -58,7 +58,7 @@ declare module '@deepseek-ai/dsh-llm' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Complete post-mutation goal state or clear tombstone. diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 82a67f9b93..adaafd6b0f 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-hook-protocol/types */ -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * A hook command was invoked at a hook point — a log-only record (like diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 780569c159..ba7b449228 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -131,7 +131,7 @@ class CommandLayer implements ScopeLayer { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * A resolved slash command entered its handler. Log-only (never model diff --git a/packages/interaction/permission/src/index.ts b/packages/interaction/permission/src/index.ts index 44d62a9c33..35b762b2aa 100644 --- a/packages/interaction/permission/src/index.ts +++ b/packages/interaction/permission/src/index.ts @@ -39,7 +39,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Records the selected preset as durable, log-only user intent. The knob diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 3dfeabe570..70eacaa627 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -31,7 +31,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * An approval question was put to the answerer chain — log-only audit diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index dcba1e2443..2ebeb79260 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -12,16 +12,7 @@ import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { RetryId } from './brand.ts' -import type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' - -declare module '@deepseek-ai/dsh-session' { - interface SessionEventMap { - /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ - 'llm/retry': LlmRetryEventData - /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ - 'llm/retry-started': LlmRetryStartedEventData - } -} +import type { LlmRetryEventData } from './types.ts' export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' @@ -132,7 +123,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna ): Promise { const fusedSignal = AbortSignal.any([signal, lifetime.signal]) if (fusedSignal.aborted) return - const eventData = policy.mode === 'normal' + const eventData: LlmRetryEventData = policy.mode === 'normal' ? { retryId, turn, diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index 12820889dd..c698b68446 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,6 +1,15 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' import type { RetryId } from './brand.ts' +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ + 'llm/retry': LlmRetryEventData + /** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ + 'llm/retry-started': LlmRetryStartedEventData + } +} + /** Durable payload recorded before one provider-routed model-request retry wait. */ export type LlmRetryEventData = | { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index c8dd974fd2..00234424ff 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -42,7 +42,7 @@ import type { PlanProjection } from './types.ts' // declarations still receive the SessionProjectionMap merge. export type * from './types.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Whether plan mode is in force from this point on: log-only, non-surface, diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index fc7c53938c..165a462046 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -21,7 +21,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * The session's sandbox mode was switched — log-only (like `approval/*`; diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 8474e21594..ba9dc39c57 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -24,7 +24,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'cache-test/mark': { marks: string[] } } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index bd33914b2d..e54f1f2468 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { 'test/mark': { marks: string[] } } diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 31e587c301..029381fe78 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -17,7 +17,7 @@ import { type TelemetryRecord, } from '../src/index.ts' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Test-only merged event proving unknown types flow through unchanged. diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 1b0e328621..52572db30d 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -37,7 +37,7 @@ export interface SessionTitleLlmRequestEventData { readonly maxTokens: number } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** Log-only pre-dispatch record of one session-title model request. */ 'session/title-llm-request': SessionTitleLlmRequestEventData diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 12ca7924b4..6f8594edd4 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -91,7 +91,7 @@ declare module 'cordis' { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Latest-wins session title snapshot. Log-only: it never enters the model diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 55ba73dcc4..6d9dedee75 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -25,7 +25,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Durable identity and lifecycle mode of a session-backed subagent child, diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 4bacd80fec..f88941fa12 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -77,7 +77,7 @@ export interface DeepSeekSearchLlmRequest { } } -declare module '@deepseek-ai/dsh-session' { +declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** Secret-free auxiliary DeepSeek search request recorded before dispatch. */ 'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest From 10464d155def7c3cdc22459e6bfe824cc118a10a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:42:25 +0800 Subject: [PATCH 09/20] fix(client): consume typed business session events --- ...08-09-client-conversation-node-assembly.md | 2 +- ...09-client-conversation-node-assembly.zh.md | 2 +- packages/client/runtime/package.json | 2 + .../sessions/conversation-location-index.ts | 12 +- .../src/client/sessions/request-inspection.ts | 108 ++++++------------ .../src/client/sessions/steering-history.ts | 5 +- .../src/client/sessions/tool-call-tree.ts | 22 +--- packages/client/runtime/tsconfig.json | 6 + packages/client/ui-conversation/package.json | 8 ++ .../client/conversation-nodes/assistant.ts | 10 +- .../src/client/conversation-nodes/command.ts | 72 +++++------- .../client/conversation-nodes/compaction.ts | 14 +-- .../src/client/conversation-nodes/inbox.ts | 13 ++- .../src/client/conversation-nodes/retry.ts | 58 +++------- .../src/client/conversation-nodes/tool.ts | 31 ++--- .../client/conversation-nodes/turn-error.ts | 7 +- .../client/conversation-nodes/turn-tail.ts | 9 +- .../ui-conversation/tests/chat-apply.spec.tsx | 4 +- .../conversation-node-definitions.spec.ts | 22 ++++ packages/client/ui-conversation/tsconfig.json | 12 ++ packages/compact/compact/package.json | 8 +- packages/compact/compact/src/types.ts | 4 +- packages/core/agent/package.json | 4 +- packages/core/agent/src/dispatch.ts | 2 +- packages/core/agent/src/inbox.ts | 4 +- packages/core/agent/src/index.ts | 5 +- .../agent/src/{types.ts => runtime-types.ts} | 22 +--- packages/core/agent/src/session-types.ts | 27 +++++ .../tests/gen-persistence-catalog.spec.ts | 2 +- packages/core/tools/package.json | 4 + packages/core/tools/src/code-mode.ts | 36 +----- packages/core/tools/src/index.ts | 1 + packages/core/tools/src/types.ts | 58 ++++++++++ packages/interaction/commands/package.json | 4 + packages/interaction/commands/src/index.ts | 42 +------ packages/interaction/commands/src/types.ts | 48 ++++++++ packages/llm/llm-retry/package.json | 4 - packages/llm/llm-retry/src/types.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 2 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 +- .../context-breakdown-projection.spec.ts | 2 +- .../tests/token-usage-projection.spec.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- pnpm-lock.yaml | 18 +++ scripts/gen-persistence-catalog.ts | 19 +-- scripts/type-equiv.manifest.json | 16 +-- tsconfig.base.json | 3 + 47 files changed, 405 insertions(+), 359 deletions(-) rename packages/core/agent/src/{types.ts => runtime-types.ts} (96%) create mode 100644 packages/core/agent/src/session-types.ts create mode 100644 packages/core/tools/src/types.ts create mode 100644 packages/interaction/commands/src/types.ts diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index f9768a652c..9952f8fac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -16,7 +16,7 @@ Business events also use different correlation models. Tool has call IDs, Assist 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. -The complete derivation, business-by-business validation, and file-level implementation plan remain in [`business-node assembler version one`](../../../../docs/client-conversation-node-engine-rfc.md), [`follow-up design differences`](../../../../docs/client-conversation-node-engine-follow-up-differences.md), [`business-node and dual-view adaptation analysis`](../../../../docs/client-conversation-node-adaptation-analysis.md), and the [`Chat implementation design`](../../../../docs/client-conversation-node-chat-implementation-plan.md). Those design documents retain the full discussion; this Note fixes the responsibilities, algorithms, and trade-offs that remain relevant after implementation. +This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation. ### Responsibility layers diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 408d61f73b..f27a82459c 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -16,7 +16,7 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在 Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续事件窗口送入引擎并发布它的 snapshot,不再解释具体 conversation 业务。 -详细的方案推导、逐业务适配和逐文件实施设计保留在 [`业务节点组装器第一版`](../../../../docs/client-conversation-node-engine-rfc.md)、[`后续方案差异`](../../../../docs/client-conversation-node-engine-follow-up-differences.md)、[`业务节点与双视图适配论证`](../../../../docs/client-conversation-node-adaptation-analysis.md) 和 [`Chat 链路工程实施设计`](../../../../docs/client-conversation-node-chat-implementation-plan.md)。这些设计稿保留完整讨论过程;本 Note 固定实现后仍需长期维护的职责、算法和取舍。 +本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 ### 责任分层 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3176428b36..f5a9a06a60 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "immer": "^10.1.1", "react": "^18.2.0", "zustand": "~4.4.7" diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts index 20fe3efdff..c842a230a9 100644 --- a/packages/client/runtime/src/client/sessions/conversation-location-index.ts +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -139,7 +139,11 @@ export class ConversationLocationIndex { return this.timeline } - /** Replace all Definition-owned Location values while preserving reader identities. */ + /** + * Replace all Definition-owned Location values while preserving reader identities. + * @param entries - complete current set of Definition-owned Location values. + * @returns whether any published Location data changed. + */ replaceData(entries: readonly { readonly owner: string; readonly data: ConversationLocationData }[]): boolean { const turns = new Map>() const steps = new Map>() @@ -165,7 +169,11 @@ export class ConversationLocationIndex { return changed } - /** Apply changed Context publications without rebuilding Turn/Step membership. */ + /** + * Apply changed Context publications without rebuilding Turn/Step membership. + * @param changes - incremental removals and replacements from published Contexts. + * @returns whether any published Location data changed. + */ applyData(changes: readonly ConversationLocationDataChange[]): boolean { let changed = false for (const change of changes) { diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 36ae79eebe..162f34d5ff 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -5,6 +5,9 @@ import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-compact/types' +import type {} from '@deepseek-ai/dsh-llm-retry/types' +import type {} from '@deepseek-ai/dsh-tools/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './conversation.ts' @@ -109,48 +112,6 @@ export function inspectRequests( } } -interface RetryEvent { - type: 'llm/retry' - seq: number - time: number - data: { - turn: number - step: number - retry: number - maxRetries: number - delayMs: number - failure: { message: string } - } -} - -interface CompactionStartEvent { - type: 'compact/start' - seq: number - time: number - data: { turn: number | null } -} - -interface CompactionSummaryEvent { - type: 'compact/summary' - seq: number - time: number - data: { - summary: readonly ContentBlock[] - rawOutput?: readonly ContentBlock[] - provider: string - model: string - maxTokens?: number - usage?: unknown - } -} - -interface CompactionEndEvent { - type: 'compact/end' - seq: number - time: number - data: { turn: number | null; error?: string } -} - function requestKey(turn: number, step: number): string { return `${turn}\u0000${step}` } @@ -205,10 +166,8 @@ function deriveCallSchemas( capture(String(event.data.callId), event.data.name) continue } - const type = event.type as string - if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') { - const data = event.data as unknown as { subCallId: string; name: string } - capture(data.subCallId, data.name) + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + capture(String(event.data.subCallId), event.data.name) } } return calls @@ -351,14 +310,14 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] if (activeStep === key) activeStep = undefined continue } - if ((sourceEvent.type as string) === 'llm/retry') { - const event = sourceEvent as unknown as RetryEvent - updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), { + if (sourceEvent.type === 'llm/retry') { + const data = sourceEvent.data + updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), { status: 'error', - error: displayFailureMessage(event.data.failure), - retry: event.data.retry, - maxRetries: event.data.maxRetries, - retryDelayMs: event.data.delayMs, + error: displayFailureMessage(data.failure), + retry: data.retry, + ...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}, + retryDelayMs: data.delayMs, }) continue } @@ -374,8 +333,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] continue } - const type = sourceEvent.type as string - if (type === 'session/end-seed' && activeCompaction !== undefined) { + if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) { updateCompaction(activeCompaction, { completedAt: sourceEvent.time, status: 'error', @@ -384,37 +342,36 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] activeCompaction = undefined continue } - if (type === 'compact/start') { - const event = sourceEvent as unknown as CompactionStartEvent + if (sourceEvent.type === 'compact/start') { activeCompaction = requests.length requests.push({ purpose: 'compaction', - startSeq: event.seq, - turn: event.data.turn, + startSeq: sourceEvent.seq, + turn: sourceEvent.data.turn, step: 0, - startedAt: event.time, + startedAt: sourceEvent.time, completedAt: null, status: 'running', }) continue } - if (type === 'compact/summary' && activeCompaction !== undefined) { - const event = sourceEvent as unknown as CompactionSummaryEvent + if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) { + const data = sourceEvent.data updateCompaction(activeCompaction, { - resultSeq: event.seq, - summary: event.data.summary, - ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + resultSeq: sourceEvent.seq, + summary: data.summary, + ...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }), provenance: { - provider: event.data.provider, - model: event.data.model, + provider: data.provider, + model: data.model, }, requestConfig: { - provider: event.data.provider, - model: event.data.model, + provider: data.provider, + model: data.model, purpose: 'compaction', - ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + ...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }), }, - ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + ...(data.usage === undefined ? {} : { usage: data.usage }), }) continue } @@ -426,12 +383,11 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq }) continue } - if (type !== 'compact/end' || activeCompaction === undefined) continue - const event = sourceEvent as unknown as CompactionEndEvent + if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue updateCompaction(activeCompaction, { - completedAt: event.time, - status: event.data.error === undefined ? 'complete' : 'error', - ...(event.data.error === undefined ? {} : { error: event.data.error }), + completedAt: sourceEvent.time, + status: sourceEvent.data.error === undefined ? 'complete' : 'error', + ...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }), }) activeCompaction = undefined } diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/runtime/src/client/sessions/steering-history.ts index 0f66025e16..4220a769ff 100644 --- a/packages/client/runtime/src/client/sessions/steering-history.ts +++ b/packages/client/runtime/src/client/sessions/steering-history.ts @@ -1,6 +1,7 @@ /** Reconstruct durable steering identity from the event-sourced agent inbox. */ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-agent/types' type InboxTarget = 'next-turn' | 'next-step' @@ -45,8 +46,8 @@ export class SteeringHistory { * @returns true only for a user-origin message previously claimed from `next-step`. */ apply(event: SessionEvent): boolean { - if ((event.type as string) === 'agent/inbox/spliced') { - this.applySplice(event.data as unknown as InboxSplice) + if (event.type === 'agent/inbox/spliced') { + this.applySplice(event.data) return false } if (event.type !== 'user/message') return false diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts index fbc32e0cef..4507b7a947 100644 --- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts +++ b/packages/client/runtime/src/client/sessions/tool-call-tree.ts @@ -1,5 +1,5 @@ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from './conversation.ts' @@ -55,13 +55,8 @@ export class ToolCallTree { * @returns Whether the event was consumed as a child-call lifecycle event. */ apply(event: SessionEvent): boolean { - if ((event.type as string) === 'tool/code-dispatch-start') { - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - } + if (event.type === 'tool/code-dispatch-start') { + const data = event.data const running: RunningToolCall = { callId: data.subCallId, name: data.name, @@ -78,15 +73,8 @@ export class ToolCallTree { this.revision++ return true } - if ((event.type as string) !== 'tool/code-dispatch') return false - const data = event.data as unknown as { - parentCallId: string - subCallId: string - name: string - arguments: unknown - isError: boolean - content: ContentBlock[] - } + if (event.type !== 'tool/code-dispatch') return false + const data = event.data const siblings = this.childrenByParent.get(data.parentCallId) ?? [] const at = siblings.findIndex(sub => sub.callId === data.subCallId) if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 867b2f588c..b8949fc6db 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../../interaction/commands" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, { "path": "../../compact/compact" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 3f5bd8364c..e78567cbf7 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,22 +39,28 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-compact": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-token-meter": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", @@ -62,9 +68,11 @@ "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 8a487c89ed..83df1e0df6 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -6,6 +6,7 @@ import type { import { emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { AssistantChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' @@ -197,7 +198,7 @@ function fallbackState(context: ConversationNodeContext): Assist } continue } - if ((match.event.type as string) === 'llm/retry' && state !== undefined) { + if (match.event.type === 'llm/retry' && state !== undefined) { state = resetForRetry(state) } } @@ -247,9 +248,8 @@ export const assistantDefinition: ConversationNodeDefinition = { || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } - if ((event.type as string) === 'llm/retry') { - const data = event.data as unknown as { turn: number; step: number } - return { id: `${data.turn}:${data.step}`, role: 'update' } + if (event.type === 'llm/retry') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } return null }, @@ -268,7 +268,7 @@ export const assistantDefinition: ConversationNodeDefinition = { usage: match.event.data.usage, } } - if ((match.event.type as string) === 'llm/retry') { + if (match.event.type === 'llm/retry') { return resetForRetry(context.state) } return context.state diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 3ca7976e32..38cb85ed87 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -5,6 +5,8 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type {} from '@deepseek-ai/dsh-compact/types' +import type {} from '@deepseek-ai/dsh-commands/types' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' @@ -32,21 +34,9 @@ interface CompactionEvidence { readonly checkpoint?: ConversationMatch } -interface CommandRunData { - readonly commandId: CommandId - readonly name: string - readonly args?: string -} - -interface CommandDoneData { - readonly commandId: CommandId - readonly kind: 'success' | 'error' - readonly text?: string - readonly sourceEventSeq?: number -} - function commandFromRun(match: ConversationMatch): CommandNode { - const data = match.event.data as unknown as CommandRunData + if (match.event.type !== 'command/run') throw new Error('command start requires command/run') + const data = match.event.data return { kind: 'command', seq: match.event.seq, @@ -59,10 +49,12 @@ function commandFromRun(match: ConversationMatch): CommandNode { } function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode { - const data = match.event.data as unknown as CommandDoneData + if (match.event.type !== 'command/done') throw new Error('command update requires command/done') + const data = match.event.data const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number + && data.sourceEventSeq !== undefined + && Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0 + ? data.sourceEventSeq : undefined return { kind: 'command', @@ -112,28 +104,21 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver let summary: string | null = null let shadowedItemCount: number | null = null let shadowedTokenCount: number | null = null - if (match !== undefined) { - const data = match.event.data as unknown as { - summary?: unknown - shadowedSeqs?: unknown - shadowedTokenCount?: unknown - } + if (match?.event.type === 'compact/summary') { + const data = match.event.data if (Array.isArray(data.summary)) { const text = data.summary - .map((block: unknown) => { - const value = block as { type?: unknown; text?: unknown } - return value.type === 'text' && typeof value.text === 'string' ? value.text : '' - }) + .map(block => block.type === 'text' ? block.text : '') .join('') summary = text.trim() === '' ? null : text } shadowedItemCount = Array.isArray(data.shadowedSeqs) - && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && (seq as number) >= 0) + && data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0) ? data.shadowedSeqs.length : null shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount) - && (data.shadowedTokenCount as number) >= 0 - ? data.shadowedTokenCount as number + && data.shadowedTokenCount >= 0 + ? data.shadowedTokenCount : null } return { @@ -148,9 +133,9 @@ function compactSummary(match: ConversationMatch | undefined, checkpoint: Conver } function fallbackState(context: ConversationNodeContext): CommandState | undefined { - const done = context.matches.find(match => (match.event.type as string) === 'command/done') + const done = context.matches.find(match => match.event.type === 'command/done') const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) - const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const summary = context.matches.find(match => match.event.type === 'compact/summary') if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } const source = compactSource(checkpoint.event) if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) } @@ -182,7 +167,7 @@ export function updateCompactionState( state: State, match: ConversationMatch, ): State { - if ((match.event.type as string) === 'compact/summary') return { ...state, summary: match } + if (match.event.type === 'compact/summary') return { ...state, summary: match } if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match } return state } @@ -191,27 +176,28 @@ export function updateCompactionState( export const commandDefinition: ConversationNodeDefinition = { kind: 'command', match: (event) => { - if ((event.type as string) === 'command/run') { - return { id: String((event.data as unknown as CommandRunData).commandId), role: 'start' } + if (event.type === 'command/run') { + return { id: String(event.data.commandId), role: 'start' } } - if ((event.type as string) === 'command/done') { - return { id: String((event.data as unknown as CommandDoneData).commandId), role: 'update' } + if (event.type === 'command/done') { + return { id: String(event.data.commandId), role: 'update' } } const checkpoint = compactSource(event) if (checkpoint?.sourceCommandId !== undefined) { return { id: String(checkpoint.sourceCommandId), role: 'update' } } - if ((event.type as string) === 'compact/start' - || (event.type as string) === 'compact/summary' - || (event.type as string) === 'compact/end') { - const data = event.data as unknown as { sourceCommandId?: CommandId } - if (data.sourceCommandId !== undefined) return { id: String(data.sourceCommandId), role: 'update' } + if (event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end') { + if (event.data.sourceCommandId !== undefined) { + return { id: String(event.data.sourceCommandId), role: 'update' } + } } return null }, start: (_context, match) => ({ command: commandFromRun(match) }), update: (context, match) => { - if ((match.event.type as string) === 'command/done') { + if (match.event.type === 'command/done') { return { ...context.state, command: commandFromDone(match, context.state.command) } } return updateCompactionState(context.state, match) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index 0742ab6539..04eace8038 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-compact/types' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' @@ -18,7 +19,7 @@ interface CompactionState { } function fallbackState(context: ConversationNodeContext): CompactionState { - const summary = context.matches.find(match => (match.event.type as string) === 'compact/summary') + const summary = context.matches.find(match => match.event.type === 'compact/summary') const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined) return { ...summary === undefined ? {} : { summary }, @@ -34,12 +35,11 @@ export const compactionDefinition: ConversationNodeDefinition = if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { return { id: checkpoint.compactionId, role: 'update' } } - if ((event.type as string) === 'compact/start' - || (event.type as string) === 'compact/summary' - || (event.type as string) === 'compact/end') { - const data = event.data as unknown as { compactionId?: unknown; sourceCommandId?: unknown } - if (typeof data.compactionId !== 'string' || data.sourceCommandId !== undefined) return null - return { id: data.compactionId, role: (event.type as string) === 'compact/start' ? 'start' : 'update' } + if (event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end') { + if (event.data.sourceCommandId !== undefined) return null + return { id: String(event.data.compactionId), role: event.type === 'compact/start' ? 'start' : 'update' } } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index 092bcaaa59..74406c324a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-agent/types' type InboxTarget = 'next-turn' | 'next-step' @@ -41,14 +42,14 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition (event.type as string) === 'agent/inbox/spliced' - && (event.data as unknown as { target?: unknown }).target === target + match: event => event.type === 'agent/inbox/spliced' + && event.data.target === target ? { id: String(event.seq), role: 'start' } : null, - start: (_context, match, reader) => applySplice( - reader.previous(kind), - match.event.data as unknown as InboxSplice, - ), + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`) + return applySplice(reader.previous(kind), match.event.data) + }, update: context => context.state, publication: () => 'none', buildViewNode: () => null, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index fe95c0052d..f383bc46d8 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' import { chatNode } from './common.ts' @@ -12,11 +13,6 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { } } -type WithoutRetryProjection = Node extends unknown - ? Omit - : never -type RetryEventData = WithoutRetryProjection - /** Accumulated retry attempts sharing one producer-owned RetryId. */ export interface RetryState { readonly turn: number @@ -24,31 +20,14 @@ export interface RetryState { readonly attempts: readonly ModelRetryNode[] } -function retryData(value: unknown): RetryEventData | undefined { - if (value === null || typeof value !== 'object') return undefined - const data = value as Record - if (typeof data.retryId !== 'string' || data.retryId === '' - || !Number.isSafeInteger(data.turn) || (data.turn as number) < 0 - || !Number.isSafeInteger(data.step) || (data.step as number) < 0 - || !Number.isSafeInteger(data.retry) || (data.retry as number) <= 0 - || typeof data.delayMs !== 'number' || !Number.isFinite(data.delayMs) || data.delayMs < 0 - || typeof data.provider !== 'string' || typeof data.policyKey !== 'string' - || (data.mode !== 'normal' && data.mode !== 'always') - || data.failure === null || typeof data.failure !== 'object') return undefined - if (data.mode === 'normal' && (!Number.isSafeInteger(data.maxRetries) || (data.maxRetries as number) <= 0)) { - return undefined - } - return data as unknown as RetryEventData -} - -function scheduledNode(event: { seq: number; time: number; data: unknown }): ModelRetryNode | undefined { - const data = retryData(event.data) - return data === undefined ? undefined : { +function scheduledNode(match: Parameters[1]): ModelRetryNode | undefined { + if (match.event.type !== 'llm/retry') return undefined + return { kind: 'model-retry', - seq: event.seq, - time: event.time, + seq: match.event.seq, + time: match.event.time, retryState: 'scheduled', - ...data, + ...match.event.data, } } @@ -61,33 +40,30 @@ function isClosed(location: ConversationLocation): boolean { export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', match: (event) => { - if ((event.type as string) === 'llm/retry') { - const data = retryData(event.data) - if (data === undefined) return null - return { id: String(data.retryId), role: data.retry === 1 ? 'start' : 'update' } + if (event.type === 'llm/retry') { + return { id: String(event.data.retryId), role: event.data.retry === 1 ? 'start' : 'update' } } - if ((event.type as string) === 'llm/retry-started') { - const data = event.data as unknown as { retryId?: unknown } - return typeof data.retryId === 'string' ? { id: data.retryId, role: 'update' } : null + if (event.type === 'llm/retry-started') { + return { id: String(event.data.retryId), role: 'update' } } return null }, start: (_context, match) => { - const node = scheduledNode(match.event) + const node = scheduledNode(match) if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event') return { turn: node.turn, step: node.step, attempts: [node] } }, update: (context, match) => { - if ((match.event.type as string) === 'llm/retry') { - const node = scheduledNode(match.event) + if (match.event.type === 'llm/retry') { + const node = scheduledNode(match) return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] } } - if ((match.event.type as string) !== 'llm/retry-started') return context.state - const data = match.event.data as unknown as { retry: number } + if (match.event.type !== 'llm/retry-started') return context.state + const retry = match.event.data.retry return { ...context.state, attempts: context.state.attempts.map(attempt => - attempt.retry === data.retry ? { ...attempt, retryState: 'started' } : attempt), + attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt), } }, buildViewNode: (context, target) => { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index 23201fec3e..dc9e996dcc 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -4,6 +4,7 @@ import type { RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' @@ -141,27 +142,30 @@ function acceptsEdge(state: ToolState, parent: string, child: string): boolean { } function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { - const data = match.event.data as unknown as DispatchData - const siblings = state.children.get(data.parentCallId) ?? [] - const index = siblings.findIndex(candidate => candidate.callId === data.subCallId) - if ((match.event.type as string) === 'tool/code-dispatch-start') { - if (index >= 0 || !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + const event = match.event + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state + const data = event.data + const parentCallId = String(data.parentCallId) + const subCallId = String(data.subCallId) + const siblings = state.children.get(parentCallId) ?? [] + const index = siblings.findIndex(candidate => candidate.callId === subCallId) + if (event.type === 'tool/code-dispatch-start') { + if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state const children = new Map(state.children) - children.set(data.parentCallId, [...siblings, childCall(match, data)]) + children.set(parentCallId, [...siblings, childCall(match, data)]) const parents = new Map(state.parents) - parents.set(data.subCallId, data.parentCallId) + parents.set(subCallId, parentCallId) return { ...state, children, parents } } - if ((match.event.type as string) !== 'tool/code-dispatch') return state - if (index < 0 && !acceptsEdge(state, data.parentCallId, data.subCallId)) return state + if (index < 0 && !acceptsEdge(state, parentCallId, subCallId)) return state const previous = index < 0 ? undefined : siblings[index] const settled = childResult(match, data, previous) const children = new Map(state.children) - children.set(data.parentCallId, index < 0 + children.set(parentCallId, index < 0 ? [...siblings, settled] : siblings.map((child, at) => at === index ? settled : child)) const parents = new Map(state.parents) - if (index < 0) parents.set(data.subCallId, data.parentCallId) + if (index < 0) parents.set(subCallId, parentCallId) return { ...state, children, parents } } @@ -236,9 +240,8 @@ export const toolDefinition: ConversationNodeDefinition = { if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.message.source.callId), role: 'update' } } - if ((event.type as string) === 'tool/code-dispatch-start' || (event.type as string) === 'tool/code-dispatch') { - const data = event.data as unknown as { rootCallId: string } - return { id: data.rootCallId, role: 'update' } + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + return { id: String(event.data.rootCallId), role: 'update' } } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts index 58725d8723..60b2fca087 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -3,6 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import { chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { @@ -30,9 +31,9 @@ function lastStep(context: ConversationNodeContext): number { } function retryTurn(event: Parameters[0]): number | undefined { - if ((event.type as string) !== 'llm/retry' && (event.type as string) !== 'llm/retry-started') return undefined - const turn = (event.data as unknown as { turn?: unknown }).turn - return Number.isSafeInteger(turn) && (turn as number) >= 0 ? turn as number : undefined + return event.type === 'llm/retry' || event.type === 'llm/retry-started' + ? event.data.turn + : undefined } function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 51cb348e6a..2a1b4d2b13 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -3,6 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' @@ -58,9 +59,7 @@ function turnCoordinates(event: Parameters[ || event.type === 'step/end') { return { turn: event.data.turn, step: event.data.step } } - if ((event.type as string) === 'llm/retry') { - return event.data as unknown as { turn: number; step: number } - } + if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step } return undefined } @@ -90,7 +89,7 @@ function closingAnchor(context: ConversationNodeContext): number } continue } - if ((event.type as string) === 'llm/retry') { + if (event.type === 'llm/retry') { steps.set(coordinates.step, { streamedText: false, finalized: false }) continue } @@ -130,7 +129,7 @@ function tailData(context: ConversationNodeContext): TurnTailChat const candidate = event.type === 'tool/call' || (event.type === 'tool/result' && isAppendSurfaceEvent(event)) || (event.type === 'turn/end' && event.data.reason.kind === 'error') - || (event.type as string) === 'llm/retry' + || event.type === 'llm/retry' ? event.seq : undefined if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) { diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 3481ee758e..41ffd329b8 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -62,7 +62,9 @@ describe('apply wiring', () => { expect(entries[0]?.options.order).toBe(0) // Declaring is claiming: the chat entry's registration put the hole on // the ledger with the contract's kind/scope. - expect(b.slots.spec('conversation.chat.node')).toEqual({ kind: 'keyed', scope: 'session' }) + const nodeSlot = b.slots.spec('conversation.chat.node') + expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' }) + expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function') await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 5346ad57f9..3e5dcccff3 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -644,6 +644,28 @@ describe('built-in conversation node Definitions', () => { }) }) + it('renders a historical compaction when its start remains outside the loaded window', () => { + const value = assembler([ + at(10, 'compact/summary', { + compactionId: 'compact-windowed', + summary: [{ type: 'text', text: 'loaded summary' }], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + at(11, 'user/message', { + ...textMessage('checkpoint-windowed', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + ], true) + + expect(node(snapshot(value), 'compaction')?.data).toMatchObject({ + summary: 'loaded summary', + summaryEventSeq: 10, + shadowedItemCount: 3, + shadowedTokenCount: 42, + }) + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 89d0067caf..0d6f250441 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -23,12 +23,24 @@ { "path": "../runtime" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../interaction/commands" + }, { "path": "../../session/session-projection" }, { "path": "../../llm/token-meter" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../plan/plan-mode" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index bb4150ee32..860145e542 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -15,14 +15,14 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./checkpoint": { "types": "./lib/types/checkpoint.d.ts", "default": "./lib/types/checkpoint.js" }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index df5e749cd8..53c3baf05f 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -9,7 +9,9 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import type { CompactionId } from './brand.ts' +import { CompactionId } from './brand.ts' + +export { CompactionId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9f64d33e75..d186f32d03 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -16,8 +16,8 @@ "default": "./lib/invariant.js" }, "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" + "types": "./lib/types/session-types.d.ts", + "default": "./lib/types/session-types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index cf07f24ecf..7eff09b01d 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -10,7 +10,7 @@ import type { Context, Events } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from './types.ts' +import type { Agent } from './runtime-types.ts' /** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */ type Params = F extends (...args: infer P) => unknown ? P : never diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index d457277035..2db3c4b580 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -6,9 +6,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' - -/** One of the two ordered pending-message lists owned by an agent. */ -export type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from './session-types.ts' /** Mutable state privately owned by an {@link Inbox}. */ type InboxState = Record diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index bedfc819dc..e35bc06211 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -13,9 +13,10 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' -import type { Agent, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './runtime-types.ts' -export * from './types.ts' +export * from './runtime-types.ts' +export * from './session-types.ts' export * from './inbox.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/runtime-types.ts similarity index 96% rename from packages/core/agent/src/types.ts rename to packages/core/agent/src/runtime-types.ts index 8decb9ba3f..072f509f8f 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -2,7 +2,7 @@ * Public agent types and live-runtime events. Durable transcript facts and * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * - * @module @deepseek-ai/dsh-agent/types + * @module @deepseek-ai/dsh-agent */ import type { Context } from 'cordis' @@ -10,7 +10,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' -import type { Inbox, InboxTarget } from './inbox.ts' +import type { Inbox } from './inbox.ts' +import type { InboxTarget } from './session-types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -289,20 +290,3 @@ declare module 'cordis' { 'agent/error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; error: unknown }): void } } - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. - */ - 'agent/inbox/spliced': { - target: InboxTarget - start: number - removedCount?: number - inserted: UserMessage[] - outcome?: 'canceled' - } - } -} diff --git a/packages/core/agent/src/session-types.ts b/packages/core/agent/src/session-types.ts new file mode 100644 index 0000000000..b54e56ea8f --- /dev/null +++ b/packages/core/agent/src/session-types.ts @@ -0,0 +1,27 @@ +/** + * Durable agent session-event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-agent/types + */ + +import type { UserMessage } from '@deepseek-ai/dsh-llm/types' + +/** One of the two ordered pending-message lists owned by an agent. */ +export type InboxTarget = 'next-turn' | 'next-step' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * One normalized mutation of an agent's durable pending-message lists. + * Live dispatch precedes projection mutation, so synchronous observers may + * read the pre-splice inbox to recover the removed messages. + */ + 'agent/inbox/spliced': { + target: InboxTarget + start: number + removedCount?: number + inserted: UserMessage[] + outcome?: 'canceled' + } + } +} diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index 22bc09835f..4fee74793f 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -89,7 +89,7 @@ describe('gen-persistence-catalog collectLogEvents', () => { it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => { expect(() => collectLogEvents(make({ 'packages/group/fix/src/types.ts': - 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n', + 'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session/types\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n', }))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/) }) diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 3279f17729..0fcc66b497 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./presentation": { "types": "./lib/types/presentation.d.ts", "default": "./lib/types/presentation.js" diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 984fecab88..f5c5809544 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -14,41 +14,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * One sub-dispatch STARTING inside a `run_code` program: the parent - * `run_code` call id, the deterministic sub-call id (`:code:`, - * numbered in submission order), and the tool `name` with its - * JSON-normalized `arguments` — the exact value dispatched, normalized - * BEFORE dispatch, so this append can never fail on payload shape. - * Appended when the scheduler actually starts the call (not at - * submission), so a start means the tool body pipeline was entered; a - * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` - * ignores it; UIs use it for live per-sub-call running state and pair it - * with `tool/code-dispatch` by `subCallId` (timing = the two events' - * `time` fields). - */ - 'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } - /** - * One bridged sub-dispatch SETTLING: the pairing ids (matching the - * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` - * with the same JSON-normalized `arguments`, and the sub-call's complete - * model-facing outcome in `tool/result`'s own vocabulary - * (`content` + `isError`), so UIs render a sub-call through the exact - * code path that renders a native call. Every started sub-call settles - * with exactly one of these (abort included: the aborted pipeline result - * is an `isError` outcome). - * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter - * model context; persistence and UIs get every call. Appended inside the - * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so its execution-enclosure relation holds by - * construction. - */ - 'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } - } -} +import type {} from './types.ts' /** The model-facing name of the Code Mode tool. */ export const RUN_CODE_NAME = 'run_code' diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6338e54501..1a5f6a05eb 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -85,6 +85,7 @@ export { } from './json-schema.ts' export type { JsonValue } from '@deepseek-ai/dsh-session' +export type { CodeDispatchEventData, CodeDispatchStartEventData } from './types.ts' export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' diff --git a/packages/core/tools/src/types.ts b/packages/core/tools/src/types.ts new file mode 100644 index 0000000000..4dd6f7a9a8 --- /dev/null +++ b/packages/core/tools/src/types.ts @@ -0,0 +1,58 @@ +/** + * Durable Tool event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-tools/types + */ + +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' + +/** Payload recorded when one nested Code Mode Tool dispatch starts. */ +export interface CodeDispatchStartEventData { + rootCallId: CallId + parentCallId: CallId + subCallId: CallId + name: string + arguments: unknown +} + +/** Payload recorded when one nested Code Mode Tool dispatch settles. */ +export interface CodeDispatchEventData extends CodeDispatchStartEventData { + isError: boolean + content: ContentBlock[] +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * One sub-dispatch STARTING inside a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id (`:code:`, + * numbered in submission order), and the tool `name` with its + * JSON-normalized `arguments` — the exact value dispatched, normalized + * BEFORE dispatch, so this append can never fail on payload shape. + * Appended when the scheduler actually starts the call (not at + * submission), so a start means the tool body pipeline was entered; a + * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` + * ignores it; UIs use it for live per-sub-call running state and pair it + * with `tool/code-dispatch` by `subCallId` (timing = the two events' + * `time` fields). + */ + 'tool/code-dispatch-start': CodeDispatchStartEventData + /** + * One bridged sub-dispatch SETTLING: the pairing ids (matching the + * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` + * with the same JSON-normalized `arguments`, and the sub-call's complete + * model-facing outcome in `tool/result`'s own vocabulary + * (`content` + `isError`), so UIs render a sub-call through the exact + * code path that renders a native call. Every started sub-call settles + * with exactly one of these (abort included: the aborted pipeline result + * is an `isError` outcome). + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains in-flight dispatches + * before returning), so its execution-enclosure relation holds by + * construction. + */ + 'tool/code-dispatch': CodeDispatchEventData + } +} diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 7d35603a79..cd62942a5f 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -15,6 +15,10 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./brand": { "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index ba7b449228..9f4110568e 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -11,24 +11,12 @@ import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-se import { CommandId } from './brand.ts' export { CommandId } from './brand.ts' +export type { CommandSource, CommandSourceMap } from './types.ts' export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** - * Producer record for one command invocation (the `command/run` event's - * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s - * shape; minimal today because every executor caller is a human-facing UI - * surface dispatching a human-typed line, so the sole variant is `user`. - */ -export interface CommandSourceMap { - user: { kind: 'user' } -} - -/** The union over {@link CommandSourceMap} — who issued a command line. */ -export type CommandSource = CommandSourceMap[keyof CommandSourceMap] - /** Immutable metadata for a command's optional unstructured input. */ export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ @@ -131,34 +119,6 @@ class CommandLayer implements ScopeLayer { } } -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * A resolved slash command entered its handler. Log-only (never model - * surface); paired with `command/done` by `commandId`, mirroring the - * `tool/call`↔`tool/result` pairing. The payload is structured — `name` - * and `args` are `parseCommand`'s own split (name and verbatim rawInput, - * separator whitespace included), so a consumer (a projection unit - * folding its own command records, a rich command card) never re-parses - * a line. `args` is absent when the definition sets `recordInput: false` - * because an authoritative domain event owns the input payload. - */ - 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } - /** - * The paired command settled. `kind`/`text` carry the handler's verbatim - * outcome (a thrown/aborted handler settles as `kind: 'error'` with the - * rendered failure). A successful command may identify the earlier - * authoritative domain event for a richer client-computed presentation. - */ - 'command/done': { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - } -} - declare module 'cordis' { interface Context { commands: CommandService diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts new file mode 100644 index 0000000000..2b81071d37 --- /dev/null +++ b/packages/interaction/commands/src/types.ts @@ -0,0 +1,48 @@ +/** + * Durable command event vocabulary shared with type-only consumers. + * + * @module @deepseek-ai/dsh-commands/types + */ + +import type { CommandId } from './brand.ts' + +/** + * Producer record for one command invocation (the `command/run` event's + * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s + * shape; minimal today because every executor caller is a human-facing UI + * surface dispatching a human-typed line, so the sole variant is `user`. + */ +export interface CommandSourceMap { + user: { kind: 'user' } +} + +/** The union over {@link CommandSourceMap} — who issued a command line. */ +export type CommandSource = CommandSourceMap[keyof CommandSourceMap] + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * A resolved slash command entered its handler. Log-only (never model + * surface); paired with `command/done` by `commandId`, mirroring the + * `tool/call`↔`tool/result` pairing. The payload is structured — `name` + * and `args` are `parseCommand`'s own split (name and verbatim rawInput, + * separator whitespace included), so a consumer (a projection unit + * folding its own command records, a rich command card) never re-parses + * a line. `args` is absent when the definition sets `recordInput: false` + * because an authoritative domain event owns the input payload. + */ + 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } + /** + * The paired command settled. `kind`/`text` carry the handler's verbatim + * outcome (a thrown/aborted handler settles as `kind: 'error'` with the + * rendered failure). A successful command may identify the earlier + * authoritative domain event for a richer client-computed presentation. + */ + 'command/done': { + commandId: CommandId + kind: 'success' | 'error' + text?: string + sourceEventSeq?: number + } + } +} diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 1685d0e552..809b5d14e5 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -19,10 +19,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, - "./brand": { - "types": "./lib/types/brand.d.ts", - "default": "./lib/types/brand.js" - }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index c698b68446..5144939289 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,5 +1,7 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' -import type { RetryId } from './brand.ts' +import { RetryId } from './brand.ts' + +export { RetryId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 33ac3a19fa..917a6d03f8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 3a605bd40e..9e17290b43 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/brand' +import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' import type {} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 9251eef2c0..bfbf6a89ee 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 65771fc877..3f66e0564b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0276199e3e..db60b695b1 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { CompactionId } from '@deepseek-ai/dsh-compact/brand' +import { CompactionId } from '@deepseek-ai/dsh-compact/types' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1ee272761..6fb20b9718 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1495,6 +1495,9 @@ importers: packages/client/runtime: dependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1522,6 +1525,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools immer: specifier: ^10.1.1 version: 10.2.0 @@ -1656,6 +1662,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -1677,6 +1686,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -1686,6 +1698,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm-retry': + specifier: workspace:^ + version: link:../../llm/llm-retry '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../interaction/permission @@ -1701,6 +1716,9 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 5294242e27..173d4222cb 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -18,8 +18,11 @@ const OUT = 'docs/persistence-catalog.md' * doc-typecheck, since their imported types are not standalone-compilable). */ const FENCE = 'ts persistence-catalog' -/** The package whose module id plugin merges augment (`declare module '…'`). */ -const SESSION_MODULE = '@deepseek-ai/dsh-session' +/** The package that owns the durable event vocabulary. */ +const SESSION_PACKAGE = '@deepseek-ai/dsh-session' + +/** The type-only module that plugin declaration merges augment. */ +const SESSION_TYPES_MODULE = '@deepseek-ai/dsh-session/types' /** Event-envelope declarations rendered before the per-event vocabulary. */ const EVENT_ENVELOPE_TYPE_NAMES = [ @@ -115,7 +118,7 @@ function declarationText(text: string, sf: ts.SourceFile, node: ts.Node): string /** * Every `interface SessionEventMap` declaration in a source file: the owning * top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration - * merge inside a `declare module '@deepseek-ai/dsh-session'` block. Both forms + * merge inside a `declare module '@deepseek-ai/dsh-session/types'` block. Both forms * declare members of the SAME merged interface, so both are catalogued * uniformly. `topLevel` distinguishes the owning form so the caller can verify * it actually lives in the owning package — an unrelated local interface that @@ -125,7 +128,7 @@ function sessionEventMapDecls(sf: ts.SourceFile): { decl: ts.InterfaceDeclaratio const decls: { decl: ts.InterfaceDeclaration; topLevel: boolean }[] = [] for (const stmt of sf.statements) { if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === 'SessionEventMap') decls.push({ decl: stmt, topLevel: true }) - if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_MODULE + if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === SESSION_TYPES_MODULE && stmt.body && ts.isModuleBlock(stmt.body)) { for (const inner of stmt.body.statements) { if (ts.isInterfaceDeclaration(inner) && inner.name.text === 'SessionEventMap') decls.push({ decl: inner, topLevel: false }) @@ -174,8 +177,8 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { // the owning package. Same-named interfaces elsewhere are different // types and must not enter the on-disk catalog. const pkg = packageNameFor(rel, scanRoot) - if (pkg !== SESSION_MODULE) { - violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`) + if (pkg !== SESSION_PACKAGE) { + violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_PACKAGE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_TYPES_MODULE}'.`) continue } const exported = decl.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false @@ -243,7 +246,7 @@ export function collectEventEnvelopeTypes(scanRoot: string = root): EventEnvelop const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!EVENT_ENVELOPE_TYPE_NAMES.some(name => text.includes(name))) continue - if (packageNameFor(rel, scanRoot) !== SESSION_MODULE) continue + if (packageNameFor(rel, scanRoot) !== SESSION_PACKAGE) continue const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) for (const stmt of sf.statements) { if (!ts.isTypeAliasDeclaration(stmt) || !wanted.has(stmt.name.text)) continue @@ -352,7 +355,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', '# Session Persistence Event Catalog', '', - 'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', + 'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).', '', 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).', '', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index cd9d020779..73431101da 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -114,12 +114,12 @@ { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", - "source": "packages/core/agent/src/inbox.ts" + "source": "packages/core/agent/src/session-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "CancelOptions", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", @@ -129,22 +129,22 @@ { "doc": "docs/subsystems/core.md", "symbol": "Agent", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "PreStepDecision", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "RequestErrorAction", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "SessionStartSource", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/scope.md", @@ -1698,12 +1698,12 @@ { "doc": "docs/subsystems/core.md", "symbol": "AgentStatus", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" }, { "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", - "source": "packages/core/agent/src/types.ts" + "source": "packages/core/agent/src/runtime-types.ts" } ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index d8b777a2ad..c7be2d4925 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,8 +71,11 @@ "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], + "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], + "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], + "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/interaction/user-interaction/src/types.ts"], From 1b1e170c1f291b53af8fa5ac1257530a7e59f8fd Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:43:37 +0800 Subject: [PATCH 10/20] refactor(agent): restore conventional types entrypoint --- packages/core/agent/package.json | 4 ++-- packages/core/agent/src/inbox.ts | 2 +- packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/runtime-types.ts | 2 +- packages/core/agent/src/{session-types.ts => types.ts} | 0 scripts/type-equiv.manifest.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename packages/core/agent/src/{session-types.ts => types.ts} (100%) diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index d186f32d03..9f64d33e75 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -16,8 +16,8 @@ "default": "./lib/invariant.js" }, "./types": { - "types": "./lib/types/session-types.d.ts", - "default": "./lib/types/session-types.js" + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" }, "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index 2db3c4b580..c6b9204c92 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -6,7 +6,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' -import type { InboxTarget } from './session-types.ts' +import type { InboxTarget } from './types.ts' /** Mutable state privately owned by an {@link Inbox}. */ type InboxState = Record diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index e35bc06211..4df0056c1a 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -16,7 +16,7 @@ import type { TypeRTContext, TypeRTLookup } from '@deepseek-ai/dsh-type-meta' import type { Agent, AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' -export * from './session-types.ts' +export * from './types.ts' export * from './inbox.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 072f509f8f..3698c05018 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -11,7 +11,7 @@ import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-a import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' import type { Inbox } from './inbox.ts' -import type { InboxTarget } from './session-types.ts' +import type { InboxTarget } from './types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { diff --git a/packages/core/agent/src/session-types.ts b/packages/core/agent/src/types.ts similarity index 100% rename from packages/core/agent/src/session-types.ts rename to packages/core/agent/src/types.ts diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 73431101da..fccabef852 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -114,7 +114,7 @@ { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", - "source": "packages/core/agent/src/session-types.ts" + "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/subsystems/core.md", From 924cfcef9595e101ec090d1f118c8f557e11469d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:56:29 +0800 Subject: [PATCH 11/20] fix: coverage --- .../src/client/contract/conversation.ts | 4 ++++ .../client/sessions/conversation-assembler.ts | 5 ++--- .../sessions/conversation-location-index.ts | 2 +- .../client/test-runtime/tests/runtime.spec.tsx | 3 ++- .../src/client/contract/chat-nodes.ts | 2 +- .../src/client/contract/slots.ts | 2 +- .../tests/chat-snapshot-fixture.ts | 7 +++++-- .../ui-conversation/tests/chat-view.spec.tsx | 18 ++++++++++-------- .../tests/produced-files.spec.tsx | 7 +++++-- .../client/ui-settings/tests/apply.spec.ts | 12 ++++++------ packages/client/ui-slots/src/index.ts | 10 ++++++++-- packages/typert/generator/src/analyzer.ts | 2 +- 12 files changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index fb98965d25..9507046b33 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -1,6 +1,10 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +/* 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 log event plus its optional envelope-level presentation view. */ export interface ConversationEventInput { readonly event: SessionEvent diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index 811bd43ebf..bdd89f56a4 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -712,9 +712,8 @@ export class ConversationNodeAssembler { context: InternalContext, scope: ConversationLocationDataScope, ): ConversationLocationData | null { - const build = context.definition.buildLocationData - if (build === undefined) return null - const data = build(contextSnapshot(context), scope) + if (context.definition.buildLocationData === undefined) return null + const data = context.definition.buildLocationData(contextSnapshot(context), scope) if (data === null) return null if (data.kind !== scope) { throw new Error( diff --git a/packages/client/runtime/src/client/sessions/conversation-location-index.ts b/packages/client/runtime/src/client/sessions/conversation-location-index.ts index c842a230a9..f4ce189b13 100644 --- a/packages/client/runtime/src/client/sessions/conversation-location-index.ts +++ b/packages/client/runtime/src/client/sessions/conversation-location-index.ts @@ -20,7 +20,7 @@ export interface ConversationLocationDataChange { class MutableLocationDataStore { private entries = new Map() - get(key: Key): unknown { + get(key: string): unknown { return this.entries.get(key)?.value } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f92d21b4b5..08a82ee251 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -18,6 +18,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } } 'trt.chat': { kind: 'single'; scope: 'session' } 'trt.rows': { kind: 'list'; scope: 'root' } + 'trt.rows.hole': { kind: 'single'; scope: 'root' } } } @@ -419,7 +420,7 @@ describe('feature mount and disposal', () => { await feature.dispose() await feature.dispose() // idempotent expect(runtime.slots.entries('trt.rows')).toHaveLength(0) - expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined() + expect(runtime.slots.spec('trt.rows.hole')).toBeUndefined() expect(runtime.ctx.get('feature-service')).toBeUndefined() expect(view.queryByTestId('row')).toBeNull() await runtime.dispose() diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts index 3415c502a5..787f391006 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -7,7 +7,7 @@ import type { export interface ChatNodeDataMap {} /** Renderer kinds contributed by the currently installed Chat business modules. */ -export type ChatNodeKind = keyof ChatNodeDataMap & string +export type ChatNodeKind = Extract /** Final Chat Node narrowed to one registered renderer kind and payload. */ export type ChatNode = { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eec5450e2d..56fca2381c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -210,7 +210,7 @@ export interface TurnTailOwnerProps { } /** Hook constrained to business data published on the current Chat Node's Turn. */ -export type UseChatNodeTurnData = ( +export type UseChatNodeTurnData = >( key: Key, ) => Readonly | undefined diff --git a/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts b/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts index c675554ea0..fb343f0f68 100644 --- a/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts +++ b/packages/client/ui-conversation/tests/chat-snapshot-fixture.ts @@ -78,13 +78,16 @@ class FixtureLocationIndex implements ChatLocationNodeIndex { class FixtureTurnDataStore implements ConversationLocationDataStore { private readonly values = new Map() - get( + get>( key: Key, ): Readonly | undefined { return this.values.get(key) as Readonly | undefined } - set(key: Key, value: ConversationTurnDataMap[Key]): void { + set>( + key: Key, + value: ConversationTurnDataMap[Key], + ): void { this.values.set(key, value) } } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 296dd85966..ca6223826e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -181,12 +181,14 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData = ((dataKey: string) => props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location - return location?.kind === 'turn' || location?.kind === 'step' - ? location.turn.data.get(dataKey as never) - : undefined - })) as UseChatNodeTurnData + const useTurnData = ((dataKey: string) => { + return props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey as never) + : undefined + }) + }) as UseChatNodeTurnData const nodeProps = (): ChatNodeViewProps => ( { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) @@ -226,7 +228,7 @@ function makeHarness(init?: Partial) { case 'unknown': return ()} /> case 'tool-call': { - const block = (nodeOwner.node.data as { readonly root: ToolCallBlock }).root + const block = nodeOwner.node.data.root const toolName = 'kind' in block ? block.call?.name ?? '' : block.name const tool = { callId: block.callId, @@ -843,7 +845,7 @@ describe('ChatView', () => { mounted() return () => { unmounted() } }, []) - const root = (node.data as { readonly root: ToolCallBlock }).root + const root = node.data.root return (
{root.callId} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index b7ca73a79f..b8e7cd111e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -34,13 +34,16 @@ afterEach(cleanup) class TestTurnDataStore implements ConversationLocationDataStore { private readonly values = new Map() - get( + get>( key: Key, ): Readonly | undefined { return this.values.get(key) as Readonly | undefined } - set(key: Key, value: ConversationTurnDataMap[Key]): void { + set>( + key: Key, + value: ConversationTurnDataMap[Key], + ): void { this.values.set(key, value) } } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index c46a5bf25c..3133ad4d76 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -44,8 +44,8 @@ describe('ui-settings apply', () => { declare(before.slots) await before.ctx.plugin({ inject: [...inject], apply }).await() expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const [name, spec] of Object.entries(CHILD_SPECS)) { - expect(before.slots.spec(name as never)).toEqual(spec) + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name]) } const after = await bench() @@ -120,8 +120,8 @@ describe('ui-settings apply', () => { declare(b.slots) await Promise.resolve() expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const [name, spec] of Object.entries(CHILD_SPECS)) { - expect(b.slots.spec(name as never)).toEqual(spec) + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name]) } }) @@ -132,8 +132,8 @@ describe('ui-settings apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - for (const name of Object.keys(CHILD_SPECS)) { - expect(b.slots.spec(name as never)).toBeUndefined() + for (const name of Object.keys(CHILD_SPECS) as Array) { + expect(b.slots.spec(name)).toBeUndefined() } }) }) diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 4ed6ecf519..d6c1824c99 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -677,7 +677,10 @@ export class SlotCore { >( options: BaseOptions & { inject?: undefined }, component: C - & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, object, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, + HandleOf>, object, NoInfer, NoInfer + >> & RendersCheck, ): () => void /** @@ -702,7 +705,10 @@ export class SlotCore { >( options: BaseOptions & { inject: (...args: InjectParams) => I }, component: C - & SlotComponent, keyof NoInfer & keyof SlotMap & string, HandleOf>, I, NoInfer, NoInfer>> + & SlotComponent, keyof NoInfer & keyof SlotMap & string, + HandleOf>, I, NoInfer, NoInfer + >> & RendersCheck, ): () => void /* jscpd:ignore-end */ diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 6f495aa318..68736f0b3a 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -274,7 +274,7 @@ export class WorkspaceAnalyzer { constructor(options: WorkspaceAnalyzerOptions) { this.options = { - root: resolve(options.root), + root: realPath(options.root), hostConfig: options.hostConfig ?? 'tsconfig.host.json', clientConfig: options.clientConfig ?? 'tsconfig.client.json', faces: options.faces ?? ['host', 'client'], From 27682b938493cfe9e4580a568f006d561d1302de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:56:34 +0800 Subject: [PATCH 12/20] fix: docs --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 4 +- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.md | 38 +++++++++---------- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.md | 6 ++- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 32 +++++++--------- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 4 +- docs/subsystems/commands.zh.md | 4 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 26 ++++++------- docs/subsystems/core.zh.md | 26 ++++++------- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 14 +++---- docs/subsystems/tools.zh.md | 14 +++---- 18 files changed, 96 insertions(+), 96 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 264b900b74..d19cd86c7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -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: f9768a652c7b0d2d29939fac83b201bc904e3210 -2026-08-09-client-conversation-node-assembly.zh.md: 408d61f73b3b317214cb10b26f522e7680de3f92 +2026-08-09-client-conversation-node-assembly.md: 9952f8fac0f2a3b0fe72cc97ae9a8fb4f96ace08 +2026-08-09-client-conversation-node-assembly.zh.md: f27a82459cdacdb4a2da27cf12b30c93f2dfdf64 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9607dc8d1a..12f3e2861c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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/config-catalog.md -config-catalog.md: 5f3bc744b25bf20d50e88ce75812fe8bd624905a +config-catalog.md: 7278447cf85c2b25f7dd42e10e4b0b1cbd7eb96b config-catalog.zh.md: a7d4252c526d36643a1b9f7aebd627faa60e1c77 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 22e21edc0b..7278447cf8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -917,7 +917,7 @@ Requires: `agents` export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:33`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) ## `@deepseek-ai/dsh-lsp-local` @@ -2325,7 +2325,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:623`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:624`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 41da1ee118..812b490bef 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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/event-producer-consumer.md -event-producer-consumer.md: 5b36725402c0a12c5e2a09c743c2e7cf28d2c14a +event-producer-consumer.md: 05ff62a391a0acb7db9abd2c9ce0c2082da52eaa event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a8263b70c1..05ff62a391 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,20 +8,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:174`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d6f09d6696..24ddaad77c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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/module-graph.md -module-graph.md: e7d6b47e709c3edf4dcdb506bd8f2be0c717aaab +module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 module-graph.zh.md: 255253bcfa9cfad1ab85602dfa9f953a02dfc427 diff --git a/docs/module-graph.md b/docs/module-graph.md index 767ebfcfde..a248ed4fcb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1000,14 +1000,18 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt + pkg_client_ui_conversation --> pkg_agent pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slash pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands pkg_client_ui_conversation --> pkg_compact pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1354,7 +1358,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 19c1f1b280..d814b6ac6e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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/persistence-catalog.md -persistence-catalog.md: 614f6afbeb8fbaadbb43a76782c5a345180b25e7 +persistence-catalog.md: c9aa15cd827d99cee64e7a33db11995ee17f9bf8 persistence-catalog.zh.md: 364912b88c3b2c5efd92a616034be9ef5025ee67 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 76487ca3d4..c9aa15cd82 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -3,7 +3,7 @@ # Session Persistence Event Catalog -Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). +Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md). @@ -101,7 +101,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) ### `approval/*` @@ -212,7 +212,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts) #### `command/run` — log-only @@ -230,7 +230,7 @@ Source: [`packages/interaction/commands/src/index.ts:153`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts) ### `compact/*` @@ -244,7 +244,7 @@ Source: [`packages/interaction/commands/src/index.ts:146`](../packages/interacti 'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -268,7 +268,7 @@ Source: [`packages/compact/compact/src/types.ts:69`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -281,7 +281,7 @@ Source: [`packages/compact/compact/src/types.ts:79`](../packages/compact/compact 'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -333,7 +333,7 @@ Source: [`packages/compact/compact/src/types.ts:21`](../packages/compact/compact Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/compact/compact/src/types.ts:31`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -417,7 +417,7 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- 'llm/retry': LlmRetryEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts) #### `llm/retry-started` — log-only @@ -426,7 +426,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:20`](../packages/llm/llm-retry/src 'llm/retry-started': LlmRetryStartedEventData ``` -Source: [`packages/llm/llm-retry/src/index.ts:22`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) ### `permission/*` @@ -648,12 +648,10 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': CodeDispatchEventData ``` -Types: [CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) - -Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) #### `tool/code-dispatch-start` — log-only @@ -671,12 +669,10 @@ Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/c * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { rootCallId: CallId; parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': CodeDispatchStartEventData ``` -Types: [CallId](subsystems/core.md) - -Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) #### `tool/result` — surface diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 9466165680..32c5f15c15 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -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/commands.md -commands.md: 7f4e4e87d7206be3e46c7076ee5e8ab6a7589f9a -commands.zh.md: 0f8ff31bca5f62824912f53aee8dbc32db4c331b +commands.md: 5e280d659c2a74b3d8ba20f362922e9eb4e0b84c +commands.zh.md: 24f099d1926f7dba45122bdc53c23a4274e3f507 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 7f4e4e87d7..5e280d659c 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index 0f8ff31bca..24f099d192 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:174`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 2fb9c399be..70ceac3b11 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -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/core.md -core.md: ec9a966164f2b706ae16341c628fb8f849eb7382 -core.zh.md: 4de168fd9714a935b4d9b08bbabd52492d1ea1cc +core.md: 27c4359e360223d336cd94695bb45a79f0fd370c +core.zh.md: 4e7519665b6d9efb8075546d93325debd961905d diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ec9a966164..27c4359e36 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -547,7 +547,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) @@ -575,7 +575,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) @@ -597,7 +597,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) @@ -621,7 +621,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) @@ -645,7 +645,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) @@ -666,7 +666,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) @@ -687,7 +687,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) @@ -712,7 +712,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) @@ -738,7 +738,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) @@ -767,7 +767,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) @@ -791,7 +791,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) @@ -814,7 +814,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) @@ -845,7 +845,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 4de168fd97..4e7519665b 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -555,7 +555,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:254`](../../packages/core/agent/src/index.ts) @@ -583,7 +583,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:159`](../../packages/core/agent/src/runtime-types.ts) @@ -605,7 +605,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:168`](../../packages/core/agent/src/runtime-types.ts) @@ -629,7 +629,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:290`](../../packages/core/agent/src/runtime-types.ts) @@ -653,7 +653,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:197`](../../packages/core/agent/src/runtime-types.ts) @@ -674,7 +674,7 @@ One message was discarded from the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:205`](../../packages/core/agent/src/runtime-types.ts) @@ -695,7 +695,7 @@ One message entered the live inbox. Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:186`](../../packages/core/agent/src/runtime-types.ts) @@ -720,7 +720,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Scoped](scope.md) · [UserMessage](session.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:231`](../../packages/core/agent/src/runtime-types.ts) @@ -746,7 +746,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [LlmCallConfig](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:244`](../../packages/core/agent/src/runtime-types.ts) @@ -775,7 +775,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:260`](../../packages/core/agent/src/runtime-types.ts) @@ -799,7 +799,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:217`](../../packages/core/agent/src/runtime-types.ts) @@ -822,7 +822,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:178`](../../packages/core/agent/src/runtime-types.ts) @@ -853,7 +853,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Scoped](scope.md) -Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/runtime-types.ts:278`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index d015cdb638..1384b3888b 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -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/tools.md -tools.md: f06db58b9b52fcb2c325ff813406cd2dc460ea7b -tools.zh.md: cca4bf9faa870d9414d95f785b67e0393e469cdd +tools.md: 83389f39c3188fc251504ed5786249ff1921acae +tools.zh.md: 45cb85b3f2940f84b46bc58406bc8255cbe08be7 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index f06db58b9b..83389f39c3 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index cca4bf9faa..45cb85b3f2 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -554,7 +554,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](scope.md) -Source: [`packages/core/tools/src/index.ts:746`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:747`](../../packages/core/tools/src/index.ts) @@ -579,7 +579,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:192`](../../packages/core/tools/src/index.ts) @@ -605,7 +605,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:174`](../../packages/core/tools/src/index.ts) @@ -629,7 +629,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts) @@ -654,7 +654,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:161`](../../packages/core/tools/src/index.ts) @@ -677,7 +677,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) @@ -698,5 +698,5 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:182`](../../packages/core/tools/src/index.ts) From e11f630fcdbd155dcf94f35d208a8b0516703f23 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:12:54 +0800 Subject: [PATCH 13/20] fix(client): address remaining conversation review feedback --- ...lient-conversation-node-assembly.i18n.yaml | 4 +-- ...08-09-client-conversation-node-assembly.md | 2 ++ ...09-client-conversation-node-assembly.zh.md | 2 ++ docs/subsystems/compaction.i18n.yaml | 4 +-- docs/subsystems/compaction.md | 2 +- docs/subsystems/compaction.zh.md | 2 +- .../src/client/chat/TurnTailNodeView.tsx | 6 ++-- .../src/client/contract/chat-nodes.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 36 ++++++++++++++----- .../src/client/turn-deliverables.ts | 4 ++- vitest.config.ts | 1 + 11 files changed, 47 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index d19cd86c7d..b76951347b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -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: 9952f8fac0f2a3b0fe72cc97ae9a8fb4f96ace08 -2026-08-09-client-conversation-node-assembly.zh.md: f27a82459cdacdb4a2da27cf12b30c93f2dfdf64 +2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172 +2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 9952f8fac0..16a3953906 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -394,6 +394,8 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- 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. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index f27a82459c..0e0fbdf8f3 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -394,6 +394,8 @@ 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。 +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。 diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index fd8dbdd1b5..532750f069 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -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/compaction.md -compaction.md: 1aca48d677a83a488d9590edad3f8a68ca662b70 -compaction.zh.md: e2cfabc0891126d9f2165e47dffcf485f4a8a339 +compaction.md: 11ee6eae797db1c57bf2ffbb90f1f37f47b8400c +compaction.zh.md: a560e47dbb7aa92f0757179534803bbcaaebc6ba diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 1aca48d677..11ee6eae79 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -20,7 +20,7 @@ The lock brackets the **whole** operation: `compact/start` is appended first, th The markers are lock time points, not an exclusive container. An unrelated idle injection can appear between a standalone manual start and end while summarization is pending. The manual path revalidates only its selected positional span, so that injected context survives after the replacement checkpoint. A live unmatched start blocks every entry point; an unmatched start before a newer `session/end-seed` is stale evidence from a prior lifecycle and is ignored. -These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. +These variants are merged inside a `declare module '@deepseek-ai/dsh-session/types'` block, so — unlike the top-level types on the other subsystem pages — they are not pasted as a drift-checked ` ```ts type-equiv ` block (the `verify-type-equiv` extractor matches only top-level declarations by name). The payload table above is the catalog entry; follow the source link for the authoritative shapes. ## `CompactionResult` diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index e2cfabc089..a560e47dbb 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -20,7 +20,7 @@ 这些标记表示锁的时间点,而不是排他的容器。摘要等待期间,不相关的空闲注入可以出现在独立的手动 start 与 end 之间。手动路径只重新验证所选位置 span,因此替换检查点之后仍保留该注入上下文。活动的未匹配 start 会阻塞所有入口点;较新 `session/end-seed` 之前的未匹配 start 是先前生命周期留下的陈旧证据,会被忽略。 -这些变体在 `declare module '@deepseek-ai/dsh-session'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 +这些变体在 `declare module '@deepseek-ai/dsh-session/types'` 块内合并,因此——与其他子系统页面上的顶层类型不同——它们不以漂移检查的 ` ```ts type-equiv ` 块粘贴(`verify-type-equiv` 提取器只按名称匹配顶层声明)。上方的载荷表即为目录条目;权威形状请循源码链接查看。 ## `CompactionResult` diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx index bf48fc75c6..444389e6ea 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -9,9 +9,11 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<' /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlotChain, t, + node, openFile, forkAt, renderSlotChain, t, useSession, }: TurnTailNodeViewProps) { const data = node.data + const hasLaterChatNode = useSession(snapshot => + snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined @@ -34,7 +36,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ tokensPerSecond={data.tokensPerSecond} clock="end" onBranch={() => { forkAt(closing.finalNode.seq) }} - branchUnavailable={data.branchUnavailable} + branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts index 787f391006..c057d15d1b 100644 --- a/packages/client/ui-conversation/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-conversation/src/client/contract/chat-nodes.ts @@ -57,7 +57,7 @@ export interface TurnTailChatData { readonly time: number /** Last finalized content-bearing Assistant in this Turn. */ readonly closing: FinalAssistantChatData | null - /** Whether later Assistant/Step material makes the closing seq non-tail. */ + /** Whether non-rendered later evidence makes the closing seq non-tail. */ readonly branchUnavailable: boolean readonly ttftMs?: number readonly tokensPerSecond?: number diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index ca6223826e..6b2072ddfb 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -181,14 +181,12 @@ function makeHarness(init?: Partial) { if (key !== 'conversation.chat.node') return opts?.fallback ?? null const nodeOwner = owner as RoutedChatNodeOwner const nodeKey = opts?.hookContext as string | undefined - const useTurnData = ((dataKey: string) => { - return props.useSession((snapshot) => { - const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location - return location?.kind === 'turn' || location?.kind === 'step' - ? location.turn.data.get(dataKey as never) - : undefined - }) - }) as UseChatNodeTurnData + const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => { + const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location + return location?.kind === 'turn' || location?.kind === 'step' + ? location.turn.data.get(dataKey) + : undefined + }) const nodeProps = (): ChatNodeViewProps => ( { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps ) @@ -722,6 +720,28 @@ describe('ChatView', () => { expect(h.forkAt.mock.calls).toEqual([[2]]) }) + it('disables fork when the indexed Turn has a later steering Node', () => { + const base = chatSnapshotFixture({ + nodes: [user(1, 'question'), assistant(2, 'answer')], + turnEnds: new Map([[1, 4]]), + }) + const chat = { + ...base, + locations: { + getTurn: (turn: number) => turn === 1 + ? [...base.locations.getTurn(turn), 'fixture:steering:later'] + : base.locations.getTurn(turn), + getStep: (turn: number, step: number) => base.locations.getStep(turn, step), + }, + } + const h = makeHarness({ chat }) + const view = render() + const branch = view.getByRole('button', { name: '在新对话中分支' }) + expect(branch.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(branch) + expect(h.forkAt).not.toHaveBeenCalled() + }) + it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 0227b34261..9151f88869 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -36,7 +36,9 @@ interface DeliverablesState extends DeliverablesTurnData { * Paths a call view reports having created or changed, by render intent rather * than tool name: a diff card, or a generic card whose kind is `edit` (the * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. + * nothing to open — a read looked, a delete removed, a terminal ran. Only + * root call views enter this Turn accumulator; nested Code Mode dispatches + * preserve the pre-assembly behavior and do not contribute independently. */ function producedPaths(view: ToolResultNode['callView']): readonly string[] { if (view === null) return [] diff --git a/vitest.config.ts b/vitest.config.ts index e07ae43ec3..33512f4898 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -211,6 +211,7 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/index.ts', 'packages/client/test-runtime/src/translate.ts', 'packages/client/ui-primitives/src/JsonTree.tsx', + 'packages/client/ui-deliverables/src/client/turn-deliverables.ts', // Typert generator: correctness is pinned by its fixture suites and // the byte-for-byte catalog reproduction test; per-file coverage // would put whole-workspace compiler analysis under v8 From 126ad5bb02147a5d584349fbce4111e274cea8ff Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:36:15 +0800 Subject: [PATCH 14/20] docs: development --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 3 +- docs/architecture.zh.md | 3 +- .../adding-a-conversation-node.i18n.yaml | 6 + docs/cookbook/adding-a-conversation-node.md | 232 ++++++++++++++++ .../cookbook/adding-a-conversation-node.zh.md | 232 ++++++++++++++++ docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 3 +- docs/cookbook/extension-cookbook.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.zh.md | 44 +-- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.zh.md | 253 +++++++++--------- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.zh.md | 66 ++--- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 + docs/subsystems/session.zh.md | 2 + packages/client/AGENTS.md | 6 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + scripts/doc-budgets.manifest.json | 2 +- website/docs.ts | 8 + 27 files changed, 698 insertions(+), 201 deletions(-) create mode 100644 docs/cookbook/adding-a-conversation-node.i18n.yaml create mode 100644 docs/cookbook/adding-a-conversation-node.md create mode 100644 docs/cookbook/adding-a-conversation-node.zh.md diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index dff7978387..fe32a219de 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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/architecture.md -architecture.md: 506283bfc651d38fe4bccd50fb8a86143d2b41d8 -architecture.zh.md: ea48af8131ebbdc70a45e403dfddc78dfcfb6f47 +architecture.md: 771d7489ee338db56362a6ccc133b1ebf8cdc7c0 +architecture.zh.md: ca7c4fe2a463e01a8e14e63a53f13aea45cbfa16 diff --git a/docs/architecture.md b/docs/architecture.md index 506283bfc6..771d7489ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,10 +183,11 @@ New behavior attaches to a documented extension point; a loop change updates thi | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the stop boundary | | Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider | | Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` | | Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). +[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index ea48af8131..ca7c4fe2a4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -183,10 +183,11 @@ idle inject: | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止边界 | | 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 | | 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 | | fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | | 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) | -[扩展实操手册(cookbook)](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 +[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml new file mode 100644 index 0000000000..aa268e9461 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/cookbook/adding-a-conversation-node.md +adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4 +adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562 diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md new file mode 100644 index 0000000000..ea4ec73eb1 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.md @@ -0,0 +1,232 @@ +# Add a Web Client conversation node + +English | [中文](adding-a-conversation-node.zh.md) + +This tutorial adds one business-owned row to the Web Client Chat view. The finished plugin correlates a durable Session event family into one Context, incrementally builds business State, publishes typed Step data, and renders a keyed Chat Node without scanning the Session window or other rendered nodes. It assumes the Host already records the events and the client plugin is composed into the Web bundle; external Host-side UIs and additional view targets such as Trajectory are outside this tutorial. + +The [Conversation Node assembly decision](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md) owns the rationale and complete engine model. This guide covers the implementation path. + +## 1. Design a replayable event family + +Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. + +For a review job, the event contract could be: + +| Event | Role | Required durable facts | +|---|---|---| +| `review/start` | unique start | `reviewId`, Turn/Step coordinates, title | +| `review/progress` | update | the same `reviewId`, coordinates, replayable progress | +| `review/end` | update | the same `reviewId`, coordinates, final summary | + +Use the producer-owned branded id type across the process boundary. Put the `SessionEventMap` merge and payload types on the producer's type-only export, then import that export for side effects from the client package. Each `(kind, id)` may have at most one start event. A single-event business can use the event's stable identity, such as `event.seq`, as its Definition-local id. + +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. + +## 2. Implement the 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. + +```ts ignore-check +import { createElement } from 'react' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { + ClientContext, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' + +type ReviewId = Branded<'ReviewId'> + +interface ReviewStartData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly title: string +} + +interface ReviewProgressData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly completed: number +} + +interface ReviewEndData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly summary: string +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one durable review job. + * @mode emit + * @param data - stable identity, location, and initial display state. + */ + 'review/start': ReviewStartData + /** + * Records replayable progress for one review job. + * @mode emit + * @param data - stable identity, location, and latest progress. + */ + 'review/progress': ReviewProgressData + /** + * Closes one review job with its final summary. + * @mode emit + * @param data - stable identity, location, and final display state. + */ + 'review/end': ReviewEndData + } +} + +interface ReviewChatData { + readonly title: string + readonly completed: number + readonly status: 'running' | 'completed' + readonly summary?: string +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + 'review-job': ReviewChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + 'review-job': ReviewChatData + } +} + +interface ReviewState extends ReviewChatData { + readonly turn: number + readonly step: number +} + +function locationOf(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +function viewData(state: ReviewState): ReviewChatData { + return { + title: state.title, + completed: state.completed, + status: state.status, + ...state.summary === undefined ? {} : { summary: state.summary }, + } +} + +const reviewDefinition: ConversationNodeDefinition = { + kind: 'review-job', + match: (event) => { + if (event.type === 'review/start') { + return { id: String(event.data.reviewId), role: 'start' } + } + if (event.type === 'review/progress' || event.type === 'review/end') { + return { id: String(event.data.reviewId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'review/start') throw new Error('review-job requires review/start') + return { + turn: match.event.data.turn, + step: match.event.data.step, + title: match.event.data.title, + completed: 0, + status: 'running', + } + }, + update: (context, match) => { + if (match.event.type === 'review/progress') { + return { ...context.state, completed: match.event.data.completed } + } + if (match.event.type === 'review/end') { + return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary } + } + return context.state + }, + publication: match => match.event.type === 'review/progress' + ? 'animation-frame' + : 'immediate', + buildLocationData: (context, scope) => { + if (scope !== 'step' || context.state === undefined) return null + return { + kind: 'step', + turn: context.state.turn, + step: context.state.step, + key: 'review-job', + value: viewData(context.state), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return { + key: context.key, + kind: 'review-job', + id: context.id, + target: 'chat', + anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0, + location: locationOf(context), + visibility: 'visible', + data: viewData(context.state), + } + }, +} + +function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { + const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%` + return createElement('p', null, text) +} + +export const inject = ['conversationEvents', 'slots'] + +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(reviewDefinition) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'review-job', + }, ReviewNodeView)) +} +``` + +`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. + +`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`. + +`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. + +## 3. Query an earlier business Context only at start + +Some Definitions need the latest earlier State of another business kind. `start` receives a `ConversationContextReader`; call `reader.previous(kind)` there instead of accepting a Context collection or scanning events. The reader returns the nearest started Context before the current start `seq` as read-only data. + +The assembler records that dependency. If an older prepend later supplies a nearer predecessor, closes a previously unknown window gap, or revises the predecessor State, it reruns the dependent Context from `start` and replays its updates in ascending `seq`. The queried Definition remains responsible for writing useful State; the reader exposes no business-specific query methods and grants no mutation authority over another Context. + +## 4. Understand the three ingestion 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. + +| 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 | + +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. + +`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. + +## 5. Verify replay, pagination, and rendering + +Add focused tests that establish these outcomes: + +1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`. +2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace. +3. Initial history followed by live append produces the same result as replaying the combined window. +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. + +Use [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-conversation/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. diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md new file mode 100644 index 0000000000..4b9a8049e2 --- /dev/null +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -0,0 +1,232 @@ +# 添加 Web Client Conversation Node + +[English](adding-a-conversation-node.md) | 中文 + +本教程为 Web Client Chat 视图添加一行由业务自行拥有的内容。完成后的插件会把一个持久 Session 事件族关联成一个 Context,增量构造业务 State,发布类型化 Step 数据,再渲染 keyed Chat Node;整个过程不扫描 Session 窗口或其他已渲染节点。本教程假设 Host 已经记录这些事件,且该 Client 插件已组装进 Web bundle;Host 侧外部 UI 和 Trajectory 等额外视图目标不在本文范围内。 + +[Conversation Node 组装决策](../../.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md)记录完整的引擎模型和设计理由;本文只说明实现路径。 + +## 1. 设计可回放的事件族 + +编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 + +以一个 review job 为例,事件约定可以是: + +| 事件 | 角色 | 必须持久化的事实 | +|---|---|---| +| `review/start` | 唯一 start | `reviewId`、Turn/Step 坐标、标题 | +| `review/progress` | update | 相同的 `reviewId`、坐标、可回放进度 | +| `review/end` | update | 相同的 `reviewId`、坐标、最终摘要 | + +跨进程边界使用生产方拥有的 branded id 类型。把 `SessionEventMap` 合并和 payload 类型放在生产方的纯类型导出中,再由 Client 包通过仅类型副作用导入该导出。每个 `(kind, id)` 最多只能有一条 start 事件。单事件业务可以把事件自身的稳定身份(例如 `event.seq`)作为 Definition 内部 id。 + +系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。 + +## 2. 实现 Definition 与类型化 Chat payload + +为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 `SessionEventMap` 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。 + +```ts ignore-check +import { createElement } from 'react' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { + ClientContext, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' + +type ReviewId = Branded<'ReviewId'> + +interface ReviewStartData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly title: string +} + +interface ReviewProgressData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly completed: number +} + +interface ReviewEndData { + readonly reviewId: ReviewId + readonly turn: number + readonly step: number + readonly summary: string +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one durable review job. + * @mode emit + * @param data - stable identity, location, and initial display state. + */ + 'review/start': ReviewStartData + /** + * Records replayable progress for one review job. + * @mode emit + * @param data - stable identity, location, and latest progress. + */ + 'review/progress': ReviewProgressData + /** + * Closes one review job with its final summary. + * @mode emit + * @param data - stable identity, location, and final display state. + */ + 'review/end': ReviewEndData + } +} + +interface ReviewChatData { + readonly title: string + readonly completed: number + readonly status: 'running' | 'completed' + readonly summary?: string +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + 'review-job': ReviewChatData + } +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationStepDataMap { + 'review-job': ReviewChatData + } +} + +interface ReviewState extends ReviewChatData { + readonly turn: number + readonly step: number +} + +function locationOf(context: ConversationNodeContext): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +function viewData(state: ReviewState): ReviewChatData { + return { + title: state.title, + completed: state.completed, + status: state.status, + ...state.summary === undefined ? {} : { summary: state.summary }, + } +} + +const reviewDefinition: ConversationNodeDefinition = { + kind: 'review-job', + match: (event) => { + if (event.type === 'review/start') { + return { id: String(event.data.reviewId), role: 'start' } + } + if (event.type === 'review/progress' || event.type === 'review/end') { + return { id: String(event.data.reviewId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'review/start') throw new Error('review-job requires review/start') + return { + turn: match.event.data.turn, + step: match.event.data.step, + title: match.event.data.title, + completed: 0, + status: 'running', + } + }, + update: (context, match) => { + if (match.event.type === 'review/progress') { + return { ...context.state, completed: match.event.data.completed } + } + if (match.event.type === 'review/end') { + return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary } + } + return context.state + }, + publication: match => match.event.type === 'review/progress' + ? 'animation-frame' + : 'immediate', + buildLocationData: (context, scope) => { + if (scope !== 'step' || context.state === undefined) return null + return { + kind: 'step', + turn: context.state.turn, + step: context.state.step, + key: 'review-job', + value: viewData(context.state), + } + }, + buildViewNode: (context, target) => { + if (target !== 'chat' || context.state === undefined) return null + return { + key: context.key, + kind: 'review-job', + id: context.id, + target: 'chat', + anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0, + location: locationOf(context), + visibility: 'visible', + data: viewData(context.state), + } + }, +} + +function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { + const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%` + return createElement('p', null, text) +} + +export const inject = ['conversationEvents', 'slots'] + +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(reviewDefinition) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'review-job', + }, ReviewNodeView)) +} +``` + +`match(event)` 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context,再调用一次 `start`,或把当前 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`。 + +`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 + +## 3. 只在 start 时查询更早的业务 Context + +有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。`start` 会收到 `ConversationContextReader`;应在这里调用 `reader.previous(kind)`,不要接收 Context 集合或扫描事件。Reader 返回当前 start `seq` 之前最近一个已启动 Context 的只读数据。 + +Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 `start` 重新运行依赖方 Context,并按 `seq` 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。 + +## 4. 理解三条摄入路径 + +历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 `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 会激活已收集 update;Location 或前序依赖变化也可能重跑 Context | +| append 一条实时事件 | 每个 Definition 各调用一次 `match`,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 `update` 并请求一次发布;不扫描已有 Context | + +注册 `D` 个 Definition 时,一条新事件会进行 `D` 次仅当前事件匹配;命中后的 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;该选项只合并视图发布频率。 + +## 5. 验证回放、分页与渲染 + +添加聚焦测试,证明以下结果: + +1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。 +2. 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。 +3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。 +4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。 +5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 +6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 + +流式与中断处理可参考 [`packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-conversation/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 0509a67549..7eaa702a9d 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -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/cookbook/extension-cookbook.md -extension-cookbook.md: 025a1b6ecd11593b5d6be9d0f64e57ac8b5e3139 -extension-cookbook.zh.md: f838a281fabdba473b72b27f7b14274d9aa97528 +extension-cookbook.md: 5d9312f2f5cf840100b12045bde1829b342e580d +extension-cookbook.zh.md: 29bb57c558a0ff9413619d0bb2e8bb5e6b70da56 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 025a1b6ecd..5d9312f2f5 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -34,7 +34,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an ## A UI plugin -A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. +A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md). ```ts import type { Context } from 'cordis' @@ -123,6 +123,7 @@ Every product feature maps to a listener on a documented extension point — the | Memory | section provider + tool | | Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy | | UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` | +| Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer | | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` | | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) | | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index f838a281fa..29bb57c558 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -34,7 +34,7 @@ export function apply(ctx: Context) { ## UI 插件 -UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。 +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.md)。 ```ts import type { Context } from 'cordis' @@ -123,6 +123,7 @@ export function apply(ctx: Context) { | 记忆 | section 提供方 + 工具 | | 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | | UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` | +| Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer | | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | | 插件热重载 | 每个注册都是一个 `ctx.effect` → 随仓库提供的 HMR(热模块替换)直接生效 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 812b490bef..89e1b03d35 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md event-producer-consumer.md: 05ff62a391a0acb7db9abd2c9ce0c2082da52eaa -event-producer-consumer.zh.md: 2d4c805f9a5d3b0531ee59eebe9e6564347f0a00 +event-producer-consumer.zh.md: 7eafe5f2880cf9f0e6f2e7eb6c299730926f15ed diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2d4c805f9a..7eafe5f288 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,20 +10,20 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:172`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -46,12 +46,12 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:192`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:174`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -59,9 +59,9 @@ | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -## 包源码中出现的非 harness 或未声明事件字符串 +## Non-harness or undeclared event strings seen in package source -| 事件字符串 | 派发方 | 监听方 | +| Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | @@ -80,4 +80,4 @@ | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | -维护模式:英文源文件是生成内容,Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析;本中文文件作为经评审对侧通过双语配对维护。 +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 24ddaad77c..61e55cd997 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md module-graph.md: a248ed4fcb8abc17ffc6982d2733b3c3c2a2a635 -module-graph.zh.md: 255253bcfa9cfad1ab85602dfa9f953a02dfc427 +module-graph.zh.md: 28185c255ffa18f3ebc02f177d20594af3356164 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 255253bcfa..28185c255f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -438,9 +438,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill - pkg_compact --> pkg_invariants - pkg_compact --> pkg_llm - pkg_compact --> pkg_session pkg_web_fetch_local --> pkg_invariants pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web @@ -504,15 +501,11 @@ flowchart TD pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout - pkg_token_meter --> pkg_compact - pkg_token_meter --> pkg_invariants - pkg_token_meter --> pkg_llm - pkg_token_meter --> pkg_session - pkg_token_meter --> pkg_session_projection pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_invariants pkg_agent_default_model --> pkg_llm @@ -552,10 +545,6 @@ flowchart TD pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_invariants pkg_hook_protocol --> pkg_session - pkg_llm_replay --> pkg_compact - pkg_llm_replay --> pkg_invariants - pkg_llm_replay --> pkg_llm - pkg_llm_replay --> pkg_session pkg_loader_smoke --> pkg_agent pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm @@ -676,14 +665,11 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_command_compact --> pkg_commands - pkg_command_compact --> pkg_compact - pkg_command_compact --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_compact - pkg_compact_tool_result_prune --> pkg_invariants - pkg_compact_tool_result_prune --> pkg_llm - pkg_compact_tool_result_prune --> pkg_session - pkg_compact_tool_result_prune --> pkg_token_meter + pkg_compact --> pkg_brand + pkg_compact --> pkg_commands + pkg_compact --> pkg_invariants + pkg_compact --> pkg_llm + pkg_compact --> pkg_session pkg_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -705,13 +691,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_token_meter pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session @@ -744,6 +723,11 @@ flowchart TD pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks pkg_tasks_local --> pkg_timeout + pkg_token_meter --> pkg_compact + pkg_token_meter --> pkg_invariants + pkg_token_meter --> pkg_llm + pkg_token_meter --> pkg_session + pkg_token_meter --> pkg_session_projection pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -792,13 +776,9 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools - pkg_compact_basic --> pkg_agent - pkg_compact_basic --> pkg_compact - pkg_compact_basic --> pkg_compact_tool_result_prune - pkg_compact_basic --> pkg_invariants - pkg_compact_basic --> pkg_llm - pkg_compact_basic --> pkg_session - pkg_compact_basic --> pkg_token_meter + pkg_command_compact --> pkg_commands + pkg_command_compact --> pkg_compact + pkg_command_compact --> pkg_invariants pkg_subagent --> pkg_agent pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants @@ -859,33 +839,10 @@ flowchart TD pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt pkg_agent_loop_testkit --> pkg_tools - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_locale - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants - pkg_client_ui_deliverables --> pkg_client_locale - pkg_client_ui_deliverables --> pkg_client_runtime - pkg_client_ui_deliverables --> pkg_client_ui_conversation - pkg_client_ui_deliverables --> pkg_client_ui_slots - pkg_client_ui_deliverables --> pkg_invariants - pkg_client_ui_goal --> pkg_api_remotes - pkg_client_ui_goal --> pkg_client_locale - pkg_client_ui_goal --> pkg_client_runtime - pkg_client_ui_goal --> pkg_client_ui_conversation - pkg_client_ui_goal --> pkg_client_ui_primitives - pkg_client_ui_goal --> pkg_client_ui_slots - pkg_client_ui_goal --> pkg_goal - pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_tool --> pkg_client_locale - pkg_client_ui_tool --> pkg_client_runtime - pkg_client_ui_tool --> pkg_client_ui_conversation - pkg_client_ui_tool --> pkg_client_ui_primitives - pkg_client_ui_tool --> pkg_client_ui_slots - pkg_client_ui_tool --> pkg_invariants + pkg_llm_replay --> pkg_compact + pkg_llm_replay --> pkg_invariants + pkg_llm_replay --> pkg_llm + pkg_llm_replay --> pkg_session pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -992,6 +949,11 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tasks pkg_tool_pwsh --> pkg_tools + pkg_compact_tool_result_prune --> pkg_compact + pkg_compact_tool_result_prune --> pkg_invariants + pkg_compact_tool_result_prune --> pkg_llm + pkg_compact_tool_result_prune --> pkg_session + pkg_compact_tool_result_prune --> pkg_token_meter pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_invariants pkg_subagent_acp --> pkg_llm @@ -1040,50 +1002,18 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_locale - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants - pkg_client_ui_permission --> pkg_client_connection - pkg_client_ui_permission --> pkg_client_locale - pkg_client_ui_permission --> pkg_client_runtime - pkg_client_ui_permission --> pkg_client_schema_form - pkg_client_ui_permission --> pkg_client_ui_command - pkg_client_ui_permission --> pkg_client_ui_primitives - pkg_client_ui_permission --> pkg_client_ui_slash - pkg_client_ui_permission --> pkg_client_ui_slots - pkg_client_ui_permission --> pkg_invariants - pkg_client_ui_permission --> pkg_permission - pkg_client_ui_plan --> pkg_client_connection - pkg_client_ui_plan --> pkg_client_locale - pkg_client_ui_plan --> pkg_client_runtime - pkg_client_ui_plan --> pkg_client_ui_conversation - pkg_client_ui_plan --> pkg_client_ui_primitives - pkg_client_ui_plan --> pkg_client_ui_slots - pkg_client_ui_plan --> pkg_invariants - pkg_client_ui_plan --> pkg_plan_mode - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_locale - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_primitives - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_client_ui_tool - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants - pkg_client_ui_subagent --> pkg_subagent - pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1107,6 +1037,14 @@ flowchart TD pkg_workflow_workerthread --> pkg_subagent pkg_workflow_workerthread --> pkg_tools pkg_workflow_workerthread --> pkg_workflow + pkg_compact_basic --> pkg_agent + pkg_compact_basic --> pkg_commands + pkg_compact_basic --> pkg_compact + pkg_compact_basic --> pkg_compact_tool_result_prune + pkg_compact_basic --> pkg_invariants + pkg_compact_basic --> pkg_llm + pkg_compact_basic --> pkg_session + pkg_compact_basic --> pkg_token_meter pkg_subagent_codex --> pkg_invariants pkg_subagent_codex --> pkg_llm pkg_subagent_codex --> pkg_sdk_protocol @@ -1122,6 +1060,50 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_locale + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_goal --> pkg_api_remotes + pkg_client_ui_goal --> pkg_client_locale + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_locale + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_primitives + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_tool --> pkg_client_locale + pkg_client_ui_tool --> pkg_client_runtime + pkg_client_ui_tool --> pkg_client_ui_conversation + pkg_client_ui_tool --> pkg_client_ui_primitives + pkg_client_ui_tool --> pkg_client_ui_slots + pkg_client_ui_tool --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1163,6 +1145,33 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_locale + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_locale + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_primitives + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_client_ui_tool + pkg_client_ui_skill --> pkg_invariants pkg_acp_demo --> pkg_acp pkg_acp_demo --> pkg_agent_spine_demo pkg_acp_demo --> pkg_app_boot @@ -1240,7 +1249,6 @@ flowchart TD | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | -| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) | @@ -1257,8 +1265,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1269,7 +1276,6 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1297,19 +1303,18 @@ flowchart TD | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | +| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | @@ -1317,7 +1322,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | @@ -1327,10 +1332,7 @@ flowchart TD | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1349,6 +1351,7 @@ flowchart TD | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1357,20 +1360,26 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/scaffold/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d814b6ac6e..e676227869 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md persistence-catalog.md: c9aa15cd827d99cee64e7a33db11995ee17f9bf8 -persistence-catalog.zh.md: 364912b88c3b2c5efd92a616034be9ef5025ee67 +persistence-catalog.zh.md: d8743a1b0f26f2a4cf50aed42eb68f6c6db1ba13 diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 364912b88c..d8743a1b0f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5,7 +5,7 @@ [English](persistence-catalog.md) | 中文 -会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)(surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。 +会话持久事件日志中可能出现的所有事件类型:完整持久化的 `SessionEvent` 信封,以及可通过合并扩展的 `SessionEventMap` 中的每个成员,包括 `@deepseek-ai/dsh-session` 所属的词汇和本仓库中每个插件对 `@deepseek-ai/dsh-session/types` 的声明合并,并附有源 JSDoc、完整 payload 声明、surface 标记和声明位置。本文档是 [session.md](subsystems/session.md)(surface 排序与 `deriveMessages()` 投影)、[persistence.md](subsystems/persistence.md)(如何让日志持久化)和 [session.md](subsystems/session.md#cordis-surface) 中生成区域(实时总线接线;日志事件**不是** cordis 事件,它通过唯一一次 `session/event` emit 到达监听器)的补充。 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。 @@ -103,7 +103,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) ### `approval/*` @@ -214,7 +214,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/index.ts:151`](../packages/interaction/commands/src/index.ts) +来源:[`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts) #### `command/run` — log-only @@ -232,7 +232,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/index.ts:144`](../packages/interaction/commands/src/index.ts) +来源:[`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts) ### `compact/*` @@ -243,10 +243,10 @@ export type SessionEvent = { * Marks the end of a compaction — log-only, releases the lock. Its owner * matches `compact/start`; `error` records an unsuccessful attempt. */ -'compact/end': { turn: number | null; error?: string } +'compact/end': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null; error?: string } ``` -来源:[`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:71`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -270,7 +270,7 @@ export type SessionEvent = { } ``` -来源:[`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:81`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -280,10 +280,10 @@ export type SessionEvent = { * `compact/end`. A numbered owner is strictly enclosed by that open turn; * `null` identifies a standalone manual transaction between turns. */ -'compact/start': { turn: number | null } +'compact/start': { compactionId: CompactionId; sourceCommandId?: CommandId; turn: number | null } ``` -来源:[`packages/compact/compact/src/types.ts:19`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -298,6 +298,8 @@ export type SessionEvent = { * before it (`compact/prune` documents the shared protocol). */ 'compact/summary': { + compactionId: CompactionId + sourceCommandId?: CommandId summary: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] @@ -333,7 +335,7 @@ export type SessionEvent = { 类型:[ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts) +来源:[`packages/compact/compact/src/types.ts:33`](../packages/compact/compact/src/types.ts) ### `feedback/*` @@ -414,29 +416,19 @@ export type SessionEvent = { ```ts persistence-catalog /** Durable, non-surface record of one provider-routed retry scheduled after a failed request attempt. */ -'llm/retry': { - turn: number - step: number - provider: string - mode: 'normal' - policyKey: string - retry: number - maxRetries: number - delayMs: number - failure: LlmFailure -} | { - turn: number - step: number - provider: string - mode: 'always' - policyKey: string - retry: number - delayMs: number - failure: LlmFailure -} +'llm/retry': LlmRetryEventData ``` -来源:[`packages/llm/llm-retry/src/index.ts:17`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/types.ts:9`](../packages/llm/llm-retry/src/types.ts) + +#### `llm/retry-started` — log-only + +```ts persistence-catalog +/** Durable transition written after a retry wait succeeds and before the next request attempt starts. */ +'llm/retry-started': LlmRetryStartedEventData +``` + +来源:[`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src/types.ts) ### `permission/*` @@ -658,12 +650,10 @@ export type SessionEvent = { * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] } +'tool/code-dispatch': CodeDispatchEventData ``` -类型:[CallId](subsystems/core.md) · [ContentBlock](subsystems/core.md) - -来源:[`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) #### `tool/code-dispatch-start` — log-only @@ -681,12 +671,10 @@ export type SessionEvent = { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown } +'tool/code-dispatch-start': CodeDispatchStartEventData ``` -类型:[CallId](subsystems/core.md) - -来源:[`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) #### `tool/result` — surface diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 04f3cc5394..4c8cad1880 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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/session.md -session.md: a5162c77d510c5ab66fa2dd6789693e1449970fb -session.zh.md: 989a3f5368aa0b0475dec4272fde724fd4a98230 +session.md: e5eb78908ba4b7ebfc398d5c735969a5a8937374 +session.zh.md: 51729999a831c94e87e36ec1c4705201f9c8360f diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index a5162c77d5..e5eb78908b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -580,6 +580,8 @@ Activity ordering excludes the boundary through `lastActivityTime(events)`: pick A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history). Their owner decides whether they belong to an open execution turn or may stand between turns, and enforces any relation in its own invariant companion. The generated [persistence log event catalog](../persistence-catalog.md) enumerates every core and plugin-contributed event with its payload, surface badge, and declaration site; the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md). +When several events in one plugin-owned family assemble into one Web Client Conversation Node, every start, update, result, resource, or interruption event in that family carries or independently derives the same stable business id. This requirement applies to correlated Node families, not to every Session event; it lets the client group each event without guessing from adjacency or scanning history. See the [Conversation Node cookbook](../cookbook/adding-a-conversation-node.md). + The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record because it runs before turn 1; its context remains pending in the inbox until a waking delivery opens a turn (see [the hook-bridges Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md)). ## Durability contract diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 989a3f5368..51729999a8 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -584,6 +584,8 @@ interface TurnEndReasonMap { 插件可以通过 declaration merging 添加额外的 `SessionEventMap` 类型。这些是**仅日志**事件:不是 `SurfaceEventType`(不携带 `surfaceOp`,不参与派生历史)。事件所有方决定它们属于一个开放的执行轮次,还是可以独立位于轮次之间,并在自己的不变量配套插件中强制所需关系。生成的[持久化日志事件目录](../persistence-catalog.md)会列出每个核心或插件贡献的事件,以及其 payload、surface 标记和声明位置;压缩 seam 的 `compact/*` 语义在 [compaction.md](compaction.md) 中讨论。 +如果同一个插件事件族中的多条事件要组装成一个 Web Client Conversation Node,该事件族中的每条 start、update、result、resource 或 interruption 事件都必须携带或独立推导出同一个稳定业务 id。此要求只约束需要关联的 Node 事件族,并不要求每条 Session 事件都有业务 id;Client 因此无须根据相邻关系猜测归属,也无须扫描历史。参见 [Conversation Node 实操手册](../cookbook/adding-a-conversation-node.md)。 + 钩子桥接层的 `hook/invoked` / `hook/result` 对(来自 `@deepseek-ai/dsh-hook-protocol`)通过 `handlerId` 关联。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 在 loop 已打开的轮次内触发,因此其 `hook/*` 记录天然位于轮次之内。`SessionStart` 不生成 `hook/*` 记录,因为它在轮次 1 之前运行;其上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次(见[钩子桥接 Agent Note](../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md))。 ## 持久性约定 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 78704f52fa..9626e96e74 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -54,6 +54,12 @@ Non-negotiables across the layers: - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). +## 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 Node cookbook](../../docs/cookbook/adding-a-conversation-node.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`. +- 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) One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index d47266cd8a..139474af19 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: 46a383f22032c8da7e4bb9b5fb445980661c670f -README.zh.md: 73578a8f249472abfdd7c539e3eaee6de767ad60 +README.md: df3689176cf059f46004fa7ecb31ab7c326ea0bc +README.zh.md: 9afe9773ff2a6a320bdf66978877678428c46118 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 46a383f220..df3689176c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -38,6 +38,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and Each `Session` gives its contiguous event window to a `ConversationNodeAssembler`. Plugins register business Definitions that map one event to a stable `{kind, id}`, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair. +Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. + `ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 73578a8f24..9afe9773ff 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -38,6 +38,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 每个 `Session` 都把连续事件窗口交给 `ConversationNodeAssembler`。插件注册业务 Definition,把单个事件映射为稳定的 `{kind, id}`,在唯一 start 事件处创建 State,折叠有关联的 update,再为已注册的视图目标构造最终节点。Assembler 负责 Context 索引、只读前序 Context 查询,以及引用稳定的 Turn/Step Location 索引。实时 append 只对每个 Definition 求值一次,并且只更新命中的 Context;加载更早分页时保留已有 Context 与节点身份,只匹配新 prepend 的事件,并重放前序依赖或 Location 事实发生变化的 Context。完整替换仅用于 open、resync 和 gap repair。 +Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 + `ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bb15c65029..1238e136d7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 3570e814609af0bc4e7d048a55205af756fae843 -README.zh.md: e37f867c72d83b47924e87261e6ee5ff8b374a6f +README.md: d894d3e578f065e9cb2c45676224efd1f4f76fa8 +README.zh.md: ec99a8d7daa593cdf3014f292a095fb2fd6f39d3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3570e81460..d894d3e578 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,6 +12,8 @@ Another plugin can make one session's composer inert through `ctx.conversation.b The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. +Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. + Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e37f867c72..ec99a8d7da 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,6 +12,8 @@ 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 +Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 + 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index b5c000a714..2626cec2da 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 1782, "docs/AGENTS.md": 1320, - "docs/architecture.md": 2160, + "docs/architecture.md": 2174, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 1150, diff --git a/website/docs.ts b/website/docs.ts index 1e97d88e24..a8d07a59c2 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -396,6 +396,14 @@ const reference = [ section: { root: '开发手册', en: 'Cookbook' }, order, }))), + ...pairedPages([{ + source: 'docs/cookbook/adding-a-conversation-node.md', + route: 'reference/cookbook/adding-a-conversation-node.md', + label: { root: '新增 Conversation Node', en: 'Adding a Conversation Node' }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '开发手册', en: 'Cookbook' }, + order: 4, + }]), ] /** Every canonical page published by the documentation website. */ From fcbc97a88dd7f22aa5f71342d19248485e7cc58f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:08:37 +0800 Subject: [PATCH 15/20] fix(client): address conversation assembly review --- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/conversation-nodes/inbox.ts | 4 +- .../src/client/conversation-nodes/retry.ts | 1 + .../conversation-node-definitions.spec.ts | 32 ++++++++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../tests/produced-files.spec.tsx | 52 ++++++++++++++++++- packages/compact/compact/src/types.ts | 4 +- packages/llm/llm-retry/src/types.ts | 4 +- .../llm/llm-retry/tests/invariant.spec.ts | 2 +- .../llm/llm-retry/tests/persistence.spec.ts | 2 +- .../context-breakdown-projection.spec.ts | 2 +- .../tests/token-usage-projection.spec.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- tsconfig.base.json | 1 + vitest.config.ts | 1 - 19 files changed, 102 insertions(+), 23 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 139474af19..734b158e66 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: df3689176cf059f46004fa7ecb31ab7c326ea0bc -README.zh.md: 9afe9773ff2a6a320bdf66978877678428c46118 +README.md: d4031c2e3b7730bdb0075ac84c50ad3e46ce63a2 +README.zh.md: ca6ac9f5e8efec3472c03b044739a9221e98183c diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index df3689176c..d4031c2e3b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -58,7 +58,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start ## Model retry projection -The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error. +The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. ## Session forking diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9afe9773ff..ca6ac9f5e8 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -58,7 +58,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新 ## 模型重试投影 -Session 对象会在事件 wire 边界依据生产方的完整字段约定,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。 +Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 ## 会话 fork diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index 74406c324a..e3cfae47f4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -2,9 +2,7 @@ import type { Context } from 'cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-agent/types' - -type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from '@deepseek-ai/dsh-agent/types' interface InboxIdentity { readonly id: string diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index f383bc46d8..fa7c2ee5cf 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -31,6 +31,7 @@ function scheduledNode(match: Parameters[1] } } +/** A scheduled attempt is cancelled once either owning boundary closes. */ function isClosed(location: ConversationLocation): boolean { return (location.kind === 'step' && location.step.status === 'closed') || ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed') diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 3e5dcccff3..d90093b864 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -465,6 +465,38 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'user')).toBeUndefined() }) + it('orders claimed steering after the finalized Turn tail', () => { + const steering = textMessage('steer-after-answer', 'change direction') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-before-steering', 'initial answer'), + }, { surfaceOp: 'append' }), + at(4, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [steering], + }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', + start: 0, + removedCount: 1, + inserted: [], + }), + at(6, 'user/message', steering, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + const current = snapshot(value) + const steeringNode = node(current, 'steering') + expect(steeringNode).toBeDefined() + expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key) + }) + it('classifies appended producer context from durable source metadata', () => { const value = assembler([ at(1, 'user/message', { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e9810d98da..0df3ca0d86 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 -README.zh.md: ba493549bd0bc3f8a2adbda5989448e497f0af93 +README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13 +README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 189dedd88f..7d03e5faed 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. -`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. +`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row. `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index ba493549bd..dfbbc7a39a 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -4,7 +4,7 @@ 产出文件功能的属主:把已完成轮次末尾的产出文件行注册到 chat 视图的 `conversation.chat.turnTail` slot 中。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该界面,属主视图无需额外开销即可渲染空 slot。 -`producedForClosing` 根据 tail slot 属主提供的当前数据,即定稿快照节点和收尾助手的 seq,推导一个轮次产出的文件。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在轮次边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 +`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData`;`producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`,而不是收尾正文:无论模型是否记得点名,产出文件都会被列出。修改操作按渲染意图而非工具名识别:diff 卡片,或 `kind` 为 `edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。 `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签、至多六个标签项(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index b8e7cd111e..91b15cf361 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** * ui-deliverables browser half: the derivation contract of - * `producedForClosing` over finalized snapshot nodes, the row's rendering + * `producedForClosing` over engine-published Turn data, the row's rendering * and opener wiring, and the plugin registrations' fiber-teardown removal * (HMR safety) against the real SlotsService. */ @@ -12,7 +12,7 @@ import { ConversationEventRegistry, ConversationNodeAssembler, SlotsService, } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationEventInput, ConversationLocationDataStore, ConversationNodeDefinition, + ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition, ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' @@ -107,6 +107,10 @@ function at( } } +function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch { + return { ...input, role, location: { kind: 'unresolved' } } +} + function call( seq: number, callId: string, @@ -190,6 +194,50 @@ describe('produced-file Turn data', () => { ]) }) + it('ignores calls without mutation locations, orphan results, and replacement results', () => { + const replacement = result(8, 'replacement') + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool/call', { turn: 1, step: 1, callId: 'no-view', name: 'fixture', arguments: '{}' }), + result(3, 'no-view'), + call(4, 'locationless-edit', { card: 'generic', title: 'Edit', kind: 'edit' }), + result(5, 'locationless-edit'), + result(6, 'orphan'), + call(7, 'replacement', diff('replaced.txt')), + { + ...replacement, + event: { + ...replacement.event, + surfaceOp: { op: 'replace', start: 1, end: 1 }, + } as ConversationEventInput['event'], + }, + at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + + expect(producedForClosing(deliverablesOf(value))).toEqual([]) + }) + + it('rejects an invalid start match and preserves state for an unrelated update', () => { + const startMatch = matched(at(1, 'turn/start', { turn: 1 }), 'start') + const emptyContext: Parameters[0] = { + key: 'deliverables:1', + kind: 'deliverables', + id: '1', + matches: [startMatch], + start: startMatch, + state: undefined, + current: new Map(), + } + const reader: Parameters[2] = { previous: () => undefined } + const state = deliverablesDefinition.start(emptyContext, startMatch, reader) + const unrelated = matched(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), 'update') + const context: Parameters[0] = { ...emptyContext, state } + + expect(() => deliverablesDefinition.start(emptyContext, unrelated, reader)) + .toThrow('deliverables start requires turn/start') + expect(deliverablesDefinition.update(context, unrelated)).toBe(state) + }) + it('replays a tail page once prepend supplies its missing Turn start', () => { const value = assembler([ call(10, 'late', diff('history.txt')), diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 53c3baf05f..2cee6c191a 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -9,9 +9,9 @@ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import { CompactionId } from './brand.ts' +import type { CompactionId } from './brand.ts' -export { CompactionId } +export type { CompactionId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/src/types.ts b/packages/llm/llm-retry/src/types.ts index 5144939289..e78026cbeb 100644 --- a/packages/llm/llm-retry/src/types.ts +++ b/packages/llm/llm-retry/src/types.ts @@ -1,7 +1,7 @@ import type { LlmFailure } from '@deepseek-ai/dsh-llm/types' -import { RetryId } from './brand.ts' +import type { RetryId } from './brand.ts' -export { RetryId } +export type { RetryId } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 917a6d03f8..dd22e11369 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' +import { RetryId } from '@deepseek-ai/dsh-llm-retry' import { providerForOpenStep } from '../src/history.ts' async function setup(): Promise { diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 9e17290b43..8affb9e059 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import { RetryId } from '@deepseek-ai/dsh-llm-retry/types' +import { RetryId } from '@deepseek-ai/dsh-llm-retry' import type {} from '../src/index.ts' const dirs: string[] = [] diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index bfbf6a89ee..9bdd1222f0 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -10,7 +10,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts' import { estimateContent, diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 3f66e0564b..9ccc2d7e9b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -7,7 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index db60b695b1..f9aa66bce5 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { CompactionId } from '@deepseek-ai/dsh-compact/types' +import { CompactionId } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, diff --git a/tsconfig.base.json b/tsconfig.base.json index c7be2d4925..e404e3fc8b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -79,6 +79,7 @@ "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/interaction/user-interaction/src/types.ts"], + "@deepseek-ai/dsh-agent/types": ["./packages/core/agent/src/types.ts"], "@deepseek-ai/dsh-agent/brand": ["./packages/core/agent/src/brand.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index 33512f4898..e07ae43ec3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -211,7 +211,6 @@ export default defineConfig({ 'packages/client/ui-workspace/src/client/index.ts', 'packages/client/test-runtime/src/translate.ts', 'packages/client/ui-primitives/src/JsonTree.tsx', - 'packages/client/ui-deliverables/src/client/turn-deliverables.ts', // Typert generator: correctness is pinned by its fixture suites and // the byte-for-byte catalog reproduction test; per-file coverage // would put whole-workspace compiler analysis under v8 From 0cfe852c2d4f0893a7d77326995513916c79a872 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:21:49 +0800 Subject: [PATCH 16/20] fix(ui-conversation): ignore legacy compact events without ids --- .../client/conversation-nodes/compaction.ts | 4 +++- .../conversation-node-definitions.spec.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index 04eace8038..d21b4aa4b4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -39,7 +39,9 @@ export const compactionDefinition: ConversationNodeDefinition = || event.type === 'compact/summary' || event.type === 'compact/end') { if (event.data.sourceCommandId !== undefined) return null - return { id: String(event.data.compactionId), role: event.type === 'compact/start' ? 'start' : 'update' } + const compactionId: unknown = event.data.compactionId + if (typeof compactionId !== 'string' || compactionId === '') return null + return { id: compactionId, role: event.type === 'compact/start' ? 'start' : 'update' } } return null }, diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index d90093b864..016c7176db 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -698,6 +698,26 @@ describe('built-in conversation node Definitions', () => { }) }) + it('ignores legacy compaction transactions without correlation ids', () => { + const value = assembler([ + at(10, 'compact/start', { turn: null }), + at(11, 'compact/end', { turn: null, error: 'This operation was aborted' }), + at(20, 'compact/start', { turn: null }), + at(21, 'compact/summary', { + summary: [{ type: 'text', text: 'legacy summary' }], + shadowedSeqs: [1, 2, 3], + shadowedTokenCount: 42, + }), + at(22, 'user/message', { + ...textMessage('legacy-checkpoint', 'checkpoint'), + source: { kind: 'plugin', plugin: 'compact' }, + }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }), + at(23, 'compact/end', { turn: null }), + ], true) + + expect(node(snapshot(value), 'compaction')).toBeUndefined() + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { From 81822f40762806c354f2abd489e04841653e507a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:23:58 +0800 Subject: [PATCH 17/20] fix(ui-conversation): skip uncorrelated legacy events --- .../src/client/conversation-nodes/retry.ts | 7 ++- .../src/client/conversation-nodes/tool.ts | 5 ++- .../conversation-node-definitions.spec.ts | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index fa7c2ee5cf..d504f32928 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -42,10 +42,13 @@ export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', match: (event) => { if (event.type === 'llm/retry') { - return { id: String(event.data.retryId), role: event.data.retry === 1 ? 'start' : 'update' } + const retryId: unknown = event.data.retryId + if (typeof retryId !== 'string' || retryId === '') return null + return { id: retryId, role: event.data.retry === 1 ? 'start' : 'update' } } if (event.type === 'llm/retry-started') { - return { id: String(event.data.retryId), role: 'update' } + const retryId: unknown = event.data.retryId + return typeof retryId === 'string' && retryId !== '' ? { id: retryId, role: 'update' } : null } return null }, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index dc9e996dcc..c1b8022e41 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -241,7 +241,10 @@ export const toolDefinition: ConversationNodeDefinition = { return { id: String(event.data.message.source.callId), role: 'update' } } if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { - return { id: String(event.data.rootCallId), role: 'update' } + const rootCallId: unknown = event.data.rootCallId + return typeof rootCallId === 'string' && rootCallId !== '' + ? { id: rootCallId, role: 'update' } + : null } return null }, diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index 016c7176db..db9c0ccdd4 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -718,6 +718,50 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'compaction')).toBeUndefined() }) + it('ignores legacy retry and code-dispatch events without correlation ids', () => { + const value = assembler([ + at(10, 'llm/retry', { + turn: 1, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'first legacy retry' }, + }), + at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }), + at(20, 'llm/retry', { + turn: 2, + step: 1, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 10, + failure: { code: 'TRANSPORT', message: 'second legacy retry' }, + }), + at(30, 'tool/code-dispatch-start', { + parentCallId: 'root', + subCallId: 'child', + name: 'legacy-subcall', + arguments: {}, + }), + at(31, 'tool/code-dispatch', { + parentCallId: 'root', + subCallId: 'child', + name: 'legacy-subcall', + arguments: {}, + content: [], + }), + ], true) + + expect(node(snapshot(value), 'model-retry')).toBeUndefined() + expect(node(snapshot(value), 'tool-call')).toBeUndefined() + }) + it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => { const value = assembler([ at(5, 'llm/retry', { From d99ad2b1d05da476e93b5eaa4a58e288d4d8b179 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:36:57 +0800 Subject: [PATCH 18/20] fix conversation node review follow-ups --- docs/subsystems/compaction.i18n.yaml | 4 ++-- docs/subsystems/compaction.md | 7 +++--- docs/subsystems/compaction.zh.md | 7 +++--- .../fixtures/workspace-context-compaction.ts | 4 ++-- packages/client/runtime/package.json | 1 + .../src/client/sessions/steering-history.ts | 4 +--- .../src/client/conversation-nodes/command.ts | 4 ++-- packages/compact/compact/README.i18n.yaml | 4 ++-- packages/compact/compact/README.md | 10 ++++---- packages/compact/compact/README.zh.md | 10 ++++---- packages/compact/compact/src/checkpoint.ts | 23 +++++++++---------- packages/compact/compact/src/index.ts | 13 ++++++----- .../tests/session-reference.spec.ts | 15 ++++++++---- packages/llm/llm-retry/README.i18n.yaml | 4 ++-- packages/llm/llm-retry/README.md | 4 ++-- packages/llm/llm-retry/README.zh.md | 4 ++-- .../tool-cordis/src/api-catalog.ts | 2 +- pnpm-lock.yaml | 3 +++ 18 files changed, 67 insertions(+), 56 deletions(-) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index 532750f069..5e7c6d92be 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -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/compaction.md -compaction.md: 11ee6eae797db1c57bf2ffbb90f1f37f47b8400c -compaction.zh.md: a560e47dbb7aa92f0757179534803bbcaaebc6ba +compaction.md: 8325694d92aa1a3019aef8f6b0c1f99d45ad0df5 +compaction.zh.md: 9f9014b8c1c1db12abc6581fbe6ebacfac3dd6fe diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 11ee6eae79..8325694d92 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -66,7 +66,7 @@ Automatic callers state why policy is running; implementations may treat confirm type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. The predicate keeps checkpoint recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, `compactNow(agent, signal)` for one useful idle-session reduction even below pressure, and `compactRegion(...)` for an explicit inclusive surface range. `compactNow()` runs as agent maintenance between turns, returns `null` without writing when no useful range exists, records a standalone `turn: null` bracket before summarization, and flushes a closed attempt before later queued prompts may derive from the new surface. Every backend creates its replacement `user/message` source with `compactCheckpointSource(compactionId, sourceCommandId?)`; client and wire consumers import that constructor, `CompactCheckpointSource`, and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports them for host consumers. The required transaction identity correlates the replacement checkpoint, while the predicate keeps recognition independent of any one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. Expected manual failures use `ManualCompactionErrorCode`: @@ -129,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog /** @@ -175,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index a560e47dbb..9f9014b8c1 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -66,7 +66,7 @@ interface CompactionResult { type CompactionTrigger = 'pressure' | 'context-overflow' ``` -`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该值和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出两者。该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 +`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略,暴露 `compactNow(agent, signal)` 以便即使未达到压力也对空闲会话进行一次有效缩减,还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。`compactNow()` 作为轮次之间的 agent maintenance 运行;没有有效范围时返回 `null` 且不写入;在摘要前记录独立的 `turn: null` 标记对,并在后续排队提示词能够从新表层派生前 flush 已闭合尝试。每个后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换用 `user/message` 的源;client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该构造函数、`CompactCheckpointSource` 和 `isCompactCheckpointSource()`,包根则为 host 消费方重新导出它们。必填的事务身份会关联替换检查点,而该判定函数使检查点识别不依赖任一特定后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。 预期的手动失败使用 `ManualCompactionErrorCode`: @@ -129,7 +129,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.compact` — `CompactService` (abstract seam) -Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses COMPACT_CHECKPOINT_SOURCE so consumers recognize it independently of the backend. Load one implementation per context as `ctx.compact`. +Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. The replacement user message uses compactCheckpointSource with the transaction identity so consumers recognize and correlate it independently of the backend. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog /** @@ -175,7 +175,8 @@ abstract compactNow( agent: ManualCompactAgentContext, signal: AbortSignal, sour * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts index e13e3cf6b3..6c1cd46fbf 100644 --- a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -1,6 +1,6 @@ import type { Context } from 'cordis' import type {} from '@deepseek-ai/dsh-agent' -import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-tools' @@ -26,7 +26,7 @@ export function apply(ctx: Context): void { if (baseline === undefined) throw new Error('workspace baseline missing before snapshot compaction') agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Earlier context was compacted for this snapshot.' }], - source: COMPACT_CHECKPOINT_SOURCE, + source: compactCheckpointSource(CompactionId('workspace-context-fixture')), }), { surfaceOp: { op: 'replace', start: baseline.seq, end: baseline.seq }, sourceEventSeqs: [baseline.seq], diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index f5a9a06a60..83a62693cb 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/steering-history.ts b/packages/client/runtime/src/client/sessions/steering-history.ts index 4220a769ff..a28c3376a8 100644 --- a/packages/client/runtime/src/client/sessions/steering-history.ts +++ b/packages/client/runtime/src/client/sessions/steering-history.ts @@ -1,9 +1,7 @@ /** Reconstruct durable steering identity from the event-sourced agent inbox. */ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-agent/types' - -type InboxTarget = 'next-turn' | 'next-step' +import type { InboxTarget } from '@deepseek-ai/dsh-agent/types' /** Minimal pending identity retained while replaying durable inbox splices. */ interface PendingIdentity { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 38cb85ed87..752d7e3dd2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -4,7 +4,7 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client' -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' import type {} from '@deepseek-ai/dsh-compact/types' import type {} from '@deepseek-ai/dsh-commands/types' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' @@ -21,7 +21,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { type CommandId = CommandNode['commandId'] -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' interface CommandState { readonly command: CommandNode diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml index ec95ee2165..8b16ecfa4b 100644 --- a/packages/compact/compact/README.i18n.yaml +++ b/packages/compact/compact/README.i18n.yaml @@ -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/compact/compact/README.md -README.md: 3fecc91422bcdc994bcbe4db308ee4a52cb3c53f -README.zh.md: ea66d8bdb0684f27cf68a5431be75966041b7534 +README.md: eac4339da76352bc9468488d0828a0b90a993d48 +README.zh.md: 4aec6123169a7c88a36da647202e7462fbf94564 diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 3fecc91422..eac4339da7 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -8,7 +8,7 @@ This package owns the Service Definition role of the compaction capability, spli | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | Service Definition: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers | +| `@deepseek-ai/dsh-compact` (this) | Service Definition: abstract service + `compact/*` events + `CompactionResult` + correlated checkpoint-source constructor + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | Service provider: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-command-compact` | Consumer: the human `/compact` command over `ctx.compact.compactNow()` | @@ -22,7 +22,7 @@ All three operations are **abstract** — the backend owns trigger policy, reten |---|---| | `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | | `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. | -| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source comes from `compactCheckpointSource(compactionId)`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/subsystems/compaction.md#compactionresult). @@ -43,7 +43,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an 1. appends `compact/start` (log-only) — acquires the lock, 2. summarizes the range, 3. appends `compact/summary` (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope, -4. appends a single `user/message` with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**, +4. appends a single `user/message` with `source: compactCheckpointSource(compactionId, sourceCommandId?)` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**, 5. appends `compact/end` (log-only) — releases the lock. The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed. @@ -64,11 +64,11 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati ## Implementing a backend -Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. +Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend creates its replacement user message source with `compactCheckpointSource(compactionId, sourceCommandId?)`; the required `compactionId` correlates the checkpoint with its `compact/*` transaction, while `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter. ## Recognizing a checkpoint outside the host program (`./checkpoint`) -`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there. +`compactCheckpointSource()`, `CompactCheckpointSource`, and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The constructor requires the owning `CompactionId`, preventing backends from writing an uncorrelated marker that the package invariant must reject. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here. ## Model Experience diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md index ea66d8bdb0..4aec612316 100644 --- a/packages/compact/compact/README.zh.md +++ b/packages/compact/compact/README.zh.md @@ -8,7 +8,7 @@ | 包 | 职责 | |---|---| -| `@deepseek-ai/dsh-compact`(本包) | Service Definition:抽象服务 + `compact/*` 事件 + `CompactionResult` + 规范检查点源 + 工具配对边界 helper | +| `@deepseek-ai/dsh-compact`(本包) | Service Definition:抽象服务 + `compact/*` 事件 + `CompactionResult` + 关联检查点源构造函数 + 工具配对边界 helper | | `@deepseek-ai/dsh-compact-basic` | Service provider:`ctx.tokenMeter` 压力 + token 预算保留 + `llm.stream()` 摘要 | | `@deepseek-ai/dsh-command-compact` | Consumer:面向人类的 `/compact` 命令,基于 `ctx.compact.compactNow()` 实现 | @@ -22,7 +22,7 @@ |---|---| | `compactIfNeeded(agent, trigger, signal)` | 根据 `trigger: 'pressure' \| 'context-overflow'` 判断是否需要自动压缩。压力触发可应用后端的阈值与保留尾部策略;已确认溢出可强制进行有效的平衡缩减。返回 `CompactionResult`,无安全范围时则返回 `null`。后端摘要请求是直接的 `ctx.llm.stream()` 调用(不是 agent loop 步骤),因此每次调用都可在 `llm/stream` 处拦截。 | | `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 | -| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | +| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源由 `compactCheckpointSource(compactionId)` 创建。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 | `CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/subsystems/compaction.md#compactionresult)。 @@ -43,7 +43,7 @@ 1. 追加 `compact/start`(仅日志):获取锁; 2. 摘要该范围; 3. 追加 `compact/summary`(仅日志),其中记录摘要、范围、已遮蔽 seq、token 数与提供方/模型调用 envelope; -4. 追加单个 `user/message`,其携带 `source: COMPACT_CHECKPOINT_SOURCE` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**; +4. 追加单个 `user/message`,其携带 `source: compactCheckpointSource(compactionId, sourceCommandId?)` 和包含摘要的 `surfaceOp: { op: 'replace', start, end }`:这是**本操作唯一的表层变更**; 5. 追加 `compact/end`(仅日志):释放锁。 表层变更(第 4 步)位于锁的起止范围**内**:`compact/end` 是最后一个事件,因此表层变更落地前绝不会释放锁。如果在 `compact/start` 与 `compact/end` 之间崩溃,会留下可检测的遗留锁(一个 `compact/start` 没有匹配的 `compact/end`),而不是虚假声称压缩已完成、但表层从未被遮蔽的 `compact/end`。 @@ -64,11 +64,11 @@ ## 实现后端 -继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE`;`isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 +继承 `CompactService`,实现 `compactIfNeeded`、`compactNow` 与 `compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都使用 `compactCheckpointSource(compactionId, sourceCommandId?)` 创建替换 user 消息的源;必填的 `compactionId` 将检查点与对应 `compact/*` 事务关联,而 `isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。 ## 在 host 程序之外识别检查点(`./checkpoint`) -`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。 +`compactCheckpointSource()`、`CompactCheckpointSource` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。构造函数要求传入所属 `CompactionId`,防止后端写入缺少关联关系、必然被包不变量拒绝的标记。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子的源类型上,因此在此处改插件 id 会让那边编译失败。 ## 模型体验 diff --git a/packages/compact/compact/src/checkpoint.ts b/packages/compact/compact/src/checkpoint.ts index 9908fe4ac0..18a2c5985e 100644 --- a/packages/compact/compact/src/checkpoint.ts +++ b/packages/compact/compact/src/checkpoint.ts @@ -1,12 +1,12 @@ /** - * The compaction seam's canonical checkpoint source: the plugin marker every - * backend stamps on the replacement user message that lands a checkpoint, plus - * the predicate that recognizes it. + * Compaction checkpoint provenance: the correlated source constructor and type + * every backend uses for its replacement user message, plus the predicate that + * recognizes persisted checkpoints. * - * The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of - * these; this module is a pure value/predicate outlet (no cordis imports, no - * module augmentation) so client and wire programs can name the checkpoint - * source without loading the host plugin's Context merges — the + * The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports these + * contracts; this module is a pure type/value/predicate outlet (no cordis + * imports, no module augmentation) so client and wire programs can name the + * checkpoint source without loading the host plugin's Context merges — the * `dsh-commands/brand` shape. * * @module @deepseek-ai/dsh-compact/checkpoint @@ -16,11 +16,10 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm/message' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionId } from './brand.ts' -/** Canonical source for the replacement user message produced by every compaction backend. */ -export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) +const COMPACT_CHECKPOINT_MARKER = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const) /** Message provenance carried by a concrete compaction checkpoint. */ -export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_SOURCE & { +export type CompactCheckpointSource = typeof COMPACT_CHECKPOINT_MARKER & { readonly compactionId: CompactionId readonly sourceCommandId?: CommandId } @@ -36,7 +35,7 @@ export function compactCheckpointSource( sourceCommandId?: CommandId, ): CompactCheckpointSource { return Object.freeze({ - ...COMPACT_CHECKPOINT_SOURCE, + ...COMPACT_CHECKPOINT_MARKER, compactionId, ...sourceCommandId === undefined ? {} : { sourceCommandId }, }) @@ -48,5 +47,5 @@ export function compactCheckpointSource( * @returns whether the source carries the backend-independent checkpoint marker. */ export function isCompactCheckpointSource(source: MessageSource): boolean { - return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin + return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_MARKER.plugin } diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 0085321333..6fb9706c04 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -15,10 +15,10 @@ import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' export { CompactionId } from './brand.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' -// The checkpoint source and its predicate are declared on the cordis-free +// The checkpoint source constructor and predicate are declared on the cordis-free // `./checkpoint` leaf so client and wire programs can name them without this // root's Context merge; the root stays the host-side entry point for both. -export { COMPACT_CHECKPOINT_SOURCE, compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' +export { compactCheckpointSource, isCompactCheckpointSource } from './checkpoint.ts' export type { CompactCheckpointSource } from './checkpoint.ts' /** Why automatic policy is asking a backend to consider compaction. */ @@ -89,9 +89,9 @@ declare module 'cordis' { * and summarization, and may consume a separate measurement service. A * successful run replaces the selected surface span with one summary node and * prevents concurrent compaction of the same session. The replacement user - * message uses {@link COMPACT_CHECKPOINT_SOURCE} so consumers recognize it - * independently of the backend. Load one implementation per context as - * `ctx.compact`. + * message uses {@link compactCheckpointSource} with the transaction identity + * so consumers recognize and correlate it independently of the backend. Load + * one implementation per context as `ctx.compact`. */ export abstract class CompactService extends Service { constructor(ctx: Context) { @@ -149,7 +149,8 @@ export abstract class CompactService extends Service { * balanced so assistant tool calls remain paired with their results. A model- * backed implementation forwards cancellation and rejects active, missing, * reversed, or unbalanced ranges. The target session is `agent.session`. - * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}. + * Its replacement user message must use {@link compactCheckpointSource} with + * the transaction's `CompactionId`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index b9e5e55241..ce964af16a 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact' +import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionQueryService from '@deepseek-ai/dsh-session-query' @@ -48,6 +48,10 @@ function expectCode(code: SessionReferenceErrorCode): Error { return expect.objectContaining({ code }) as Error } +function checkpointSource(id: string) { + return compactCheckpointSource(CompactionId(id)) +} + function appendConversation(session: Session): void { const oldUser = session.append( 'user/message', @@ -75,7 +79,8 @@ function appendConversation(session: Session): void { session.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'checkpoint' }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: 'checkpoint' }], + source: checkpointSource('conversation'), }), { surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq }, @@ -534,7 +539,8 @@ describe('session reference discovery and preparation', () => { source.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], + source: checkpointSource(id), }), { surfaceOp: 'append' }, ) @@ -616,7 +622,8 @@ describe('session reference discovery and preparation', () => { source.append( 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'later compact checkpoint' }], source: COMPACT_CHECKPOINT_SOURCE, + content: [{ type: 'text', text: 'later compact checkpoint' }], + source: checkpointSource('later-source-mutation'), }), { surfaceOp: { op: 'replace', start: original.seq, end: later.seq }, diff --git a/packages/llm/llm-retry/README.i18n.yaml b/packages/llm/llm-retry/README.i18n.yaml index 65e0c911ed..2a1f8d46a2 100644 --- a/packages/llm/llm-retry/README.i18n.yaml +++ b/packages/llm/llm-retry/README.i18n.yaml @@ -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/llm/llm-retry/README.md -README.md: e6e56ec44032d714393c6fcc1c42d7271017a294 -README.zh.md: b7ce8bee4acd2c4f7c88870745dff96ec5695435 +README.md: 0a907b5505650f7c2cd5e9933750be3701f8e34b +README.zh.md: bb8c5b50efddf3def0047f411285b57c3c544f95 diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index e6e56ec440..0a907b5505 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -8,9 +8,9 @@ Each provider adapter owns an optional nested `retryPolicy`, captured when its r Both modes use bounded exponential backoff with symmetric jitter. A valid `providerRetryAfterMs` at or below `maxDelayMs` replaces local backoff without jitter. An over-cap provider delay makes normal mode delegate, while always mode uses its configured local backoff so it cannot terminate on that instruction. -Before waiting, the plugin appends a non-surface `llm/retry` event with the provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. After the wait, the listener returns `{ kind: 'retry' }`, and the loop closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. +Before waiting, the plugin appends a non-surface `llm/retry` event with the shared `retryId`, provider, mode, canonical resolved-policy key, failure, and scheduled delay. Its payload is available from the browser-safe `@deepseek-ai/dsh-llm-retry/types` subpath, so remote renderers can consume the durable status without loading the policy runtime. The key includes every behavior-affecting field and sorts normal-mode codes because eligibility uses set membership. Retry numbers continue only across events with the same provider and complete policy key, so a route replacement with different limits, code membership, or backoff starts its own history. Normal events include the finite maximum; always events omit it, and UIs render `∞`. When the wait completes, the plugin appends `llm/retry-started` with the same `retryId`, turn, step, and retry number immediately before returning `{ kind: 'retry' }`; cancellation during backoff writes no started event. The loop then closes the failed turn and opens a retry turn over the same durable history. Cancellation and plugin disposal abort active backoff, drain active delegated recovery before applying the abort, and make a callback captured before disposal fail closed. -The separately published `./invariant` companion checks that every retry record names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. +The separately published `./invariant` companion checks that every scheduled retry names the current open turn and latest closed step, matches the failed request's durable provider, carries non-empty provider and policy identities, has mode-specific bounds, a unique step record, the correct provider-policy retry number, and a bounded timer delay. It also requires each `llm/retry-started` event to name one prior scheduled attempt with the same `retryId`, turn, step, and retry number, and rejects repeated started events. Full jitter may schedule zero milliseconds at its lower boundary. ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-retry/README.zh.md b/packages/llm/llm-retry/README.zh.md index b7ce8bee4a..bb8c5b50ef 100644 --- a/packages/llm/llm-retry/README.zh.md +++ b/packages/llm/llm-retry/README.zh.md @@ -8,9 +8,9 @@ 两种 mode 都使用带对称 jitter 的有界指数退避。有效 `providerRetryAfterMs` 不超过 `maxDelayMs` 时会替换本地退避,并且不加 jitter。超出上限的提供方延迟会使 normal mode 继续委托;always mode 则改用已配置的本地退避,避免该指令终止重试。 -等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待结束后,监听器返回 `{ kind: 'retry' }`,循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 +等待前,插件会追加一条不进入表层的 `llm/retry` 事件,其中包含共享 `retryId`、提供方、mode、已解析策略的规范 key、失败和计划延迟。该载荷由可安全用于浏览器的 `@deepseek-ai/dsh-llm-retry/types` 子路径导出,因此远程渲染器无需加载策略运行时即可使用该持久状态。该 key 包含所有影响行为的字段,并对 normal mode 的 code 排序,因为合格性采用集合成员关系判断。只有提供方与完整策略 key 都相同的事件才会延续重试编号;因此,用限制、code 成员关系或退避不同的路由替换后,会开始自己的历史。normal 事件包含有限上限;always 事件省略该上限,UI 会渲染 `∞`。等待完成时,插件会在返回 `{ kind: 'retry' }` 前立即追加 `llm/retry-started`,其中带有相同的 `retryId`、轮次、步骤与重试编号;退避期间取消则不会写入 started 事件。随后循环关闭失败轮次,并在同一持久历史上开启重试轮次。取消与插件 dispose 会中止活跃退避,在应用中止前排空活跃的委托恢复,并使 dispose 前捕获的 callback 只能以失败结束。 -单独发布的 `./invariant` 配套模块会检查每个重试记录是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。完整 jitter 可以在下界调度为零毫秒。 +单独发布的 `./invariant` 配套模块会检查每个已调度重试是否指向当前开启轮次及其最新已关闭步骤,是否与失败请求的持久提供方匹配,是否携带非空的提供方与策略标识,是否满足 mode 特定边界,是否拥有唯一步骤记录和正确的提供方策略重试编号,以及是否携带有界定时器延迟。它还要求每个 `llm/retry-started` 事件通过相同的 `retryId`、轮次、步骤与重试编号指向一个先前调度的尝试,并拒绝重复的 started 事件。完整 jitter 可以在下界调度为零毫秒。 ```yaml - name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index b52000f554..c8ec4894b4 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -282,7 +282,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', - jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', + jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Its replacement user message must use {@link compactCheckpointSource} with\n * the transaction\'s `CompactionId`.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', }, ], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fb20b9718..1c27b19685 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1507,6 +1507,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy From 1566a00eeba61e5f17f65a266eec4944e1ae335c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:42:39 +0800 Subject: [PATCH 19/20] test compact checkpoint snapshot identity --- .../acp-agent/tests/snapshots/workspace-context/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index 179e61ade9..40592a9f99 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","seq":14,"time":1785730689194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":15,"time":1785730689194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9e9648c4-949e-4cf1-b9ef-0eb65897d36b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} {"type":"tool/call","seq":16,"time":1785730689195,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}} +{"type":"user/message","seq":17,"time":1785982371865,"data":{"content":[{"type":"text","text":"Earlier context was compacted for this snapshot."}],"source":{"kind":"plugin","plugin":"compact","compactionId":"workspace-context-fixture"},"role":"user","id":"162c764f-f01d-484d-ad81-1481dc29792a"},"sourceEventSeqs":[5],"surfaceOp":{"op":"replace","start":5,"end":5}} {"type":"tool/result","seq":18,"time":1785982371865,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"a46fded2-333a-4fb2-b01e-28520bffbc21"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","seq":19,"time":1785982371865,"data":{"turn":1,"step":1}} {"type":"agent/inbox/spliced","seq":20,"time":1785730689207,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"},{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".dsh-project\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"},{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"09640903-80ea-4eb6-8635-90ddfb4e24e4"}]}} From f4c817648d0685dd2bec9f3c1b474597e8473687 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:53:06 +0800 Subject: [PATCH 20/20] docs fix compact checkpoint type example --- ...2026-07-30-web-transcript-log-ordered-projection.i18n.yaml | 4 ++-- .../2026-07-30-web-transcript-log-ordered-projection.md | 4 ++-- .../2026-07-30-web-transcript-log-ordered-projection.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 1ea341085d..00fec6c8db 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: feefc951118c8511237c931a13070e1f7db1fd16 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 711e57b29742171d1861f25e63019b64356b4cad +2026-07-30-web-transcript-log-ordered-projection.md: f2b7c3830b585db619f69046917e07a0b6ff6832 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 77787f9613b64a35e79a8667c6598de5a20ff400 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index feefc95111..f2b7c3830b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -33,8 +33,8 @@ What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import: ```ts -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' ``` Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 711e57b297..77787f9613 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -33,8 +33,8 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上: ```ts -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact' ``` 重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。