Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring

# Conflicts:
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/subsystems/tools.i18n.yaml
#	docs/subsystems/tools.md
#	docs/subsystems/tools.zh.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
Yichen Jiang
2026-08-09 21:38:26 +08:00
266 changed files with 12118 additions and 4857 deletions

View File

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

View File

@@ -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<string, { exitCode: number } | { signal: stri
}
/**
* Structured grep result for the search sample (turn 66): matches grouped by
* Structured grep result for the search sample (turn 67): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
@@ -191,7 +191,7 @@ const SEARCH_MATCHES_TEXT = [
].join('\n')
/**
* Structured glob result for the search sample (turn 67): a flat path list,
* Structured glob result for the search sample (turn 68): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
@@ -426,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
// the presenter reads to emit the two-hunk sample: the card draws one path
// header, the first hunk, a `⋯` gap, then the second (the same-file
// second-hunk arm turns 62/63 cannot reach).
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
// keyed registration a top-level bash row uses).
{
const turn = 64
const turn = 65
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
@@ -456,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, 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 {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: bd8528e97b04d5b4b28922266306969e8f19295a
README.zh.md: 9aea486fb17c5a170ee8c1195435d220b495b615
README.md: d4031c2e3b7730bdb0075ac84c50ad3e46ce63a2
README.zh.md: ca6ac9f5e8efec3472c03b044739a9221e98183c

View File

@@ -34,11 +34,15 @@ 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.
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).
## Request inspection
@@ -46,7 +50,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
@@ -54,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

View File

@@ -34,11 +34,15 @@ 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` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识
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 副本不进入 Chatcompaction 检查点除外,它会成为独立标记,并在更早分页补齐 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)。
## 请求检查
@@ -46,7 +50,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 Dispatchstart/settlement 记录折叠进该 root Context并投影为一棵 keyed 递归树child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍`callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约
## Session 标题投影
@@ -54,7 +58,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
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、TurnStep 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。
## 会话 fork

View File

@@ -32,9 +32,10 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-compact": "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:^",
@@ -42,6 +43,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"

View File

@@ -0,0 +1,265 @@
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
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<DataMap extends object> {
/**
* 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 extends keyof DataMap & string>(key: Key): Readonly<DataMap[Key]> | 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<ConversationStepDataMap>
}
/** 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<ConversationTurnDataMap>
}
/** 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<State = unknown> {
readonly key: string
readonly kind: string
readonly id: string
readonly matches: readonly ConversationMatch[]
readonly start: ConversationMatch | undefined
readonly state: State | undefined
readonly current: ReadonlyMap<string, ConversationViewNode | null>
}
/** Read-only predecessor returned to a Definition's start function. */
export interface ConversationPreviousContext<State = unknown> {
readonly key: string
readonly kind: string
readonly id: string
readonly startSeq: number
readonly state: Readonly<State>
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<State>(kind: string): ConversationPreviousContext<State> | 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<State = unknown> {
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<State>,
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<State> & { 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<State>,
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<State>,
target: string,
): ConversationViewNode | null
}
/** Reference-stable Turn/Step facts published beside view Nodes. */
export interface ConversationTimelineSnapshot {
readonly turnOrder: readonly number[]
readonly turns: ReadonlyMap<number, TurnLocation>
}
/** Per-Session incremental builder for one view target. */
export interface ConversationViewBuilder<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
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<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
readonly target: string
/** @returns a new Session-owned incremental builder. */
create(): ConversationViewBuilder<Node, Snapshot>
}
/**
* 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}`
}

View File

@@ -0,0 +1,60 @@
import { Service } from 'cordis'
/** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */
export abstract class ConversationDefinitionRegistry<Definition> extends Service {
protected readonly definitions = new Map<string, Definition>()
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()
}
}

View File

@@ -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<ConversationNodeDefinition> {
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
}
}

View File

