Merge pull request #956 from deepseek-harness/fix/web-transcript-projection

fix(web): project the human transcript from append-origin events
This commit is contained in:
hypatiamay
2026-07-31 17:32:21 +08:00
committed by GitHub
74 changed files with 1625 additions and 804 deletions
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-gui-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642
2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb
2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c
@@ -50,7 +50,7 @@ There is no registration model besides slots — the former view and tool rings
## The data object layer (`packages/client/runtime/src/client/sessions/`)
Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable):
Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable):
```
mux/host 帧(ConnectionController 泵入,sinks 注入)
@@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope
Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
FoldAdapter PartialAccumulator
TranscriptAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (folded, surface-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references.
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references.
- **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
- **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory.
## The React face (`packages/client/web-react`)
@@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope
Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
FoldAdapter PartialAccumulator
TranscriptAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session**session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量 foldopen/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts):不可变快照契约——`nodes`fold 产物,surface 序)、`partial``runningCalls``pending``running``removed``openState``hasMore``promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;nodes 数组重建但元素引用来自缓存;未变的子结构复用上一快照的引用。
- **Session**session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts):不可变快照契约——`nodes`人类对话记录,日志序)、`partial``runningCalls``pending``running``removed``openState``hasMore``promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用。
- **SessionManager**manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
- **Notifier**notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
- **FoldAdapter / PartialAccumulator**fold 复用核心 SurfaceManager`@deepseek-ai/dsh-session/surface`),垫哨兵事件使 seq > 0 起头的分页窗口满足核心 `seq === index` 断言;跨窗口 replace 时降级为容错线性扫描并置 `foldDegraded`。分片完全不进 fold(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
- **TranscriptAdapter / PartialAccumulator**对话记录是按日志顺序投影的 append 来源 surface`@deepseek-ai/dsh-session/surface``isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`Web 承载(HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。
## React 面(`packages/client/web-react`
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
2026-07-29-human-transcript-append-origin.md: a296b93d538d9c28bd61ee8fd0530863b4bfd878
2026-07-29-human-transcript-append-origin.zh.md: 96e0cd1038fe8904dfd4c1eceaae9b25339c5dca
2026-07-29-human-transcript-append-origin.md: dcc4a786c6f1926f06dce03124ec1d8ca805d7ae
2026-07-29-human-transcript-append-origin.zh.md: 0fefc52afa52e99cdec2bcea1a86b9c28711dd67
@@ -24,9 +24,9 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa
## Deferred
The browser client still builds its conversation from the model surface through `FoldAdapter`, so compaction still collapses web history to a single context row. The same predicate is the fix there, together with an append-order transcript projection and a marker component; that work is a separate change against `packages/client/runtime` and `packages/client/ui-conversation`.
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page.
That work must handle a page whose checkpoint cites a `surfaceOp.start` outside the window: pagination no longer spends quota on the checkpoint, so it never cuts on the checkpoint's provenance group, and `FoldAdapter` pads absent events with a non-surface sentinel — so `SurfaceManager` rejects the range and `nodes()` falls back to `degradedSeqs()` with a logged error. The hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. `degradedSeqs()` — every surface-eligible event in append order — is already close to the transcript projection A2 needs, which is the shape to build deliberately rather than reach as a degradation. Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is likewise out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
## Alternatives considered
@@ -24,9 +24,9 @@ Status: implemented
## Deferred
浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话,因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词,另需按追加顺序的记录投影与一个标记组件;该工作是针对 `packages/client/runtime``packages/client/ui-conversation` 的独立变更
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页
该工作必须处理这样一页:其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度,因此永远不会按检查点的溯源分组切分;而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影,因此那正是应当刻意构建的形态,而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,同样不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
## Alternatives considered
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
2026-07-30-web-transcript-log-ordered-projection.md: 9fb338643774efaeb9deab6f66920a9f4276ce67
2026-07-30-web-transcript-log-ordered-projection.zh.md: 7962dd432b8bbf115acde9dd480eba9c91f35bfc
@@ -0,0 +1,72 @@
# Agent Note: The browser conversation is a log-ordered human transcript
Status: implemented
English | [中文](2026-07-30-web-transcript-log-ordered-projection.zh.md)
## Problem
The browser client built its conversation from the model-visible surface: `FoldAdapter` ran the core `SurfaceManager` over the history window and read `surface.nodes`. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the web flow collapsed every message it shadowed into a single dim context row — conversation the user had already read. Nothing was lost from the log; the defect was entirely in the projection, and [the terminal and the host gateway were fixed the same way](2026-07-29-human-transcript-append-origin.md) while the browser was left for this change.
Surface order made two further problems structural. It is not seq-ascending after a replacement — `SurfaceManager` splices the high-seq checkpoint into the position of the range it shadows — so log-only nodes merged into that array by numeric seq (slash-command rows, interrupted frozen nodes) could be flushed ahead of the checkpoint and never interleave into the retained tail again. And because pagination no longer spends `maxMessages` quota on replacement copies, a page can now carry a checkpoint whose `surfaceOp.start` lies outside the window; the core fold rejects that range, so `nodes()` fell back to a lenient linear scan behind a `console.error` and published a `foldDegraded` flag describing the failure.
## Decision
`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It 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. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends.
Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged.
`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended.
The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text.
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
## Recognizing a checkpoint: one declaration, pinned at compile time
Recognition needs all three conditions, as in the terminal: `event.type === 'user/message'`, the compaction seam's checkpoint plugin source, **and** `isReplacementSurfaceEvent(event)`. A plugin-sourced `user/message` that *appends* is injected context — a session-reference card — not a compaction.
What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root**, not the package. The root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against the client's `sessions: ISessions``TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout) — and that holds for a type-only import too, because the collision is a compiler fact rather than a bundler one.
The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it.
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
## What #835's positional anchors were for, and why they are dissolved rather than lost
The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here.
## Alternatives considered
**Value-import the predicate** from the new leaf and add `dsh-compact` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf.
**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk.
**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate.
**Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product.
**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract.
## Consequences
Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files.
`ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor.
The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves.
The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface.
## Deferred
Compaction **progress** — an indicator while a compaction runs — needs the bracket-first ordering the queued manual-compaction work introduces, and stays out of scope here as it did in the terminal. The marker also carries no **scale**: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. Both belong together, where the reader meets the two halves of the same information.
@@ -0,0 +1,72 @@
# Agent Note: 浏览器会话是按日志顺序投影的人类对话记录
Status: implemented
[English](2026-07-30-web-transcript-log-ordered-projection.md) | 中文
## Problem
浏览器客户端从模型可见的 surface 构建会话:`FoldAdapter` 在历史窗口上运行核心 `SurfaceManager` 并读取 `surface.nodes`。一次成功的压缩会用一个检查点节点替换一段 surface 范围,因此该替换一落地,Web 流就把它所遮蔽的每条消息折叠成一行灰暗的上下文——那是用户已经读过的对话。日志中什么都没丢失;缺陷完全在投影层,而[终端与宿主历史网关已按同一方式修复](2026-07-29-human-transcript-append-origin.md),浏览器留给了本次变更。
surface 顺序还让另外两个问题成为结构性的。一次替换之后它并非按 seq 升序——`SurfaceManager` 把高 seq 的检查点拼接到它所遮蔽范围的位置上——因此按数值 seq 归并进该数组的仅日志节点(斜杠命令行、被打断的冻结节点)可能被冲刷到检查点之前,再也无法交错回保留下来的尾部。而且由于分页不再为 replacement 副本消耗 `maxMessages` 额度,一页现在可以携带一个 `surfaceOp.start` 落在窗口之外的检查点;核心 fold 拒绝该范围,于是 `nodes()` 退回到一次宽容的线性扫描、打印一条 `console.error`,并发布一个描述该失败的 `foldDegraded` 标志。
## Decision
`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者现在在两个前端上都已分离。
节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。
`foldDegraded``ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。
标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。
没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。
## 识别检查点:同一份声明,在编译期钉住
识别需要三个条件同时成立,与终端一致:`event.type === 'user/message'`、压缩缝隙的检查点插件来源,**以及** `isReplacementSurfaceEvent(event)`。一条 append 的插件来源 `user/message` 是注入上下文——跨会话引用卡片——不是压缩。
`packages/client/*` 程序无法到达的是 `dsh-compact` 的**根部**,而不是这个包。根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则;这一点对仅类型导入同样成立,因为该冲突是编译器事实而非打包器事实。
本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
重命名缝隙的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失
尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本基线上并不存在,本次也不撰写它。
## Alternatives considered
**从新叶子值导入该谓词**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id,不是谓词——一个类型就够了,而被擦除的导入根本不会抵达纯度门禁,因此无需向它放行任何东西。白名单只在值导入时才有意义,而在那里它是笔糟糕的交换:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行。
**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。
**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议契约变更——为一个纯谓词付出的代价不成比例。
**把冻结节点的归属移进适配器**`nodes(extraNodes)`),像那个未合并分支所做的那样。已拒绝:被打断的节点来自 `Session` 已经在窗口上运行的 `turn/end` 清扫,而在按 seq 单调的记录之上,简单形态就是正确的——适配器返回节点,会话按 seq 归并冻结节点。加宽适配器签名什么也换不到,还会把清扫与它的产物拆开。
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。
## Consequences
压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。
`ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。
性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。
## Deferred
压缩**进度**——压缩运行期间的指示——需要排队式手动压缩工作引入的“先开括号”顺序,与终端一样不在本次范围内。标记同样不携带**规模**信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。两者应当放在一起,读者正是在那里遇到同一份信息的两半。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1
2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c
2026-07-20-gui-testing-system.md: 4a1600bbef7ef795677a446228fcc279a4b53f39
2026-07-20-gui-testing-system.zh.md: 2aa5d7f66783c69964cabf7eb18a018b54528a33
@@ -22,7 +22,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up:
| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
- **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites.
- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details.
@@ -22,7 +22,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` |
| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.md) | `apps/web/tests/*.snapshot.ts``apps/web/tests/smoke-{fixture,real}.e2e.ts``apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。
层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/transcript-adapter)随 2 层同包 tests/ 零假体直测。
- **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。
- **归应用所有的语义快照**读取已构建的 client bundle,通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。
+8 -7
View File
@@ -11,13 +11,14 @@
// masking its credential, without making a model call.
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches after the shipped surface overlay: temp persistenceRoot; local skill
// roots confined to the temp workspace; workspace-context disabled (recorded
// fixtures must not embed this repo's AGENTS.md); session-title-llm disabled
// (its fire-and-forget title call would race the loop for the session's replay
// cursor); webserver pinned to port 0 with the built dist; ordinary keyless
// modes disable llm-deepseek and fill the open llm seam post-boot with
// installLlmReplay on the settled root ctx
// patches after the shipped surface overlay, over the SAME tree (never a
// second yml): temp persistenceRoot; host-level skill roots confined to the
// temp workspace while project skill discovery remains real; workspace-context
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
// session-title-llm disabled (its fire-and-forget title call would race the
// loop for the session's replay cursor); webserver pinned to port 0 with the
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
// llm seam post-boot with installLlmReplay on the settled root ctx
// (the plugin-row path discards the ReplayHandle; the direct install keeps
// assertConsumed for the teardown fixture-consumption check).
import { existsSync } from 'node:fs'
+108 -3
View File
@@ -1,11 +1,12 @@
// Web e2e scenario: seeded history. A recorded session seeded cold through
// the REAL persistence API renders purely from the log — the surface nothing
// else covers: sidebar cold listing, the implicit resume/attach inside the
// history RPC, history-page tool views, and the client fold of historical
// history RPC, history-page tool views, and the client's log-ordered transcript
// events — with ZERO model calls in replay (no replay fixture; a stray stream
// fails loud on the open llm seam). The cold session also carries the one
// keyless command-row surface: an Access-chip pick runs `/permission` on the
// host, so the settled row's copy has a golden here. The seed is a recorded fixture under the
// host, so the settled row's copy has a golden here. The seed is a recorded
// fixture under the
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
// live through the composer (real read tool against seeded workspace files)
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
@@ -34,6 +35,90 @@ const SEED_ID = 'seeded-history-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
/**
* Append a complete, valid compaction transaction over the recorded turn's own
* surface. The recording stays model-authentic and reusable; replay adds this
* deterministic condition before seeding it cold, so the scenario pins the bug
* this change fixes — a landed compaction must not erase history the reader
* already saw — through the real host and the real browser.
* @param raw - the committed seed fixture text.
* @returns the fixture with a compacted turn appended.
*/
function withCompaction(raw: string): string {
const lines = raw.trimEnd().split('\n')
const events = lines.slice(1).map(line => JSON.parse(line) as {
type: string
seq: number
time: number
surfaceOp?: unknown
data?: { turn?: unknown }
})
const surfaceSeqs = events
.filter(event => event.surfaceOp === 'append'
&& (event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'))
.map(event => event.seq)
const first = surfaceSeqs[0]
const last = surfaceSeqs.at(-1)
const tail = events.at(-1)
if (first === undefined || last === undefined || tail === undefined) {
throw new Error('seeded-history compaction requires a non-empty closed surface')
}
// The transaction opens the turn after the recording's last closed one; read
// it from the fixture so a re-recording with a different turn count stays
// valid instead of appending a duplicate turn number.
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
if (typeof lastTurn !== 'number') {
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
}
const turn = lastTurn + 1
let seq = tail.seq + 1
let time = tail.time + 1
/**
* Append one event at the next seq/time.
* @param event - the event body, without seq/time.
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
*/
const at = (event: Record<string, unknown>): number => {
const taken = seq++
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
return taken
}
at({ type: 'turn/start', data: { turn, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } })
const startSeq = at({ type: 'compact/start', data: { turn } })
const summarySeq = at({
type: 'compact/summary',
data: {
summary: [{
type: 'text',
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
}],
shadowedRange: { start: first, end: last },
shadowedSeqs: surfaceSeqs,
shadowedTokenCount: 10_000,
provider: 'snapshot',
model: 'snapshot-compactor',
},
})
at({
type: 'user/message',
data: {
content: [{
type: 'text',
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'compact' },
},
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
at({ type: 'compact/end', data: { turn } })
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
describe('web e2e: seeded history renders through cold resume', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -53,7 +138,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
if (MODE !== 'record') {
const raw = await readFile(SEED, 'utf8')
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
await seedSession(scaffold, raw, SEED_ID)
await seedSession(scaffold, withCompaction(raw), SEED_ID)
}
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -119,11 +204,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
await sessionRow.click()
// Settled barrier for history: the recorded final assistant text renders.
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
// Tool cards render from logged tool/call + tool/result alone (views are
// host-recomputed per page; the generic card is the documented default).
const toolRows = page.locator('[data-variant], [data-sample]')
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
// The bug this fixes: the compaction shadowed the whole recorded surface on
// the model side, and the prompt and full tool output are still on screen.
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
if (agent === undefined) throw new Error('seeded session did not attach an agent')
@@ -230,6 +319,22 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
})
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
const marker = page.getByRole('button', { name: /Context compacted/ })
await marker.waitFor({ timeout: 10_000 })
expect(await marker.getAttribute('aria-expanded')).toBe('false')
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
timeout: 5_000,
}).toBe(1)
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
// Restore the shared page state for any later case.
await marker.click()
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
})
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no
@@ -29,6 +29,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img
@@ -29,6 +29,9 @@
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}}
- button "Context compacted View compaction summary":
- img
- text: Context compacted View compaction summary
- button "Context injection":
- img
- img
+1 -1
View File
@@ -486,7 +486,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext,
Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md)
Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
Source: [`packages/compact/compact/src/index.ts:45`](../../packages/compact/compact/src/index.ts)
## `ctx.credentials` — `Credentials` (abstract seam)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md
compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a
compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b
compaction.md: 3ae4d7e50452b549654b7a7162141a4be505d784
compaction.zh.md: 448c3aaf298b65ebe88565190c5b3978b975f5f3
+1 -1
View File
@@ -60,7 +60,7 @@ Automatic callers state why policy is running; implementations may treat confirm
type CompactionTrigger = 'pressure' | 'context-overflow'
```
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with the package-exported `COMPACT_CHECKPOINT_SOURCE`; consumers call `isCompactCheckpointSource()` instead of coupling checkpoint recognition to one backend. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Every backend marks its replacement `user/message` with `COMPACT_CHECKPOINT_SOURCE`; client and wire consumers import that value and `isCompactCheckpointSource()` from the cordis-free `@deepseek-ai/dsh-compact/checkpoint` subpath, while the package root re-exports both for host consumers. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
Pressure compaction runs at serial `agent/step` before request derivation. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
+1 -1
View File
@@ -60,7 +60,7 @@ interface CompactionResult {
type CompactionTrigger = 'pressure' | 'context-overflow'
```
`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用包导出的 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`消费方调用 `isCompactCheckpointSource()`而不是把检查点识别逻辑耦合到某一个后端。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。
`CompactService` 暴露 `compactIfNeeded(agent, trigger, signal)` 以执行自动 `pressure` 或 `context-overflow` 策略;没有可安全执行的工作时返回 `null`。它还针对显式、两端均包含的 surface 范围暴露 `compactRegion(...)`。每个后端都使用 `COMPACT_CHECKPOINT_SOURCE` 标记其替换用的 `user/message`client 与 wire 消费方从无 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 子路径导入该值和 `isCompactCheckpointSource()`包根则为 host 消费方重新导出两者。实现必须把传入的 signal 转发给摘要流程。该 seam 不拥有计价 API:单例 [`ctx.tokenMeter`](token-meter.md) 直接拥有估算与回放,而 `dsh-compact-basic` 拥有保留策略、事件排序、按路由执行的摘要调用及其配置。
压力压缩在串行 `agent/step` 中运行,先于请求推导。一旦压力或规范化溢出满足条件,compact-basic 会在选择范围前调用可选的 [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compact-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2
README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2
README.md: e8ba80790307e7123406934c1ab11b86dfc0faf3
README.zh.md: 6eb69d9cf10959b007f3759378612dbc013a8904
+7 -1
View File
@@ -24,9 +24,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
## The human transcript
`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. 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. The adapter's plugin literal is pinned to the seam's own 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). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
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 comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. 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.
## Code Mode sub-dispatch index
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
## Session title projection
+10 -4
View File
@@ -24,9 +24,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`
## 面向人的 transcript(文本记录)
`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## Code Mode 子调用索引
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
## Session 标题投影
@@ -34,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
## 会话 fork
@@ -50,10 +56,10 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
#### KV Cache 影响
更改目标可能改变提供方侧的缓存复用,或使其失效;该包package本身不会改变提示词前缀。
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
## 已知限制与暂缓事项
- **`loader.unload` 是 stub(抛出 not-implemented**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
- **`loader.unload` 是 stub(抛出 not-implemented**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()``scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。
+1
View File
@@ -32,6 +32,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+3 -2
View File
@@ -44,8 +44,9 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
@@ -156,7 +156,32 @@ export interface ToolResultNode {
resultView: ToolResultView | null
}
/** Fallback for surface events this UI version does not know. */
/**
* One landed compaction, marked at the checkpoint's own log position. The
* conversation it shadowed on the model surface stays in the transcript above
* it: the marker reports where the model stopped seeing that history, it does
* not replace it. The framed checkpoint payload is an instruction envelope
* written for the model and never renders.
*/
export interface CompactionSummaryNode {
kind: 'compaction'
/** Seq of the replacement `user/message` that landed the checkpoint. */
seq: number
/** Unix epoch ms of the checkpoint event. */
time: number
/** Summary text from the checkpoint's `compact/summary` provenance; null when
* the window cut left that provenance outside (the marker is then not expandable). */
summary: string | null
}
/**
* 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
* `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.
*/
export interface UnknownSurfaceNode {
kind: 'unknown'
seq: number
@@ -169,7 +194,7 @@ 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 never enter the surface fold, so the FoldAdapter indexes
* 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
* 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
@@ -200,6 +225,7 @@ export type ConversationNode =
| ModelRetryNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode
| UnknownSurfaceNode
/**
@@ -209,7 +235,7 @@ export type ConversationNode =
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the surface `nodes` flow — sub-calls live under their parent via
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
@@ -280,10 +306,8 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
/** Finalized surface events and durable operational notices in event order. */
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
@@ -1,283 +0,0 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
// the degradation lives in one branch function in this file, zero scattered removal points).
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 {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
} from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
export 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
}
/** Non-surface sentinel used to preserve paged-window sequence offsets.
* `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface behavior; this cast is the only synthetic event entry point.
*/
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', 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,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
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,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, 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,
}
}
}
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
private baseSeq = 0
private surface = new SurfaceManager(this.padded)
private nodeCache = new Map<number, ConversationNode>()
private degraded = false
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so the surface fold never
* emits it; this index folds the pair (done settles its run's node in
* place) and nodes() merges the products into the flow 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>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
private rev = 0
private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
/** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
get callIndex(): ReadonlyMap<string, CallIndexEntry> {
return this.callIdx
}
/**
* Window rebuild (after open/resync/page prepend): new padded array, new
* SurfaceManager, cleared cache, rebuilt callIndex.
* @param events - the new window contents (seq-ascending).
* @param baseSeq - seq of the window head (sentinels pad below it).
* @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
*/
reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
this.rev++
this.baseSeq = baseSeq
this.padded = []
for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = 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) {
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
}
}
/**
* Tail append (live session/event): push into the same array (incremental
* lazy fold applies) + incremental callIndex upkeep.
* @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.rev++
this.padded.push(event)
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.indexCall(event, view)
this.indexCommand(event)
}
/**
* Current node array + degradation flag. Same revision -> same array
* reference (memo boundary); node object references always come from the per-seq cache.
* @returns the fold projection for the current window revision.
*/
nodes(): { nodes: ConversationNode[]; degraded: boolean } {
if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
let seqs: readonly number[]
if (this.degraded) {
seqs = this.degradedSeqs()
} else {
try {
seqs = this.surface.nodes
} catch (error) {
console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
this.degraded = true
seqs = this.degradedSeqs()
}
}
const out: ConversationNode[] = []
for (const seq of seqs) {
const cached = this.nodeCache.get(seq)
if (cached !== undefined) {
out.push(cached)
continue
}
const event = this.padded[seq]
/* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
if (event === undefined) continue
const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
this.nodeCache.set(seq, node)
out.push(node)
}
// Command nodes fold outside the surface (log-only events); merge by seq.
// Both inputs are seq-ascending (surface order and run-index insertion
// order share the log order), so one linear merge keeps flow order.
let nodes = out
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of out) {
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)
}
const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
/** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
private degradedSeqs(): number[] {
const seqs: number[] = []
for (let i = this.baseSeq; i < this.padded.length; i++) {
const event = this.padded[i]
if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
}
return seqs
}
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
private indexCommand(event: SessionEvent): void {
// 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, outcome: null,
})
return
}
if ((event.type as string) !== 'command/done') return
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
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
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
}
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).
}
}
@@ -18,7 +18,7 @@ import type {
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
@@ -90,11 +90,12 @@ export class Session implements SessionFace {
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private readonly foldAdapter = new FoldAdapter()
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
/** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
* Derived from window events rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
* 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
@@ -106,7 +107,7 @@ export class Session implements SessionFace {
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
@@ -336,7 +337,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.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.transcript.reset(this.events, this.views) // prepend forces a rebuild (the window grew at the head)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
@@ -569,7 +570,7 @@ export class Session implements SessionFace {
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
@@ -584,14 +585,15 @@ export class Session implements SessionFace {
if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
this.events.push(event)
this.views.push(view)
this.foldAdapter.append(event, view)
this.transcript.append(event, view)
this.applyEventSideEffects(event, view)
}
/** 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 — never fed to the fold to trip
* its continuity assertion into the degraded view). */
* 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 own provenance. */
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -833,18 +835,18 @@ export class Session implements SessionFace {
}
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
// Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
// The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
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.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
nodes = this.derivedNodes.length === 0
? folded
: [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
? 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()] }
@@ -862,7 +864,6 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
@@ -0,0 +1,328 @@
// 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 seam's own
// 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'
/**
* The compaction seam's checkpoint plugin, pinned to the seam's own 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.
* `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally.
*/
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 eight-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal 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,
}
}
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,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.message.content, source: event.data.message.source,
}
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,
}
}
/* 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
}
/**
* One landed checkpoint -> the human-facing compaction marker. The summary text
* comes from the checkpoint's own provenance (`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
* provenance 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
for (const seq of sources ?? []) {
const candidate = eventIndex.get(seq)
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
summary = compactSummaryText(candidate)
break
}
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
}
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
export class TranscriptAdapter {
/** Window events by seq: provenance lookup for a checkpoint's summary. */
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>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* 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()
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)
}
// 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))
}
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)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
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): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
}
/**
* 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, 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 }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
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).
}
}
@@ -0,0 +1,49 @@
/**
* Behavioral half of the compaction-checkpoint drift trap.
*
* `TranscriptAdapter` pins its plugin literal to the seam's own declaration at
* compile time through a type-only import of `dsh-compact/checkpoint`, so
* renaming the seam'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 seam's own 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 seam's own 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 }])
})
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 seam
// 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' })
})
})
@@ -87,6 +87,27 @@ export const ev = {
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
/** A compaction's log-only `compact/summary` provenance record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compact/summary', data: {
summary: text(summary),
shadowedRange: { start, end },
shadowedSeqs: [start, end],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
} }),
/** The replacement user message a compaction backend lands (the checkpoint). */
compactCheckpoint: (seq: number, summarySeq: number, start: number, end: number): SessionEvent =>
at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [summarySeq, start, end],
data: createUserMessage({
content: text('<context_checkpoint>model only</context_checkpoint>'),
source: { kind: 'plugin', plugin: 'compact' },
}),
}),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
@@ -1,399 +0,0 @@
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
* materialization, call-index backfill, and the degraded linear-scan branch.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
import { projectConversationHistory } from '../src/client/session-history/history-fold.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
describe('FoldAdapter', () => {
it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
const adapter = new FoldAdapter()
const window = plainTurn(100, 5, '偏移问', '偏移答')
adapter.reset(window, 100)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(false)
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
})
it('appends incrementally keeping old node references (cache identity)', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
const first = adapter.nodes()
expect(adapter.nodes()).toBe(first)
adapter.append(ev.user(6, '追加'))
const second = adapter.nodes()
expect(second.nodes).toHaveLength(3)
expect(second.nodes[0]).toBe(first.nodes[0])
expect(second.nodes[1]).toBe(first.nodes[1])
expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
})
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('materializes all six node variants with field mapping', () => {
const adapter = new FoldAdapter()
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
adapter.reset(events, 0)
const { nodes } = adapter.nodes()
const kinds = nodes.map(n => n.kind)
expect(kinds).toContain('user')
expect(kinds).toContain('assistant')
expect(kinds).toContain('steering')
expect(kinds).toContain('context')
const result = nodes.find(n => n.kind === 'tool-result')
expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
})
it('returns call:null for a tool-result whose call fell outside the window', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
const { nodes } = adapter.nodes()
expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
})
it('materializes surface-eligible types it does not know as unknown nodes', () => {
const adapter = new FoldAdapter()
adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
const { nodes } = adapter.nodes()
// Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
// shapes; what matters is no throw and no misclassification into a known kind.
for (const node of nodes) expect(node.kind).toBe('unknown')
})
it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
const adapter = new FoldAdapter()
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: {
kind: 'model',
...{ provider: 'x', model: 'y' },
},
}),
} }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset(window, 10)
const first = adapter.nodes()
expect(first.degraded).toBe(true)
expect(errorSpy).toHaveBeenCalled()
expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
const second = adapter.nodes()
expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
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' },
} }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
it('exposes the in-window call index for runningCalls material', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
expect(adapter.callIndex.size).toBe(2)
})
it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
const adapter = new FoldAdapter()
const events = [
ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
ev.toolResult(1, 1, 'c1', 'listing'),
]
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(events, 0, [callView, resultView] as never)
expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).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 FoldAdapter()
adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
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.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
})
it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
const adapter = new FoldAdapter()
const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
const node = adapter.nodes().nodes[0]
expect(node).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 FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 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 FoldAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().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 FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().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 surface node', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
it('command nodes survive the degraded linear-scan branch', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0,
step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: { kind: 'model', provider: 'x', model: 'y' },
}),
} }),
], 0)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(true)
expect(nodes.some(n => n.kind === 'command')).toBe(true)
} finally {
errorSpy.mockRestore()
}
})
})
})
@@ -0,0 +1,94 @@
import { createMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
import { ev } from './event-script.ts'
const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('projects frozen surface generations without widening the core live surface', () => {
const events = [
ev.user(0, 'a'),
ev.user(1, 'b'),
at(2, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
at(3, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 2, end: 1 },
sourceEventSeqs: [2, 1],
data: {
turn: 1,
step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
]
expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({
id: context.id,
parentId: context.parentId,
originSeq: context.originSeq,
nodes: context.nodes.map(node => node.seq),
}))).toEqual([
{ id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] },
{ id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] },
{ id: 2, parentId: 1, originSeq: 3, nodes: [3] },
])
})
it('projects assistant timing and the active request header from history', () => {
const projection = projectConversationHistory([
ev.stepStart(0, 1, 2),
at(1, { type: 'request/header', data: {
reason: 'initial',
header: {
config: { provider: 'fake', model: 'first' },
tools: [],
},
} }),
ev.chunkStart(2, 1, 2),
ev.chunkText(3, 1, 'token', 2),
ev.assistant(4, 1, 'done', 2),
ev.stepStart(5, 2, 1),
ev.chunkText(6, 2, 'next', 1),
ev.assistant(7, 2, 'next done', 1),
].map(event => ({ event })))
expect(projection.eventNodes[0]).toMatchObject({
kind: 'assistant',
timing: {
stepStartTime: 1_700_000_000_000,
firstTokenTime: 1_700_000_000_003,
completedTime: 1_700_000_000_004,
},
requestConfig: { provider: 'fake', model: 'first' },
})
expect(projection.eventNodes.at(-1)).toMatchObject({
timing: {
stepStartTime: 1_700_000_000_005,
firstTokenTime: 1_700_000_000_006,
completedTime: 1_700_000_000_007,
},
requestConfig: { provider: 'fake', model: 'first' },
})
})
})
@@ -404,6 +404,45 @@ describe('live event path', () => {
})
})
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 })
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
@@ -435,6 +474,30 @@ 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.
const { api, session } = makeSession()
api.onHistory = () => histResponse([
ev.compactSummary(80, '窗外范围的摘要', 3, 40),
ev.compactCheckpoint(81, 80, 3, 40),
ev.user(82, '压缩后的新问题'),
], true)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
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(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
@@ -0,0 +1,418 @@
/**
* 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` provenance 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()
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 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 === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
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/* provenance 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' },
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
])
})
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' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
})
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 provenance', 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()).toEqual([
{ 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: '可用摘要' },
])
})
it('leaves the summary null when the checkpoint records no provenance at all', () => {
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 }])
})
it('skips a non-summary provenance seq before reaching the real one', () => {
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 provenance', () => {
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('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('renders the /compact row alongside the marker its own command produced', () => {
// The row that reports the compaction is a command node; dropping command
// folding would delete it together with every other slash-command row.
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', '已压缩'),
])
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: '已压缩' } })
})
})
})
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../compact/compact"
},
{
"path": "../../session-projection/session-projection"
},
@@ -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/test-runtime/README.md
README.md: 883d71224139dc409229fdfb35362d040e810cc7
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe
README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031
README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e
+1 -1
View File
@@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production projection would never produce.
+1 -1
View File
@@ -21,4 +21,4 @@
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件,可接受;Node 兼容的运行时入口待出现仓外消费方再补。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。
@@ -46,7 +46,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
return {
sessionId,
nodes: [],
foldDegraded: false,
partial: null,
runningCalls: [],
codeDispatches: new Map(),
@@ -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: 5e918bbf06e163c50e184f500b565eb2436729e0
README.zh.md: e8ef25b0734e73ff32dc57c12a76adbf6a2fcb6f
README.md: 66120f222de4b4d4707430a1ec310c6fe801c0c5
README.zh.md: f5e953f363733341400a292a1946b4e298858b77
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
@@ -48,6 +50,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
+7 -4
View File
@@ -4,9 +4,11 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seatprops share)。
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slotSession scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slotSession scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`)投影而来。聊天视图是该包package自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
@@ -22,9 +24,9 @@
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
@@ -48,6 +50,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
## 已知限制与暂缓事项
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
@@ -56,4 +59,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript 中,因此从外部提交的 steering 在回放时仍能如实呈现。
@@ -0,0 +1,56 @@
// CompactionItem: the one row a landed compaction contributes to the flow.
// The conversation it shadowed on the model surface stays above it, so this
// marker reports where the model stopped seeing that history — it never
// replaces it. The framed checkpoint payload is written for the model and is
// not rendered; the disclosure shows the summary from the checkpoint's own
// provenance, and a window cut that left that provenance outside makes the row
// non-expandable rather than empty.
import { memo, useState } from 'react'
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
import {
IconChevronDownOutline14,
IconChevronRightOutline14,
MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './MessageItem.module.css'
interface CompactionItemProps {
node: CompactionSummaryNode
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}
/**
* The collapsed-by-default compaction marker.
* @param props - the marker node off the snapshot cache.
* @returns the marker row, with the summary disclosure when one is available.
*/
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
const [expanded, setExpanded] = useState(false)
const expandable = node.summary !== null
const open = expandable && expanded
return (
<div className={css.compactionRow}>
<button
type="button"
className={css.compactionButton}
disabled={!expandable}
aria-expanded={expandable ? open : undefined}
onClick={() => { setExpanded(value => !value) }}
>
<span className={css.compactionLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
<span className={css.compactionTitle}>{t('message.compaction')}</span>
<span className={css.compactionSep} aria-hidden />
<span className={css.compactionSummary}>
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
</span>
</button>
{open && node.summary !== null
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
</div>
)
})
@@ -34,6 +34,81 @@
padding: 2px 0;
}
/* Compaction marker: one dim 24px row with a chevron disclosure for the
summary body. Dimmed title (not label-primary) — the row is a boundary
notice, not conversation content. */
.compactionRow {
padding: 2px 0;
}
.compactionButton {
display: flex;
align-items: center;
width: 100%;
height: 24px;
min-width: 0;
padding: 0;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.compactionButton:not(:disabled) {
cursor: pointer;
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.compactionLeading {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 6px;
color: var(--dsw-alias-label-secondary);
}
.compactionTitle {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
}
.compactionSep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.compactionSummary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactionBody {
padding: 4px 0 4px 22px;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
@@ -1,20 +1,22 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context
// injection, retry disclosure, and unknown-surface JSON rows.
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { 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 | ModelRetryNode | UnknownSurfaceNode
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
@@ -179,6 +181,8 @@ export const MessageItem = memo(function MessageItem({
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
case 'compaction':
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
default:
@@ -49,8 +49,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool results and retry notices reuse their first key.
* @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[] = []
@@ -49,6 +49,9 @@ export const zh = {
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
'message.unknownSurface': '未知 surface 事件:{type}',
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
@@ -143,6 +146,9 @@ export const en = {
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',
'message.unknownSurface': 'Unknown surface event: {type}',
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
@@ -164,6 +164,34 @@ describe('MessageItem arms', () => {
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
})
it('a compaction marker discloses its summary and never shows the framed checkpoint', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'compaction', seq: 5, time: 1_000,
summary: '## 摘要标题\n\n保留的事实。',
}}
/>,
)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText(/保留的事实/)).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByRole('heading', { name: '摘要标题' })).toBeTruthy()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a marker whose provenance fell outside the window is not expandable', () => {
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
const row = view.getByRole('button', { name: /上下文已压缩/ })
expect(row).toHaveProperty('disabled', true)
expect(row.getAttribute('aria-expanded')).toBeNull()
expect(view.getByText('压缩摘要不可用')).toBeTruthy()
fireEvent.click(row) // a disabled control stays collapsed
expect(row.getAttribute('aria-expanded')).toBeNull()
})
it('collapses retry details behind the durable model retry status', () => {
vi.useFakeTimers()
vi.setSystemTime(10_000)
@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
@@ -33,7 +33,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -33,7 +33,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -309,7 +309,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
@@ -26,7 +26,7 @@ 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: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
@@ -112,7 +112,7 @@ 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: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
@@ -28,7 +28,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
@@ -253,7 +253,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
@@ -366,7 +366,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
@@ -451,7 +451,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
@@ -212,7 +212,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
@@ -357,6 +357,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'compaction') {
// Chat owns the human-facing compaction marker. It contributes no
// duplicate trajectory cell, but still advances the duration cursor.
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
continue
}
if (node.kind === 'tool-result') {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
@@ -195,7 +195,7 @@ describe('deriveTrajectoryLayout', () => {
expect(message?.thinkingDetail).toBe(thinking)
})
it('advances the duration cursor over context nodes', () => {
it('advances the duration cursor over context and compaction nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
{
@@ -211,17 +211,21 @@ describe('deriveTrajectoryLayout', () => {
kind: 'context', seq: 4, time: 9_000,
content: [{ type: 'text', text: 'extra' }], source: null,
},
// A landed compaction renders no cell, but is still a real log position,
// so it moves the cursor after the visible context row.
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
{
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups
.flatMap(g => g.cells)
.find(c => c.kind === 'message' && c.text === 'done')
// From context at 9s, not from the earlier user/tool surfaces.
expect(message?.timeSeconds).toBe(1)
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
expect(message?.timeSeconds).toBe(0.5)
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
})
it('uses the recorded step start for assistant duration when timing exists', () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compact/compact/README.md
README.md: 17a5420ae9fa23ce4021b4d4927ae5b95962f979
README.zh.md: 56624efad3c4ffacb61c1a2e199e0461afc57242
README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866
README.zh.md: 7763faad101a4f7f6f8034b76dff9284909667e2
+4
View File
@@ -59,6 +59,10 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Recognizing a checkpoint outside the host program (`./checkpoint`)
`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../ui/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
## Model Experience
### Conversation history, when a backend is invoked
+4
View File
@@ -59,6 +59,10 @@
继承 `CompactService`,实现 `compactIfNeeded``compactRegion`,再将子类作为插件加载:它会注册为 `ctx.compact`。每个成功后端都在替换 user 消息上使用 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 可在持久化或克隆后识别该标记,无需依赖后端身份。基于模板或模型的实现可以放在同级包中,不需更改调用方或共享 token meter。
## 在 host 程序之外识别检查点(`./checkpoint`
`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../ui/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
## 模型体验
### 调用后端时的会话历史
+5
View File
@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./checkpoint": {
"types": "./lib/types/checkpoint.d.ts",
"default": "./lib/types/checkpoint.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -0,0 +1,27 @@
/**
* The compaction seam's canonical checkpoint source: the plugin marker every
* backend stamps on the replacement user message that lands a checkpoint, plus
* the predicate that recognizes it.
*
* The seam itself lives in `@deepseek-ai/dsh-compact`, which re-exports both of
* these; this module is a pure value/predicate outlet (no cordis imports, no
* module augmentation) so client and wire programs can name the checkpoint
* source without loading the host plugin's Context merges — the
* `dsh-commands/brand` shape.
*
* @module @deepseek-ai/dsh-compact/checkpoint
*/
import type { MessageSource } from '@deepseek-ai/dsh-llm/message'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
}
+4 -13
View File
@@ -8,24 +8,15 @@
*/
import { Context, Service } from 'cordis'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
}
// The checkpoint source and its predicate are declared on the cordis-free
// `./checkpoint` leaf so client and wire programs can name them without this
// root's Context merge; the root stays the host-side entry point for both.
export { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from './checkpoint.ts'
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
+3
View File
@@ -1069,6 +1069,9 @@ importers:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-compact':
specifier: workspace:^
version: link:../../compact/compact
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
+1
View File
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"],
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
"@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"],
"@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"],
"@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"],
"@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"],
"@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"],
+7
View File
@@ -37,6 +37,13 @@
// client-side Context merges keep it out of the host program.
{ "path": "./packages/host/directory-picker-native" },
{ "path": "./packages/host/directory-picker-browse" },
// Compaction seam: the client-runtime pin test value-imports the canonical
// checkpoint const from the cordis-free dsh-compact/checkpoint leaf and
// deliberately never loads the dsh-compact package root or the host-side
// Context merges reachable through it. The client package pins the same
// leaf through a type-only import in transcript-adapter.ts; composite
// rootDir rules make both paths depend on this runtime project reference.
{ "path": "./packages/compact/compact" },
{ "path": "./packages/client/ui-slots" },
{ "path": "./packages/client/schema-form" },
{ "path": "./packages/client/ui-primitives" },