@@ -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<ConversationViewDefinition> {
/** @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)})`,
)
}
}

View File

@@ -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),
})

View File

@@ -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'

View File

@@ -0,0 +1,799 @@
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<string, ConversationViewNode | null>
readonly locationData: Record<ConversationLocationDataScope, ConversationLocationData | null>
dependencies: Map<string, Dependency>
}
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<ConversationPublication, number> = {
none: 0,
'animation-frame': 1,
immediate: 2,
}
const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn']
function emptyLocationData(): Record<ConversationLocationDataScope, ConversationLocationData | null> {
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<State>(context: InternalContext): ConversationNodeContext<State> {
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<string, InternalContext>()
private readonly contextsByKind = new Map<string, InternalContext[]>()
private readonly contextsBySeq = new Map<number, Set<InternalContext>>()
private readonly inputs = new Map<number, ConversationEventInput>()
private readonly locationIndex = new ConversationLocationIndex()
private readonly dirty = new Set<InternalContext>()
private readonly revised = new Set<InternalContext>()
private readonly dependents = new Map<string, Set<InternalContext>>()
private readonly views = new Map<string, ViewState>()
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<string, PendingMatch[]>()
for (const entry of fresh) {
publication = maximumPublication(publication, this.collectInput(entry, pending))
}
this.applyPendingMatches(pending, affected)
this.replayContexts(affected)
if ((this.revised.size > 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<string, ConversationViewNode[]>()
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<string, ConversationViewNode[]>()
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<string, PendingMatch[]>,
): 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<InternalContext>()
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<string, readonly PendingMatch[]>,
affected: Set<InternalContext>,
): void {
const startsByKind = new Map<string, InternalContext[]>()
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<InternalContext>()
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<InternalContext>): 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<string, Dependency>()
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<string, Dependency>): 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 affected = new Set<InternalContext>()
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 (affected.has(dependent)) continue
affected.add(dependent)
pending.push(dependent)
}
}
this.replayContexts(affected)
return affected.size > 0
}
private readerFor(
beforeSeq: number,
dependencies: Map<string, Dependency>,
): ConversationContextReader {
return {
previous: <State>(kind: string): ConversationPreviousContext<State> | 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<State>,
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<number>): Set<InternalContext> {
const affected = new Set<InternalContext>()
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 {
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(
`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 })
}
// 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)
}
}
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 }
}

View File

@@ -0,0 +1,516 @@
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<string, OwnedLocationData>()
get(key: string): 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<string, OwnedLocationData>): 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<number, StepDraft>
}
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<T>(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<number, Coordinates>()
private locations = new Map<number, ConversationLocation>()
private seqsByTurn = new Map<number, Set<number>>()
private timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
private readonly turnDataStores = new Map<number, MutableLocationDataStore>()
private readonly stepDataStores = new Map<string, MutableLocationDataStore>()
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.
* @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<number, Map<string, OwnedLocationData>>()
const steps = new Map<string, Map<string, OwnedLocationData>>()
for (const { owner, data } of entries) {
const values = data.kind === 'turn'
? turns.get(data.turn) ?? new Map<string, OwnedLocationData>()
: steps.get(stepDataKey(data.turn, requireStep(data))) ?? new Map<string, OwnedLocationData>()
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.
* @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) {
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<number> {
const previousLocations = this.locations
const turns = new Map<number, TurnDraft>()
const coordinates = new Map<number, Coordinates>()
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<number, TurnLocation>()
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<number>()
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<number> {
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<number>()
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<number>()
current.add(seq)
this.seqsByTurn.set(turn, current)
}
private turnData(turn: number): ConversationLocationDataStore<ConversationTurnDataMap> {
return this.mutableTurnData(turn) as ConversationLocationDataStore<ConversationTurnDataMap>
}
private stepData(turn: number, step: number): ConversationLocationDataStore<ConversationStepDataMap> {
return this.mutableStepData(stepDataKey(turn, step)) as ConversationLocationDataStore<ConversationStepDataMap>
}
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`)
}

View File

@@ -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'
@@ -13,6 +15,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 +211,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 +227,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 +316,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 +338,76 @@ export interface PromptError {
error: RpcError
}
/**
* 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
/** @returns all currently materialized Nodes without imposing render order. */
values(): readonly ChatConversationViewNode[]
}
/**
* 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[]
/** @param turn - owning turn. @param step - owning step. @returns ordered Chat Node keys in the step. */
getStep(turn: number, step: number): readonly string[]
}
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
readonly turnEnds: ReadonlyMap<number, number>
readonly partial: PartialAssistant | null
readonly runningCalls: readonly RunningToolCall[]
}
/** Incremental Chat publication with immutable order and stable live keyed readers. */
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 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<number, { readonly startTime: number; readonly endTime?: number }>

View File

@@ -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)

View File

@@ -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: '' }

View File

@@ -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<MuxFrame, { type: 'session/queue' }>['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
}
}

View File

@@ -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
}

View File

@@ -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'
@@ -265,16 +266,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<SessionSelection>(
{},
{ 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<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
@@ -302,6 +317,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)
}

View File

@@ -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<string, RunningToolCall>()
/** Last entered step per turn, folded from step/start for terminal error placement. */
private lastStepByTurn = new Map<number, number>()
/** 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<string, PendingInteraction>()
// 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<number, { startTime: number; endTime?: number }>()
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<number, number>()
private turnEndsRev = 0
private turnEndsCache: { rev: number; value: ReadonlyMap<number, number> } | 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]
@@ -382,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]
@@ -389,8 +352,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 +423,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 +433,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 +477,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 +541,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 +607,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 +617,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 +645,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 +675,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<ModelRetryNode['retryState'], 'scheduled'>,
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 +729,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<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
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.
*/

View File

@@ -1,8 +1,7 @@
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
import type { SessionEvent } from '@deepseek-ai/dsh-session/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 {
@@ -45,8 +44,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

View File

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

View File

@@ -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<string, CallIndexEntry>,
resultView: ToolResultView | null,
steering: boolean,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): 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<number, SessionEvent>,
): 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<number, SessionEvent>()
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
private projected: ConversationNode[] = []
private callIdx = new Map<string, CallIndexEntry>()
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
private stepTimings = new Map<string, AssistantStepMetadata>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** 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<string, CommandNode>()
/** 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<number>()
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).
}
}

View File

@@ -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<never>).scope
const scope = (this._core.specDynamic(options.name) as SlotSpec<SlotEntryDef>).scope
this._acquire(store, scope)
}
let disposed = false

View File

@@ -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<null> = {
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'))

View File

@@ -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: '<context_checkpoint>model only</context_checkpoint>' }],
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' })
})
})

View File

@@ -0,0 +1,959 @@
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<string, ConversationViewNode>
}
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<ConversationViewNode, TestSnapshot> {
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<ConversationNodeDefinition['buildViewNode']>[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<number> & { readonly state: number }) => (
context.state + 1
))
const matchCollections = new Set<readonly ConversationMatch[]>()
const definition: ConversationNodeDefinition<number> = {
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<number> & { readonly state: number }) => (
context.state + 1
))
const definition: ConversationNodeDefinition<number> = {
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<null> = {
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<number> = {
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<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
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<number> = {
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<number> = {
kind: 'consumer',
match: event => event.type === 'assistant/message'
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
: null,
start: (_context, _match, reader) => reader.previous<number>('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<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
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<number> = {
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<ConversationNodeDefinition<number>['start']>[0],
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
) => reader.previous<number>('source')?.state ?? -1)
const consumer: ConversationNodeDefinition<number> = {
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 a transitive dependency closure in start order', () => {
const sourceA: ConversationNodeDefinition<number> = {
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<number> = {
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<number> = {
kind: 'diamond-b',
match: event => event.type === 'assistant/message'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0)
+ (reader.previous<number>('diamond-x')?.state ?? 0)
),
update: context => context.state,
buildViewNode: context => node(context, context.state),
}
const consumer: ConversationNodeDefinition<number> = {
kind: 'diamond-c',
match: event => event.type === 'tool/call'
? { id: 'one', role: 'start' }
: null,
start: (_context, _match, reader) => (
(reader.previous<number>('diamond-a')?.state ?? 0) * 100
+ (reader.previous<number>('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((
_context: Parameters<ConversationNodeDefinition<string>['start']>[0],
match: Parameters<ConversationNodeDefinition<string>['start']>[1],
) => match.location.kind === 'step' ? match.location.step.status : 'missing')
const definition: ConversationNodeDefinition<string> = {
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<State> = {
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<null> = {
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<null> = {
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<null> = {
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<null> = {
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<null> = {
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<ConversationNodeDefinition<number>['start']>[0]) => {
expect(context.state).toBeUndefined()
return 1
})
const definition: ConversationNodeDefinition<number> = {
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<null> = {
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<string> = {
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<boolean> = {
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<boolean> = {
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<number> = {
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)
})
})

View File

@@ -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<null> {
return {
kind,
match: () => null,
start: () => null,
update: context => context.state,
buildViewNode: () => null,
}
}
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
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()
})
})

View File

@@ -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 } }),

View File

@@ -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 },

View File

@@ -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<string, unknown>): 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<string, ChatConversationViewNode>()
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<number, { startTime: number; endTime?: number }>()
const turnEnds = new Map<number, number>()
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<ChatConversationViewNode, ChatSnapshot> {
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<TestEventState> = {
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<string | null> = []
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<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
@@ -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)
})

View File

@@ -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<string, unknown>): 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: '<context_checkpoint>model only</context_checkpoint>' }],
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: '<skill_content name="hidden-demo">body</skill_content>' }],
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: '<context_checkpoint>x</context_checkpoint>' }],
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 },
})
})
})
})

View File

@@ -26,6 +26,12 @@
{
"path": "../../interaction/commands"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../compact/compact"
},

View File

@@ -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(),

View File

@@ -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<const D extends ChildrenDecl>(
children: D,
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
frame: SlotComponent<ComposedProps<'root', never, keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
): Promise<void> {
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)
}

View File

@@ -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' }
}
}
@@ -426,7 +427,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()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 911bca28dcfb31b1d8ef9ea5458a0d2017d8b31d
README.zh.md: 25574421bbcc3992188e378a49a69acf25744af7
README.md: d894d3e578f065e9cb2c45676224efd1f4f76fa8
README.zh.md: ec99a8d7daa593cdf3014f292a095fb2fd6f39d3

View File

@@ -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: <active id>`), 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 <preset>` 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.
@@ -20,7 +22,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 +42,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

View File

@@ -12,13 +12,15 @@
视图环是一个 slot严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`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` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
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 +42,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` servicectx.get由同一插件提供索取收尾消息的行内代码词表并把结果接进 MarkdownText 的 `fileMentions` 约定——service 缺席时正文保持死文本。
完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn datarenderer 在该 Node 的 IconActions 之渲染 `conversation.chat.turnTail` chain并派发包含 Turn、收尾 seq 和 `openFile``TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data并拥有产物行、chip 上限和文案,因此把插件从 cordis.yml 中组合掉即可关闭该交互面空位以零成本渲染为空。收尾正文经由同一个开关参与其中chat 视图向可选的 `chatFileMentions` servicectx.get由同一插件提供索取收尾消息的行内代码词表并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
## 模型体验

View File

@@ -39,20 +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:^",
@@ -60,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",

View File

@@ -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
@@ -297,8 +319,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',
@@ -306,9 +328,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<typeof chatStore>): ChatViewInjected => {

View File

@@ -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<PropsRenderSlots<'conversation.chat.turnTail'>, '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 (
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
@@ -119,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
{showActions && (
<MessageIconActions
text={copyText(blocks)}
time={time}
runMs={runMs}
ttftMs={ttftMs}
tokensPerSecond={tokensPerSecond}
clock="end"
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
branchUnavailable={forkUnavailable}
className={css.actions}
t={t}
/>
)}
</div>
)
})

View File

@@ -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<TurnTailOwnerProps | undefined>(() => {
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 (
<AssistantMarkdown
blocks={data.blocks}
streaming={data.status === 'running'}
interrupted={data.status === 'interrupted'}
mentions={mentions}
t={t}
/>
)
})

View File

@@ -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<Kind> }
}[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<ChatNodeOwnerProps | null>(() => 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 (
<div
className={css.flowItem}
data-chat-anchor-key={routedNode.key}
data-chat-flow-key={routedNode.key}
data-chat-flow-kind={routedNode.kind}
>
{renderSlot('conversation.chat.node', routedOwner, {
entryKey: routedNode.kind,
hookContext: nodeKey,
fallback: (
<JsonBlock
label={t('message.unknownSurface', { type: routedNode.kind })}
payload={routedNode.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
),
})}
</div>
)
})

View File

@@ -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 {

View File

@@ -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<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
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 (
<div className={css.toolGroup}>
{results.map(node => (
<ToolSeat
key={node.callId}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
openFile={openFile}
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
)
})
/** 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<ConversationNode, { kind: 'compaction' }>
t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
const fallback = node.name === 'compact'
? <CompactionCommandCard {...owner} t={t} />
: <GenericCommandCard {...owner} t={t} />
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback,
})}
</div>
)
})
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 <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
/**
* 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<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -312,11 +184,12 @@ export function ChatView({
* scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(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 (
<ToolGroup
renderSlot={renderSlot}
results={item.results}
openFile={openFile}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
)
}
if (item.kind === 'command-compaction') {
return (
<CommandRow
renderSlot={renderSlot}
node={item.command}
compaction={item.compaction}
t={t}
/>
)
}
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 (
<AssistantMarkdown
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={actionSeqs.has(node.seq) ? node.time : undefined}
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
ttftMs={metrics?.ttftMs}
tokensPerSecond={metrics?.tokensPerSecond}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
t={t}
/>
)
}
if (node.kind === 'command') {
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
<MessageItem
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
t={t}
/>
)
}
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
@@ -572,43 +379,21 @@ export function ChatView({
</button>
</div>
)}
{items.map(item => (
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node'
? `node:${String(item.node.seq)}`
: item.kind === 'command-compaction'
? `node:${String(item.compaction.seq)}`
: undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node'
? item.node.kind
: item.kind === 'command-compaction'
? item.kind
: 'tool-group'}
>
{renderItem(item)}
</div>
{order.map(nodeKey => (
<ChatNodeSeat
key={nodeKey}
nodeKey={nodeKey}
useSession={useSession}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
forkAt={forkAt}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
))}
<StreamingTail useSession={useSession} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
<ToolSeat
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
openFile={openFile}
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
)}
{/* No pending placeholders: questions (ui-question) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}

View File

@@ -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<CommandRowOwnerProps>(() => ({ node: command }), [command])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: command.name ?? '',
fallback: <GenericCommandCard {...owner} t={t} />,
})}
</div>
)
})
/** One integrated `/compact` command and compaction transaction renderer. */
export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({
node, t,
}: ChatNodeViewProps<'manual-compaction'>) {
const data = node.data
return (
<div className={css.callRow}>
<CompactionCommandCard
node={data.command}
{...data.compaction === null ? {} : { compaction: data.compaction }}
t={t}
/>
</div>
)
})

View File

@@ -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 (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
className={css.actions}
t={t}
/>
)}
/>
)
case 'context':
return (
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
node, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
return (
<UserStyleBubble
content={data.content}
steering={data.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={data.time}
clock="start"
className={css.actions}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error':
return <TurnErrorItem node={node} t={t} />
default:
return (
<div className={css.contextRow}>
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
</div>
)
}
)}
/>
)
})
/** Injected-context keyed Chat renderer. */
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
const data = node.data
return (
<ContextInjectionRow
content={data.content}
source={data.source}
provenance={data.provenance}
form={data.form}
t={t}
/>
)
})
/** Automatic compaction keyed Chat renderer. */
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
return <CompactionItem node={node.data} t={t} />
})
/** Correlated retry-chain keyed Chat renderer. */
export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) {
const data = node.data
return <ModelRetryItem node={data.current} active={data.current.retryState === 'scheduled'} t={t} />
})
/** Terminal turn-error keyed Chat renderer. */
export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) {
return <TurnErrorItem node={node.data} t={t} />
})
/** Explicit unknown-surface keyed Chat renderer. */
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
const data = node.data
return (
<div className={css.contextRow}>
<JsonBlock
label={t('message.unknownSurface', { type: data.type })}
payload={data.data}
truncatedLabel={total => t('json.truncated', { total })}
/>
</div>
)
})

View File

@@ -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) {

View File

@@ -0,0 +1,9 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
.actions {
margin-left: -6px;
}

View File

@@ -0,0 +1,45 @@
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, 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
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 : <div className={css.root}>{tail}</div>
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
return (
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
{tail}
<MessageIconActions
text={assistantText(closing.blocks)}
time={closing.time}
runMs={runMs}
ttftMs={data.ttftMs}
tokensPerSecond={data.tokensPerSecond}
clock="end"
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
t={t}
/>
</div>
)
})

View File

@@ -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<string, CompactionSummaryNode>
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
} {
const commandsBySource = new Map<number, CommandNode | null>()
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<number, CompactionSummaryNode | null>()
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<string, CompactionSummaryNode>()
const byCompactionSeq = new Map<number, CommandNode>()
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<number, number>,
): ReadonlySet<number> {
const lastByTurn = new Map<number, number>()
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<number, number>,
): ReadonlySet<number> {
const result = new Set<number>()
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('|')
}

View File

@@ -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))
}

View File

@@ -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<ConversationSnapshot['chat']['nodes']['get']>): 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
}

View File

@@ -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('')
}

View File

@@ -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<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
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<number, TurnMetrics> {
export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue

View File

@@ -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 = Extract<keyof ChatNodeDataMap, string>
/** Final Chat Node narrowed to one registered renderer kind and payload. */
export type ChatNode<Kind extends ChatNodeKind = ChatNodeKind> = {
[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 non-rendered later evidence 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<ToolCallBlock, { kind: 'tool-result' }> {
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)
}

View File

@@ -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<Kind> } }
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
@@ -48,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. */
@@ -199,7 +206,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.
*/
@@ -214,14 +221,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
/**
@@ -231,34 +237,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:<id>"`
* and `data-chat-call-id="<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 extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
) => Readonly<ConversationTurnDataMap[Key]> | 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<Kind extends ChatNodeKind = ChatNodeKind> =
PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'>
/** Owner currency of the details panel's Tool output renderer. */
export interface DetailsToolOwnerProps {
/** Frozen selected call slice. */
@@ -584,7 +590,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<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**

View File

@@ -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 {} from '@deepseek-ai/dsh-llm-retry/types'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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<AssistantState>,
): 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 + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant,
time: boundary.time,
turn: state.turn,
step: state.step,
blocks,
interrupted: true,
}
}
function fallbackState(context: ConversationNodeContext<AssistantState>): 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 === '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<AssistantState>): 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<AssistantState> = {
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 === 'llm/retry') {
return { id: `${event.data.turn}:${event.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 === '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)
}

View File

@@ -0,0 +1,459 @@
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<T>(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<string, ChatConversationViewNode>()
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<number, readonly string[]>()
private steps = new Map<string, readonly string[]>()
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<number, string[]>()
const steps = new Map<string, string[]>()
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<number>()
const steps = new Set<string>()
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<Key>(
previous: ReadonlyMap<Key, readonly string[]>,
nextMutable: ReadonlyMap<Key, string[]>,
): Map<Key, readonly string[]> {
const next = new Map<Key, readonly string[]>()
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
// 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':
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 for StatsLine and legacy top-level snapshot fields. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()
private readonly runningContributions = new Map<string, LegacyContribution>()
private readonly partialContributions = new Map<string, LegacyContribution>()
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<number, { startTime: number; endTime?: number }>()
const turnEnds = new Map<number, number>()
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<string, LegacyContribution>,
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<ChatConversationViewNode, ChatSnapshot> {
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<ChatConversationViewNode, ChatSnapshot> = {
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)
}

View File

@@ -0,0 +1,229 @@
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 { 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'
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: CompactCheckpointSource['plugin'] = 'compact'
interface CommandState {
readonly command: CommandNode
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
interface CompactionEvidence {
readonly summary?: ConversationMatch
readonly checkpoint?: ConversationMatch
}
function commandFromRun(match: ConversationMatch): CommandNode {
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,
time: match.event.time,
commandId: data.commandId,
name: data.name,
args: data.args ?? null,
outcome: null,
}
}
function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode {
if (match.event.type !== 'command/done') throw new Error('command update requires command/done')
const data = match.event.data
const sourceEventSeq = data.kind === 'success'
&& data.sourceEventSeq !== undefined
&& Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0
? data.sourceEventSeq
: 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<ConversationNodeDefinition['match']>[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?.event.type === 'compact/summary') {
const data = match.event.data
if (Array.isArray(data.summary)) {
const text = data.summary
.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 >= 0)
? data.shadowedSeqs.length
: null
shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount)
&& data.shadowedTokenCount >= 0
? data.shadowedTokenCount
: 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>): CommandState | undefined {
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 === '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 extends CompactionEvidence>(
state: State,
match: ConversationMatch,
): State {
if (match.event.type === '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<CommandState> = {
kind: 'command',
match: (event) => {
if (event.type === 'command/run') {
return { id: String(event.data.commandId), role: 'start' }
}
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 === '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 === '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 }

View File

@@ -0,0 +1,65 @@
import type {
ConversationLocation, ConversationNodeContext,
} from '@deepseek-ai/dsh-client-runtime/client'
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.
* @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<Kind extends ChatNodeKind>(
context: ConversationNodeContext,
kind: Kind,
anchorSeq: number,
data: ChatNodeDataMap[Kind],
options: {
readonly location?: ConversationLocation
readonly visibility?: 'visible' | 'hidden'
} = {},
): ChatNode<Kind> {
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
}

View File

@@ -0,0 +1,65 @@
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'
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>): CompactionState {
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 },
...checkpoint === undefined ? {} : { checkpoint },
}
}
/** Automatic compaction lifecycle and landed checkpoint Definition. */
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
kind: 'compaction',
match: (event) => {
const checkpoint = compactSource(event)
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
return { id: checkpoint.compactionId, role: 'update' }
}
if (event.type === 'compact/start'
|| event.type === 'compact/summary'
|| event.type === 'compact/end') {
if (event.data.sourceCommandId !== undefined) return null
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
},
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)
}

View File

@@ -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<UnknownSurfaceNode> = {
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)
}

View File

@@ -0,0 +1,70 @@
import type { Context } from 'cordis'
import type {
ConversationNodeDefinition, ConversationPreviousContext,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
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<string>
}
function applySplice(
previous: ConversationPreviousContext<InboxState> | 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<InboxState> {
const kind = `inbox-${target}`
return {
kind,
match: event => event.type === 'agent/inbox/spliced'
&& event.data.target === target
? { id: String(event.seq), role: 'start' }
: null,
start: (_context, match, reader) => {
if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`)
return applySplice(reader.previous<InboxState>(kind), match.event.data)
},
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)
}

View File

@@ -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<ConversationNodeDefinition['match']>[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<MessageNode> = {
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<InboxState>('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)
}

View File

@@ -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)
}

View File

@@ -0,0 +1,96 @@
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'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Producer-correlated model retry chain. */
'model-retry': RetryChatData
}
}
/** Accumulated retry attempts sharing one producer-owned RetryId. */
export interface RetryState {
readonly turn: number
readonly step: number
readonly attempts: readonly ModelRetryNode[]
}
function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]): ModelRetryNode | undefined {
if (match.event.type !== 'llm/retry') return undefined
return {
kind: 'model-retry',
seq: match.event.seq,
time: match.event.time,
retryState: 'scheduled',
...match.event.data,
}
}
/** 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')
}
/** Producer-correlated model retry chain Definition. */
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
kind: 'model-retry',
match: (event) => {
if (event.type === 'llm/retry') {
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') {
const retryId: unknown = event.data.retryId
return typeof retryId === 'string' && retryId !== '' ? { id: retryId, role: 'update' } : null
}
return null
},
start: (_context, match) => {
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 === 'llm/retry') {
const node = scheduledNode(match)
return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] }
}
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 === 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)
}

View File

@@ -0,0 +1,277 @@
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 {} from '@deepseek-ai/dsh-tools/types'
import type { ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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<string, readonly ToolCallBlock[]>
readonly parents: ReadonlyMap<string, string>
}
interface ProjectedBlockCache {
readonly children: readonly ToolCallBlock[]
readonly interruptionSeq: number | undefined
readonly interruptionTime: number | undefined
readonly value: ToolCallBlock
}
const projectedBlocks = new WeakMap<ToolCallBlock, ProjectedBlockCache>()
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<string>()
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<string>()
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 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(parentCallId, [...siblings, childCall(match, data)])
const parents = new Map(state.parents)
parents.set(subCallId, parentCallId)
return { ...state, children, parents }
}
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(parentCallId, index < 0
? [...siblings, settled]
: siblings.map((child, at) => at === index ? settled : child))
const parents = new Map(state.parents)
if (index < 0) parents.set(subCallId, parentCallId)
return { ...state, children, parents }
}
function projectBlock(
block: ToolCallBlock,
state: ToolState,
interruptedAt: { seq: number; time: number } | undefined,
visited = new Set<string>(),
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 + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
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<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function interruption(context: ConversationNodeContext<ToolState>): { 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>): 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<ToolState> = {
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 === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
const rootCallId: unknown = event.data.rootCallId
return typeof rootCallId === 'string' && rootCallId !== ''
? { id: rootCallId, role: 'update' }
: null
}
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)
}

View File

@@ -0,0 +1,113 @@
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 type {} from '@deepseek-ai/dsh-llm-retry/types'
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<TurnErrorState>): 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<ConversationNodeDefinition['match']>[0]): number | undefined {
return event.type === 'llm/retry' || event.type === 'llm/retry-started'
? event.data.turn
: 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>): 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<TurnErrorState> = {
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)
}

View File

@@ -0,0 +1,196 @@
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 {} from '@deepseek-ai/dsh-llm-retry/types'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, 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<ConversationNodeDefinition['match']>[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<ConversationNodeDefinition['match']>[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<ConversationNodeDefinition['match']>[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 === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
return undefined
}
function closingAnchor(context: ConversationNodeContext<TurnTailState>): 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<number, StepEvidence>()
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 + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup
}
continue
}
if (event.type === 'llm/retry') {
steps.set(coordinates.step, { streamedText: false, finalized: false })
continue
}
if (event.type === 'step/end' && previous.streamedText && !previous.finalized) {
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup
}
}
return anchor
}
function turnLocation(context: ConversationNodeContext<TurnTailState>): 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<TurnTailState>): 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<AssistantChatData> => candidate !== undefined)
const finalized = assistants
.filter((candidate): candidate is Readonly<FinalAssistantChatData> => candidate.finalNode !== undefined)
.sort((left, right) => left.finalNode.seq - right.finalNode.seq)
const closing = finalized.findLast(hasText) ?? null
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 === '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 || latestTranscriptSeq !== 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<TurnTailState> = {
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' }
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
},
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)
}

View File

@@ -3,6 +3,16 @@
* 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 type { IConversation } from './service.ts'
@@ -12,12 +22,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.

View File

@@ -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 <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
}

View File

@@ -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 {

View File

@@ -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 = () => {}

View File

@@ -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,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.tool')).toEqual({ kind: 'single', 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()
})
@@ -97,7 +99,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()
@@ -110,8 +112,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()

View File

@@ -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<ConversationNode, { kind: 'model-retry' }>['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 <UserMessageNodeView {...props as ChatNodeViewProps<'user' | 'steering'>} />
case 'context':
return <ContextMessageNodeView {...props as ChatNodeViewProps<'context'>} />
case 'compaction':
return <CompactionNodeView {...props as ChatNodeViewProps<'compaction'>} />
case 'model-retry':
return <RetryNodeView {...props as ChatNodeViewProps<'model-retry'>} />
case 'unknown':
return <UnknownNodeView {...props as ChatNodeViewProps<'unknown'>} />
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(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 5,
time: 10_000,
retryState: 'scheduled',
@@ -761,9 +806,9 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem
t={t}
retryActive
node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 6,
time: 12_100,
retryState: 'scheduled',
@@ -788,6 +833,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 6,
time: 12_100,
retryState: 'started',
@@ -809,6 +855,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 7,
time: 12_100,
retryState: 'started',
@@ -828,6 +875,7 @@ describe('MessageItem arms', () => {
view.rerender(
<MessageItem t={t} node={{
kind: 'model-retry',
retryId: RETRY_ID,
seq: 8,
time: 12_100,
retryState: 'cancelled',
@@ -846,32 +894,6 @@ describe('MessageItem arms', () => {
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(<MessageItem t={t} node={node} />)
expect(view.getByRole('status').textContent).toBe('等待重试模型请求1/2 · 5s')
act(() => { vi.advanceTimersByTime(4_200) })
view.rerender(<MessageItem t={t} node={node} retryActive />)
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(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
streaming={false}
time={time}
seq={3}
onFork={onFork}
/>,
)
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(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
streaming={false}
time={time}
/>,
)
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
expect(thinkOnly.queryByText('14:24')).toBeNull()
thinkOnly.unmount()
const streaming = render(
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
)
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(
<AssistantMarkdown
t={t}
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
streaming={false}
time={1_000}
seq={1}
onFork={onFork}
forkUnavailable
/>,
)
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(
<StatsLine

View File

@@ -0,0 +1,298 @@
import type {
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore,
ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall,
ToolCallBlock, TurnLocation,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
const EMPTY: readonly never[] = []
function sameValues<T>(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<typeof assistantData>
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<string, ChatConversationViewNode>()
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<string, ChatConversationViewNode>()
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<number, readonly string[]>()
getTurn(turn: number): readonly string[] {
return this.turns.get(turn) ?? EMPTY
}
getStep(): readonly string[] {
return EMPTY
}
replace(next: ReadonlyMap<number, readonly string[]>): void {
const stable = new Map<number, readonly string[]>()
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<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): Readonly<ConversationTurnDataMap[Key]> | undefined {
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
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<number, TurnLocation>,
): 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<number, TurnLocation>()
const turnData = new Map<number, FixtureTurnDataStore>()
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<CompactionSummaryNode>()
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<typeof assistantData>).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<number, readonly string[]>()
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,
}
}

View File

@@ -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<ConversationSnapshot>) {
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<ConversationSnapshot>) => {
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: {

View File

@@ -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<ConversationSnapshot>) {
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<ConversationSnapshot>) => {
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<ConversationSnapshot>) {
// 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 (
<div
data-testid={`tool-seat-${tool.callId}`}
data-chat-anchor-key={`call:${tool.callId}`}
data-chat-call-id={tool.callId}
>
{tool.toolName || '(unnamed)'}:{tool.callId}
</div>
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<typeof CommandNodeView>['renderSlot']
const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
const renderTurnTailSlot = (() => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['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: 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 = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
{ ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
)
switch (nodeOwner.node.kind) {
case 'user':
case 'steering':
return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
case 'context':
return <ContextMessageNodeView {...nodeProps<'context'>()} />
case 'assistant-step':
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
case 'command':
return (
<CommandNodeView
{...nodeProps<'command'>()}
renderSlot={renderCommandSlot}
SessionProvider={props.SessionProvider}
/>
)
case 'manual-compaction':
return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
case 'compaction':
return <CompactionNodeView {...nodeProps<'compaction'>()} />
case 'model-retry':
return <RetryNodeView {...nodeProps<'model-retry'>()} />
case 'turn-error':
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
case 'turn-tail':
return (
<TurnTailNodeView
{...nodeProps<'turn-tail'>()}
renderSlot={renderTurnTailSlot}
renderSlotChain={renderTurnTail}
SessionProvider={props.SessionProvider}
/>
)
case 'unknown':
return <UnknownNodeView {...nodeProps<'unknown'>()} />
case 'tool-call': {
const block = nodeOwner.node.data.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 (
<div
data-testid={`tool-seat-${tool.callId}`}
data-chat-anchor-key={`call:${tool.callId}`}
data-chat-call-id={tool.callId}
>
{tool.toolName || '(unnamed)'}:{tool.callId}
</div>
)
}
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<ConversationSnapshot>) {
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
// 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,29 @@ describe('ChatView', () => {
expect(h.forkAt.mock.calls).toEqual([[2]])
})
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
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(<h.ChatView {...h.props} />)
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,
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
@@ -843,19 +798,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 = (
<Profiler id="chat" onRender={() => { renders += 1 }}>
<h.ChatView {...h.props} />
</Profiler>
)
const view = render(counting)
const before = renders
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
const view = render(<h.ChatView {...h.props} />)
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 +812,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 +823,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 <div data-testid="counting-row" />
})
@@ -908,6 +857,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.root
return (
<div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
{root.callId}
</div>
)
}
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'
? <StatefulToolNode node={routed.node} />
: opts?.fallback ?? null
}) as ChatViewSlotProps['renderSlot']
const view = render(<h.ChatView {...h.props} />)
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 +929,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 +940,14 @@ describe('ChatView', () => {
render(<h.ChatView {...h.props} />)
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 +957,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 +974,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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
@@ -1177,7 +1120,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 +1159,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(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(1_500)
expect(h.chatScroll.read()).toBeNull()

View File

@@ -0,0 +1,862 @@
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, TurnTailChatData,
} 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<string, unknown> = {},
): 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('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' }),
], 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('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', {
...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('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('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('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', {
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 },
})
})
})

View File

@@ -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 <div data-testid="tool-details-seat" />
}
}
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<SessionListState>(

View File

@@ -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> = {}): 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,

View File

@@ -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<ConversationSnapshot>({
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,

View File

@@ -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<ConversationSnapshot>({
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,

View File

@@ -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,
}

View File

@@ -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> = {}): 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,

View File

@@ -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"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2
README.zh.md: ba493549bd0bc3f8a2adbda5989448e497f0af93
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d

View File

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

View File

@@ -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)。

View File

@@ -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',

View File

@@ -1,17 +1,44 @@
/**
* 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<string, ToolResultNode['callView']>
}
/**
* 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 []
@@ -23,9 +50,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 +62,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<string>()
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<DeliverablesTurnData> | undefined,
seq = Number.POSITIVE_INFINITY,
): readonly string[] {
if (data === undefined) return []
const paths: string[] = []
const seen = new Set<string>()
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 +90,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<DeliverablesState> = {
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.

View File

@@ -1,22 +1,29 @@
// @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.
*/
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, ConversationMatch, 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,238 @@ 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<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
): Readonly<ConversationTurnDataMap[Key]> | undefined {
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
}
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
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<readonly [seq: number, path: string]>): 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<ConversationViewNode, TimelineSnapshot> = {
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 matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
}
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 turns 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<DeliverablesTurnData> | 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('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<typeof deliverablesDefinition.start>[0] = {
key: 'deliverables:1',
kind: 'deliverables',
id: '1',
matches: [startMatch],
start: startMatch,
state: undefined,
current: new Map(),
}
const reader: Parameters<typeof deliverablesDefinition.start>[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<typeof deliverablesDefinition.update>[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')),
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 +334,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 +349,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)

View File

@@ -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<keyof typeof CHILD_SPECS>) {
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<keyof typeof CHILD_SPECS>) {
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<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toBeUndefined()
}
})
})

View File

@@ -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<string, object>
/**
* 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<SlotMap[P]>` in {@link ChildrenDecl}), so type and value
* are declared at one point and validate each other.
* entry (`SlotSpec<SlotMap[P]>` in {@link ChildrenDecl}), so kind, scope, and
* any common inject face are declared at one point and validate each other.
*/
export interface SlotSpec<E extends SlotEntryDef> { kind: E['kind']; scope: E['scope'] }
export type SlotSpec<E extends SlotEntryDef> = {
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[P]
export type OwnerOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { owner: infer O extends object } ? O : object
/** Registration/dispatch key domain of one keyed slot. */
export type EntryKeyOf<K extends keyof SlotMap & string> =
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<K>,
> = 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<K extends keyof SlotMap & string> =
SlotMap[K] extends { hookContext: infer Context } ? Context : never
/** Common render-occurrence inject face declared by one slot. */
export type SlotInjectOf<K extends keyof SlotMap & string> =
SlotMap[K] extends { inject: infer Injected extends object } ? Injected : object
/** Scope axis of a slot key's SlotMap entry. */
export type ScopeOf<K extends keyof SlotMap & string> = 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<K extends keyof SlotMap & string> =
export type PropsRuntime<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
> =
OwnerOf<K> &
KeyPropsOf<K, EntryKey> &
SlotInjectFace<SlotInjectOf<K>> &
(ScopeOf<K> extends 'session' ? SessionStandardProps
: ScopeOf<K> 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 extends string = string> {
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<O extends object, M> = (owner: O) => M | null
export type ChainKeysOf<S extends keyof SlotMap & string> =
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 keyof SlotMap & string> =
S extends unknown ? (SlotMap[S] extends { hookContext: unknown } ? S : never) : never
/** Keys in a render share with the ordinary optional options bag. */
type OrdinaryKeysOf<S extends keyof SlotMap & string> = Exclude<S, ContextualKeysOf<S>>
/**
* 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<S extends keyof SlotMap & string> =
([ContextualKeysOf<S>] extends [never] ? object : {
<
K extends ContextualKeysOf<S>,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
>(
key: K,
owner: OwnerOf<K> & KeyPropsOf<K, NoInfer<EntryKey>>,
opts: RenderOpts<EntryKey> & { hookContext: HookContextOf<K> },
): ReactNode
}) &
([OrdinaryKeysOf<S>] extends [never] ? object : {
<
K extends OrdinaryKeysOf<S>,
EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
>(
key: K,
owner: OwnerOf<K> & KeyPropsOf<K, NoInfer<EntryKey>>,
opts?: Omit<RenderOpts<EntryKey>, '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<S extends keyof SlotMap & string> = {
* @param opts - kind dispatch options.
* @returns rendered node(s).
*/
renderSlot: <K extends Exclude<S, ChainKeysOf<S>>>(key: K, owner: OwnerOf<K>, opts?: RenderOpts) => ReactNode
renderSlot: RenderSlotFn<Exclude<S, ChainKeysOf<S>>>
readonly __renders?: ((key: S) => void) | undefined
} & ([ChainKeysOf<S>] extends [never] ? object : {
/**
@@ -277,19 +371,53 @@ export type SlotComponent<P> = (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<Name>` 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<string, HostObservable<unknown>>
/** Framework-owned props visible while a slot-level contextual Hook is bound. */
export type StandardPropsOf<K extends keyof SlotMap & string> =
(ScopeOf<K> extends 'session' ? SessionStandardProps
: ScopeOf<K> 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<K>,
hookContext: HookContextOf<K>,
) => Hook
/** Component-side Hook produced from one slot-level inject.hooks member. */
type BoundHookOf<Definition> =
Definition extends HostObservable<infer Snapshot>
? SnapshotSelectorHook<Snapshot>
: 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<Name>` selector hook over its snapshot type.
*/
export type PropsSlotHooks<HS extends object> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
BoundHookOf<HS[N]>
}
/** Component-side view of a slot dispatcher's common inject face. */
export type SlotInjectFace<I extends object> =
I extends { hooks: infer HS extends object } ? Omit<I, 'hooks'> & PropsSlotHooks<HS> : I
/** Selector-hook share synthesized from an entry inject hooks compartment. */
export type PropsHooks<HS extends HooksSources> = {
[N in keyof HS & string as `use${Capitalize<N>}`]:
SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
@@ -313,12 +441,13 @@ export type InjectFace<I extends object> =
*/
export type ComposedProps<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
S extends keyof SlotMap & string,
H,
I extends object,
M = never,
N = undefined,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
> = PropsRuntime<K, EntryKey> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
/**
* Inject factory parameter list, derived from the registration's declaration:
@@ -345,12 +474,16 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
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 extends SlotEntryDef, M = never> =
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<K>,
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<E extends { owner: infer O extends object } ? O : object, M>
select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
/** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
priority?: number
}
@@ -372,7 +505,14 @@ type RendersCheck<C, D> =
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
type BaseOptions<
K extends keyof SlotMap & string,
EntryKey extends EntryKeyOf<K>,
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<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M
locale?: N
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K], M>
} & KindOptions<K, EntryKey, M>
/**
* One stored registration, as recorded by the core and read by the render
@@ -528,15 +668,19 @@ export class SlotCore {
* would lose the per-overload inference of I. */
register<
K extends keyof SlotMap & string,
const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
options: BaseOptions<K, EntryKey, D, H, M, N> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& SlotComponent<ComposedProps<
K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string,
HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>
>>
& RendersCheck<C, D>,
): () => void
/**
@@ -552,15 +696,19 @@ export class SlotCore {
register<
K extends keyof SlotMap & string,
I extends object,
const EntryKey extends EntryKeyOf<K> = EntryKeyOf<K>,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
options: BaseOptions<K, EntryKey, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& SlotComponent<ComposedProps<
K, NoInfer<EntryKey>, keyof NoInfer<D> & keyof SlotMap & string,
HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>
>>
& RendersCheck<C, D>,
): () => void
/* jscpd:ignore-end */

View File

@@ -87,11 +87,13 @@ export interface SessionProvideInfo extends SessionMaybeProvideInfo {
hooks: Record<string, HostObservable<unknown>>
}
/** 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. */

View File

@@ -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)

View File

@@ -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<ChatHandle>
& { send: (t: string) => void }
interface TurnDataMap { tail: string; files: string }
type UseTurnData = <Key extends keyof TurnDataMap>(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<ReturnType<typeof createPanelStore>>): 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<never>)
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

Some files were not shown because too many files have changed in this diff Show More