From 8e5c792b50fd73030591535a338664b1947d2c38 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 10:18:24 +0800 Subject: [PATCH 01/12] round 1: project the web transcript from append-origin events Replace the surface-ordered fold with a log-ordered human transcript: append-origin surface events at their own log positions plus one marker per landed compaction checkpoint. Command folding, the tool-call index, and the rev-keyed memo carry over unchanged. Removes foldDegraded, the padding sentinels, baseSeq, and degradedSeqs() -- they existed only to satisfy the core fold's seq === index assertion. That also closes the pagination hole A1 exposed: a page can carry a checkpoint whose shadowed range fell outside the window, and nothing resolves surfaceOp.start anymore. --- ...7-19-gui-web-client-architecture.i18n.yaml | 6 +- .../2026-07-19-gui-web-client-architecture.md | 10 +- ...26-07-19-gui-web-client-architecture.zh.md | 6 +- ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 4 +- ...07-29-human-transcript-append-origin.zh.md | 4 +- ...ranscript-log-ordered-projection.i18n.yaml | 6 + ...0-web-transcript-log-ordered-projection.md | 66 +++ ...eb-transcript-log-ordered-projection.zh.md | 66 +++ apps/web/tests/seeded-history.e2e.ts | 97 +++- .../snapshots/seeded-history/ui.expected.md | 6 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 8 +- packages/client/runtime/README.zh.md | 8 +- packages/client/runtime/package.json | 1 + packages/client/runtime/src/client/index.ts | 4 +- .../src/client/sessions/conversation.ts | 27 +- .../src/client/sessions/fold-adapter.ts | 275 ------------ .../runtime/src/client/sessions/session.ts | 37 +- .../src/client/sessions/transcript-adapter.ts | 325 ++++++++++++++ .../tests/compact-checkpoint-pin.spec.ts | 52 +++ packages/client/runtime/tests/event-script.ts | 21 + .../client/runtime/tests/fold-adapter.spec.ts | 251 ----------- packages/client/runtime/tests/session.spec.ts | 63 +++ .../runtime/tests/transcript-adapter.spec.ts | 415 ++++++++++++++++++ packages/client/test-runtime/src/fixtures.ts | 1 - .../src/client/chat/CompactionItem.tsx | 49 +++ .../src/client/chat/MessageItem.module.css | 75 ++++ .../src/client/chat/MessageItem.tsx | 15 +- .../src/client/chat/chat-flow.ts | 2 +- .../tests/chat-branch-tails.spec.tsx | 28 ++ .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 4 +- .../client/ui-trajectory/src/client/spans.ts | 2 +- .../ui-trajectory/tests/layout.spec.tsx | 18 +- pnpm-lock.yaml | 3 + tsconfig.client.json | 6 + 45 files changed, 1385 insertions(+), 602 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md delete mode 100644 packages/client/runtime/src/client/sessions/fold-adapter.ts create mode 100644 packages/client/runtime/src/client/sessions/transcript-adapter.ts create mode 100644 packages/client/runtime/tests/compact-checkpoint-pin.spec.ts delete mode 100644 packages/client/runtime/tests/fold-adapter.spec.ts create mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts create mode 100644 packages/client/ui-conversation/src/client/chat/CompactionItem.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index e4b0447cdd..6e1b952682 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -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: abe28b00638b4f01c7a50efe9ece5148e959a5ee +2026-07-19-gui-web-client-architecture.zh.md: 560015191fd8d99e2983a19d0b23aeb4dedff837 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index cfc2a7e623..abe28b0063 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -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`, 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`, 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; the nodes array is rebuilt but element references come from the cache; 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`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index b5b082c25f..560015191f 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -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`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量 fold;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):不可变快照契约——`nodes`(fold 产物,surface 序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;nodes 数组重建但元素引用来自缓存;未变的子结构复用上一快照的引用。 +- **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`) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index edb6ac6e4a..7ee4b1fac8 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index a296b93d53..dcc4a786c6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 96e0cd1038..0fefc52afa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml new file mode 100644 index 0000000000..5f80a9b7c9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +2026-07-30-web-transcript-log-ordered-projection.md: 22e687ebc0a323a28eb554a9ad36749a0b6f3da6 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 97004f7384aed8f9e43b0949b2fcd4b8c185ca65 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md new file mode 100644 index 0000000000..22e687ebc0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -0,0 +1,66 @@ +# 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: the local literal and its drift trap + +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. + +The client restates that plugin source as a local literal, because `dsh-compact` is unreachable from `packages/client/runtime`'s program in **both** directions: + +- a **value** import fails the client purity gate (`packages/client/tsdown.client.ts`), and `dsh-compact`'s root value-imports cordis, so admitting it would pull `CompactService` into the browser bundle; +- a **type-only** import fails typecheck. `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against this program's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout). This was expected to work and does not; `import type` is erased before the *bundler* runs, but not before the *compiler* does, and the collision is a compiler fact. + +The drift protection therefore lives in a test, not in a type: `packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` runs in the client **test** program, which carries no such collision, and drives the adapter with a checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` itself. Renaming the seam's plugin fails there instead of silently deleting every compaction marker from the web transcript. `dsh-compact` is a `devDependency` of `dsh-client-runtime` and a reference of `tsconfig.client.json` only — never of a `packages/client/*` package project. + +That is a deliberate divergence from the terminal, which value-imports `isCompactCheckpointSource` directly because no gate applies host-side. + +## 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 + +**Add `dsh-compact` to the client `INLINE_SAFE` allowlist** and move the predicate to a cordis-free subpath. Rejected: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root too; the allowlist is a reviewer promise about client-facing subpaths, not a purity proof. It also needs a new export and a `files` fix, and it would not have helped — the blocking collision turned out to be in the compiler, which an allowlist does not touch. + +**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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md new file mode 100644 index 0000000000..97004f7384 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -0,0 +1,66 @@ +# 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` 是注入上下文——跨会话引用卡片——不是压缩。 + +客户端把该插件来源重述为一个本地字面量,因为 `dsh-compact` 在**两个**方向上都无法从 `packages/client/runtime` 的程序到达: + +- **值**导入会失败于客户端纯度门禁(`packages/client/tsdown.client.ts`),而 `dsh-compact` 的根部会值导入 cordis,因此放行它就会把 `CompactService` 拉进浏览器产物; +- **仅类型**导入会失败于类型检查。`dsh-compact` 的根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与本程序的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则。这一点原本预期可行,实际不可行:`import type` 在**打包器**运行前被擦除,但不在**编译器**运行前被擦除,而该冲突是编译器事实。 + +因此漂移保护住在一个测试里,而不是一个类型里:`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 运行在客户端**测试**程序中——那里不存在这一冲突——并用由权威 `COMPACT_CHECKPOINT_SOURCE` 本身构造的检查点驱动适配器。重命名缝隙的插件会在那里失败,而不是无声地把每个压缩标记从 Web 记录中删除。`dsh-compact` 只是 `dsh-client-runtime` 的 `devDependency` 以及 `tsconfig.client.json` 的一条引用——绝不是任何 `packages/client/*` 包工程的引用。 + +这是与终端的一次刻意分歧:终端直接值导入 `isCompactCheckpointSource`,因为宿主侧不适用任何门禁。 + +## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失 + +尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本基线上并不存在,本次也不撰写它。 + +## Alternatives considered + +**把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单**,并把谓词搬到一个不含 cordis 的子路径。已拒绝:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包也就放行了它那个会导入 cordis 的根部;该白名单是对面向客户端子路径的评审承诺,不是纯度证明。它还需要一个新导出与一处 `files` 修正,而且本来也帮不上忙——真正阻塞的冲突出在编译器,白名单碰不到那里。 + +**一条纯形状规则**——任何 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` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。两者应当放在一起,读者正是在那里遇到同一份信息的两半。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d30b3d7c39..30e824324b 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -1,8 +1,8 @@ // 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 -// events — with ZERO model calls in replay (no replay fixture; a stray stream +// history RPC, history-page tool views, and the client's log-ordered transcript +// of historical events — with ZERO model calls in replay (no replay fixture; a stray stream // fails loud on the open llm seam). 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) @@ -27,6 +27,77 @@ 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 + }) + 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') + } + let seq = tail.seq + 1 + let time = tail.time + 1 + const at = (event: Record): string => JSON.stringify({ ...event, seq: seq++, time: time++ }) + // The checkpoint's provenance names the two events appended before it. + const startSeq = seq + 1 + const summarySeq = seq + 2 + lines.push( + at({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } }), + at({ type: 'compact/start', data: { turn: 2 } }), + 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: 'Model-only compact checkpoint.', + }], + source: { kind: 'plugin', plugin: 'compact' }, + }, + surfaceOp: { op: 'replace', start: first, end: last }, + sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], + }), + at({ type: 'compact/end', data: { turn: 2 } }), + at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }), + ) + return `${lines.join('\n')}\n` +} + describe('web e2e: seeded history renders through cold resume', () => { let scaffold: WebScaffold let browser: Browser @@ -46,7 +117,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 browser.newPage({ viewport: { width: 1680, height: 1000 } }) @@ -112,11 +183,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('上下文已压缩', { 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) }, 60_000) it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { @@ -147,6 +222,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: /上下文已压缩/ }) + 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) + // Collapse again so the aria golden captured after this case is unaffected. + await marker.click() + await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + }) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index db71bd6696..28252224f1 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,7 +36,11 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} +- button "上下文已压缩 点击查看压缩摘要": + - img + - text: 上下文已压缩 点击查看压缩摘要 +- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 429ae8f0a9..ec2fb497e6 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816 -README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9 +README.md: 7bf8050ea988945bead491d02528906863b129a6 +README.zh.md: ff3c91dee956b2c65f0029c97c73d73f5a627b05 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 25eb60e2c9..7bf8050ea9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,9 +16,15 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +## 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. That source literal is restated locally because `dsh-compact` is unreachable from this program in both directions (the client purity gate rejects a value import; a type-only import collides the host `Context.sessions` merge) — `tests/compact-checkpoint-pin.spec.ts` is the drift trap. + +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 one 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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e3085f9175..ff3c91dee9 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -16,9 +16,15 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +## 人类对话记录 + +`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。该来源字面量在本地重述,因为 `dsh-compact` 在两个方向上都无法从本程序到达(客户端纯度门禁拒绝值导入;仅类型导入会与 host 的 `Context.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 帧与历史回放构建相同的索引;子调用永不进入对话记录 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 ## Session 标题投影 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 60e2eddf09..3868ab8d8c 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -48,6 +48,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 3b8e02b5a9..c548c03502 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -38,8 +38,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode, - ConversationSnapshot, QueuedMessage, RunningToolCall, + AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, + ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..fc9a9d6bcd 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -111,6 +111,24 @@ export interface ToolResultNode { resultView: ToolResultView | null } +/** + * 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. */ export interface UnknownSurfaceNode { kind: 'unknown' @@ -124,7 +142,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 @@ -154,6 +172,7 @@ export type ConversationNode = | ContextMessageNode | ToolResultNode | CommandNode + | CompactionSummaryNode | UnknownSurfaceNode /** @@ -163,7 +182,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 (`:code:`); the call side carries the sub-tool name * and its JSON-stringified logged arguments; `content`/`isError` are the @@ -232,10 +251,8 @@ export interface PromptError { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Surface fold product (finalized conversation nodes in surface order). */ + /** Human transcript (finalized conversation nodes in log 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[] /** diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts deleted file mode 100644 index 039c36056b..0000000000 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ /dev/null @@ -1,275 +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 } 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 -} - -/** One event -> UI node (pure function; the six-variant ConversationNode union). */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - 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() - private degraded = false - private callIdx = new Map() - /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ - private resultViews = new Map() - /** - * 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() - /** 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 { - 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 = false - 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) - 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). - } -} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0f5d39ac8e..9927f0e171 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -17,7 +17,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' @@ -87,11 +87,13 @@ 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() /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq. - * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ + * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. + * Owned here rather than by the adapter: these nodes come from the turn/end sweep this class already + * runs over the window, and the transcript array is seq-monotonic, so a plain seq merge is correct. */ private frozenNodes: ConversationNode[] = [] private pending = new Map() // Revision counters preserve array identity when derived content is unchanged, so @@ -108,7 +110,7 @@ export class Session implements SessionFace { private queueRev = 0 private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 - private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + private nodesCache: { projected: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -306,7 +308,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) @@ -553,7 +555,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 @@ -568,14 +570,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 }) @@ -790,18 +793,19 @@ export class Session implements SessionFace { } private buildSnapshot(): ConversationSnapshot { - const { nodes: folded, degraded } = this.foldAdapter.nodes() + const projected = this.transcript.nodes() // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. - // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its - // reference across snapshot swaps (§A.9.4). + // The transcript is seq-monotonic (log order), so sorting the union by seq is exactly the + // flow order. The merged array is cached on (projected reference, frozenRev) so an unchanged + // flow keeps its reference across snapshot swaps (§A.9.4). let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) { + if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.frozenRev === this.frozenRev) { nodes = this.nodesCache.value } else { nodes = this.frozenNodes.length === 0 - ? folded - : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes } + ? projected + : [...projected, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) + this.nodesCache = { projected, frozenRev: this.frozenRev, value: nodes } } if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } @@ -819,7 +823,6 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, - foldDegraded: degraded, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts new file mode 100644 index 0000000000..b79e6ee2fe --- /dev/null +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -0,0 +1,325 @@ +// 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' +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, restated locally. + * + * `dsh-compact` cannot be reached from this program in any form. A VALUE import + * fails the client purity gate (`packages/client/tsdown.client.ts`) and would + * pull the cordis `Service` base into the browser bundle; a TYPE-ONLY import of + * its `COMPACT_CHECKPOINT_SOURCE` fails typecheck, because `dsh-compact`'s root + * 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). The + * literal is pinned to the canonical const by + * `tests/compact-checkpoint-pin.spec.ts`, which runs in the client TEST program + * where that collision does not apply. + */ +const COMPACT_PLUGIN = 'compact' + +/** 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 +} + +/** One event -> UI node (pure function; the eight-variant ConversationNode union). */ +function materializeNode( + event: SessionEvent, + callIndex: ReadonlyMap, + 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. */ +function compactSummaryText(event: SessionEvent): string | null { + const summary = (event.data as unknown as { summary?: unknown }).summary + if (!Array.isArray(summary) || summary.length === 0) 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') return null + 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, +): 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() + /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ + private projected: ConversationNode[] = [] + private callIdx = new Map() + /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ + private resultViews = new Map() + /** + * Command lifecycle nodes by commandId (insertion = run order). The + * `command/run`/`command/done` pair is log-only, so it is not a surface + * event and never joins the transcript projection; this index folds the pair + * (done settles its run's node in place) and nodes() merges the products in + * by seq. Window cuts soft-fall like tool pairs: a done with no in-window + * run still builds a node. + */ + private commandIdx = new Map() + /** Projection revision, bumped only when a transcript node or a command node actually + * changed, keying the nodes() result cache: an unchanged projection returns the previous + * ARRAY reference, not just cached elements — the snapshot's reference-stability contract + * (§A.9.4) starts here, and a chunk storm bumps nothing at all. */ + private rev = 0 + private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null + + /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ + get callIndex(): ReadonlyMap { + return this.callIdx + } + + /** + * 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 node — O(1) per append. 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). + } +} diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts new file mode 100644 index 0000000000..c657ebd036 --- /dev/null +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -0,0 +1,52 @@ +/** + * Drift trap for the compaction-checkpoint recognition rule. + * + * `TranscriptAdapter` restates the compaction seam's checkpoint source as a + * local literal because it cannot import `dsh-compact` in any form: a VALUE + * import fails the client purity gate, and a TYPE-ONLY import fails typecheck — + * `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context` + * merge declares the HOST `sessions: SessionStore` against the client program's + * `sessions: ISessions` (`TS2717`). This spec runs in the client TEST program, + * which does not carry that collision, and it is the only thing keeping the two + * implementations from drifting: it drives the adapter with a checkpoint built + * from the canonical `COMPACT_CHECKPOINT_SOURCE` itself, so renaming the seam's + * plugin fails HERE instead of silently deleting every compaction marker from + * the web transcript. + */ + +import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact' +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: 'model only' }], + source: COMPACT_CHECKPOINT_SOURCE, + }), + } as unknown as SessionEvent +} + +describe('compaction checkpoint recognition', () => { + it('recognizes a checkpoint carrying the seam-canonical source', () => { + const adapter = new TranscriptAdapter() + adapter.reset([canonicalCheckpoint(1)]) + expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }]) + }) + + 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' }) + }) +}) diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 53f80e0e69..f92ee787f4 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -69,6 +69,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('model only'), + source: { kind: 'plugin', plugin: 'compact' }, + }), + }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts deleted file mode 100644 index b40bdb4111..0000000000 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ /dev/null @@ -1,251 +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 { ev, plainTurn } from './event-script.ts' - -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - -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() - 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('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('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('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() - } - }) - }) -}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index c7be330d55..8d09798b8e 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -195,6 +195,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, 'cancelled')) + 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')] @@ -226,6 +265,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 diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts new file mode 100644 index 0000000000..b53fcb0117 --- /dev/null +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -0,0 +1,415 @@ +/** + * TranscriptAdapter over the raw append-only window: log-ordered projection of + * append-origin events, one marker per landed compaction, replacement copies + * hidden, command-lifecycle folding, node/array identity, call pairing, and + * host-provided wire views. + */ + +import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' +import { ev, plainTurn } from './event-script.ts' + +const at = (seq: number, e: Record): SessionEvent => + ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent + +/** A `compact/summary` 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: 'model only' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), + }) +} + +describe('TranscriptAdapter', () => { + it('projects a window starting past seq 0 at its own log positions', () => { + const adapter = new TranscriptAdapter() + adapter.reset(plainTurn(100, 5, '偏移问', '偏移答')) + expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]]) + }) + + it('appends incrementally keeping old node references (materialize-once identity)', () => { + const adapter = new TranscriptAdapter() + adapter.reset(plainTurn(0, 0, 'a', 'b')) + const first = adapter.nodes() + adapter.append(ev.user(6, '追加')) + const second = adapter.nodes() + expect(second).toHaveLength(3) + expect(second[0]).toBe(first[0]) + expect(second[1]).toBe(first[1]) + expect(second).not.toBe(first) // a real change swaps the array + }) + + it('keeps the array reference across a chunk storm and swaps it when a node lands', () => { + const adapter = new TranscriptAdapter() + adapter.reset(plainTurn(0, 0, 'a', 'b')) + const settled = adapter.nodes() + adapter.append(ev.chunkStart(6, 1)) + expect(adapter.nodes()).toBe(settled) + adapter.append(ev.chunkText(7, 1, '流式')) + expect(adapter.nodes()).toBe(settled) + adapter.append(ev.assistant(8, 1, '流式完成')) + const finalized = adapter.nodes() + expect(finalized).not.toBe(settled) + expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 }) + }) + + it('materializes every append-origin variant with field mapping', () => { + const adapter = new TranscriptAdapter() + 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], + ['malformed 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('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: 'x' }], + 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('exposes the in-window call index for runningCalls material', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')]) + 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 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.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } }) + 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.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } }) + 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: '已压缩' } }) + }) + }) +}) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 4219d233e2..6c2f8e077b 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,7 +46,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], - foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx new file mode 100644 index 0000000000..604f504aac --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/CompactionItem.tsx @@ -0,0 +1,49 @@ +// 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 css from './MessageItem.module.css' + +/** + * 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 }: { node: CompactionSummaryNode }) { + const [expanded, setExpanded] = useState(false) + const expandable = node.summary !== null + const open = expandable && expanded + return ( +
+ + {open && node.summary !== null + &&
} +
+ ) +}) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 260382d530..be6ddf897e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -42,6 +42,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; +} + /* Reference chip projection inside a user bubble (`name` model spans render as chips; free geometry — no textarea pairing here). */ .refChip { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..b7a75262d7 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,20 +1,21 @@ -// MessageItem: the four simple node kinds — user bubble (right-aligned, with +// MessageItem: the five simple node kinds — user bubble (right-aligned, with // clock + copy / branch / edit IconActions), steering (badged bubble), context -// injection and unknown-surface JSON rows. Props are frozen node slices off -// the snapshot cache; memo holds across streaming because unchanged nodes -// keep their references. +// injection, the compaction marker, and unknown-surface JSON rows. Props are +// frozen node slices off the snapshot cache; memo holds across streaming +// because unchanged nodes keep their references. import { memo } from 'react' import type { ReactNode } from 'react' import type { - ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { CompactionItem } from './CompactionItem.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' export interface MessageItemProps { - node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode + node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | UnknownSurfaceNode } function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { @@ -98,6 +99,8 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) ) + case 'compaction': + return default: return (
diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index f1ce061af8..fb2ee42cf2 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -24,7 +24,7 @@ function rendersNothing(node: ConversationNode): boolean { /** * Group finalized nodes into the step-summary flow. - * @param nodes - snapshot nodes (surface order). + * @param nodes - snapshot nodes (human transcript order). * @returns flow items; consecutive tool-results merged into one group keyed by the first seq. */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9bb6ba539a..73d746fc7c 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -117,6 +117,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( + , + ) + 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() + 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() + }) }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 1b4d1ee158..65dde74764 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -55,7 +55,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, diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 6985991074..8213a82a3c 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -26,7 +26,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, } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 328ba38340..5514f2504a 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -29,7 +29,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, } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 6d58932ece..26171d4040 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -18,7 +18,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, } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 23d853dd1a..7d8012d413 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -20,7 +20,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): 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, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f6694f8cb4..b35b2fe06f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -23,7 +23,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({ - 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, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9d9ace032c..7dbc254ebb 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -109,7 +109,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({ - 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, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index c63d3628e5..7f7bae6d9f 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -18,7 +18,7 @@ const SID = 's1' as SessionId 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, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0d32e2edea..d00ba84718 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -47,7 +47,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): 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, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 37c86f6eb4..d714a3b42f 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -110,8 +110,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T lastAssistantTurn = node.turn continue } - if (node.kind === 'context') { - // No trajectory cell, but the surface still advances the duration cursor. + if (node.kind === 'context' || node.kind === 'compaction') { + // No trajectory cell, but transcript metadata still advances the duration cursor. prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index 585a336333..de91e4382e 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -45,7 +45,7 @@ export interface SpanStats { * Fold snapshot nodes into per-turn spans. Only assistant nodes carry a turn * number; user/steering/context/tool nodes attach to the turn last seen in * sequence order (turn 0 collects the pre-assistant prologue). - * @param nodes - snapshot nodes in surface order. + * @param nodes - snapshot nodes in human transcript order. * @returns spans ordered by first appearance. */ export function deriveSpans(nodes: ConversationSnapshot['nodes']): readonly TurnSpan[] { diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 5394d6ab09..c55a939808 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -176,7 +176,7 @@ describe('deriveTrajectoryLayout', () => { }) }) - 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 }, { @@ -192,17 +192,21 @@ describe('deriveTrajectoryLayout', () => { kind: 'context', seq: 4, time: 9_000, content: [{ type: 'text', text: 'extra' }], source: null, }, + // A landed compaction renders no cell either, but is still a real log + // position, so it moves the cursor the same way a context row does. + { 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) + // Neither the context row nor the marker contributed a cell. + expect(cells).toHaveLength(3) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6dac9263d..1102cd603e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/tsconfig.client.json b/tsconfig.client.json index 5a52f59bb0..81681df038 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -36,6 +36,12 @@ // client-side Context merges keep it out of the host program. { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/directory-picker-browse" }, + // Test-only leaf: the client-runtime drift trap for the compaction + // checkpoint source reads the seam's canonical const. It may appear HERE + // but never in a packages/client/* package project — dsh-compact's root + // reaches dsh-session's root, whose Context merge declares the host + // `sessions: SessionStore` and collides with the client's `ISessions`. + { "path": "./packages/compact/compact" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, From 91ee264e16ffda88d2b5b6975417cef24a9283b8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 14:07:08 +0800 Subject: [PATCH 02/12] round 2: pin checkpoint recognition at compile time Move COMPACT_CHECKPOINT_SOURCE and isCompactCheckpointSource into a cordis-free src/checkpoint.ts leaf, re-exported from the root so every host-side consumer keeps its import. The client can then type-import the leaf without reaching dsh-session's root, whose Context merge declares the host sessions service and collides with the client's -- the dsh-commands/brand shape. Renaming the plugin id now fails the client typecheck. Also: keep recoverable summary text when a compact/summary mixes text with other block types, and capture the seeded-history provenance seqs from the pushes that produce them instead of deriving them by arithmetic. --- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 20 +++-- ...eb-transcript-log-ordered-projection.zh.md | 20 +++-- apps/web/tests/seeded-history.e2e.ts | 85 +++++++++++-------- docs/cordis-catalog/services.md | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 9 +- .../src/client/sessions/transcript-adapter.ts | 39 +++++---- .../tests/compact-checkpoint-pin.spec.ts | 22 +++-- .../runtime/tests/transcript-adapter.spec.ts | 15 +++- packages/client/runtime/tsconfig.json | 3 + packages/compact/compact/README.i18n.yaml | 6 +- packages/compact/compact/README.md | 4 + packages/compact/compact/README.zh.md | 4 + packages/compact/compact/package.json | 5 ++ packages/compact/compact/src/checkpoint.ts | 27 ++++++ packages/compact/compact/src/index.ts | 17 +--- tsconfig.base.json | 1 + tsconfig.client.json | 14 +-- 21 files changed, 197 insertions(+), 108 deletions(-) create mode 100644 packages/compact/compact/src/checkpoint.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 5f80a9b7c9..4c078446f1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 22e687ebc0a323a28eb554a9ad36749a0b6f3da6 -2026-07-30-web-transcript-log-ordered-projection.zh.md: 97004f7384aed8f9e43b0949b2fcd4b8c185ca65 +2026-07-30-web-transcript-log-ordered-projection.md: 0c58373d58e08fbfe260f16f2104b3f7d1ccc3fd +2026-07-30-web-transcript-log-ordered-projection.zh.md: 49de0a9ecbbf2da92113f8ad63afe6478caedadd diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 22e687ebc0..0c58373d58 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -22,18 +22,24 @@ The marker's summary text comes from the checkpoint's own `compact/summary` prov No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. -## Recognizing a checkpoint: the local literal and its drift trap +## 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. -The client restates that plugin source as a local literal, because `dsh-compact` is unreachable from `packages/client/runtime`'s program in **both** directions: +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. -- a **value** import fails the client purity gate (`packages/client/tsdown.client.ts`), and `dsh-compact`'s root value-imports cordis, so admitting it would pull `CompactService` into the browser bundle; -- a **type-only** import fails typecheck. `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against this program's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout). This was expected to work and does not; `import type` is erased before the *bundler* runs, but not before the *compiler* does, and the collision is a compiler fact. +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: -The drift protection therefore lives in a test, not in a type: `packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` runs in the client **test** program, which carries no such collision, and drives the adapter with a checkpoint built from the canonical `COMPACT_CHECKPOINT_SOURCE` itself. Renaming the seam's plugin fails there instead of silently deleting every compaction marker from the web transcript. `dsh-compact` is a `devDependency` of `dsh-client-runtime` and a reference of `tsconfig.client.json` only — never of a `packages/client/*` package project. +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` -That is a deliberate divergence from the terminal, which value-imports `isCompactCheckpointSource` directly because no gate applies host-side. +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**. It runs in the client **test** program, which may value-import the root; a `packages/client/*` package program may not. + +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 @@ -41,7 +47,7 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b ## Alternatives considered -**Add `dsh-compact` to the client `INLINE_SAFE` allowlist** and move the predicate to a cordis-free subpath. Rejected: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root too; the allowlist is a reviewer promise about client-facing subpaths, not a purity proof. It also needs a new export and a `files` fix, and it would not have helped — the blocking collision turned out to be in the compiler, which an allowlist does not touch. +**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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 97004f7384..49de0a9ecb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -22,18 +22,24 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。 -## 识别检查点:本地字面量与它的漂移陷阱 +## 识别检查点:同一份声明,在编译期钉住 识别需要三个条件同时成立,与终端一致:`event.type === 'user/message'`、压缩缝隙的检查点插件来源,**以及** `isReplacementSurfaceEvent(event)`。一条 append 的插件来源 `user/message` 是注入上下文——跨会话引用卡片——不是压缩。 -客户端把该插件来源重述为一个本地字面量,因为 `dsh-compact` 在**两个**方向上都无法从 `packages/client/runtime` 的程序到达: +从 `packages/client/*` 程序无法到达的是 `dsh-compact` 的**根部**,而不是这个包。根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则;这一点对仅类型导入同样成立,因为该冲突是编译器事实而非打包器事实。 -- **值**导入会失败于客户端纯度门禁(`packages/client/tsdown.client.ts`),而 `dsh-compact` 的根部会值导入 cordis,因此放行它就会把 `CompactService` 拉进浏览器产物; -- **仅类型**导入会失败于类型检查。`dsh-compact` 的根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与本程序的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则。这一点原本预期可行,实际不可行:`import type` 在**打包器**运行前被擦除,但不在**编译器**运行前被擦除,而该冲突是编译器事实。 +本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块(即 `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上: -因此漂移保护住在一个测试里,而不是一个类型里:`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 运行在客户端**测试**程序中——那里不存在这一冲突——并用由权威 `COMPACT_CHECKPOINT_SOURCE` 本身构造的检查点驱动适配器。重命名缝隙的插件会在那里失败,而不是无声地把每个压缩标记从 Web 记录中删除。`dsh-compact` 只是 `dsh-client-runtime` 的 `devDependency` 以及 `tsconfig.client.json` 的一条引用——绝不是任何 `packages/client/*` 包工程的引用。 +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` -这是与终端的一次刻意分歧:终端直接值导入 `isCompactCheckpointSource`,因为宿主侧不适用任何门禁。 +重命名缝隙的插件 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` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。它运行在客户端**测试**程序中,那里可以值导入包根;`packages/client/*` 包工程不可以。 + +因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 ## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失 @@ -41,7 +47,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 ## Alternatives considered -**把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单**,并把谓词搬到一个不含 cordis 的子路径。已拒绝:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包也就放行了它那个会导入 cordis 的根部;该白名单是对面向客户端子路径的评审承诺,不是纯度证明。它还需要一个新导出与一处 `files` 修正,而且本来也帮不上忙——真正阻塞的冲突出在编译器,白名单碰不到那里。 +**从新叶子值导入该谓词**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id,不是谓词——一个类型就够了,而被擦除的导入根本不会抵达纯度门禁,因此无需向它放行任何东西。白名单只在值导入时才有意义,而在那里它是笔糟糕的交换:`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行。 **一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 30e824324b..f673f43631 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -43,6 +43,7 @@ function withCompaction(raw: string): string { seq: number time: number surfaceOp?: unknown + data?: { turn?: unknown } }) const surfaceSeqs = events .filter(event => event.surfaceOp === 'append' @@ -57,44 +58,56 @@ function withCompaction(raw: string): string { 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 - const at = (event: Record): string => JSON.stringify({ ...event, seq: seq++, time: time++ }) - // The checkpoint's provenance names the two events appended before it. - const startSeq = seq + 1 - const summarySeq = seq + 2 - lines.push( - at({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'compact' } } } }), - at({ type: 'compact/start', data: { turn: 2 } }), - 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: 'Model-only compact checkpoint.', - }], - source: { kind: 'plugin', plugin: 'compact' }, - }, - surfaceOp: { op: 'replace', start: first, end: last }, - sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs], - }), - at({ type: 'compact/end', data: { turn: 2 } }), - at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }), - ) + /** + * 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): 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: 'Model-only compact 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` } diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1f27744341..fe45b5b78b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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.directoryPicker` — `DirectoryPicker` (abstract seam) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index ec2fb497e6..e70060f5ac 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 7bf8050ea988945bead491d02528906863b129a6 -README.zh.md: ff3c91dee956b2c65f0029c97c73d73f5a627b05 +README.md: 69576f1e23a34b83915d075c4120d04368c49f43 +README.zh.md: 7243a9119d9c669c76e8490bd271b2669568bff0 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7bf8050ea9..69576f1e23 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -18,7 +18,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## 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. That source literal is restated locally because `dsh-compact` is unreachable from this program in both directions (the client purity gate rejects a value import; a type-only import collides the host `Context.sessions` merge) — `tests/compact-checkpoint-pin.spec.ts` is the drift trap. +`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 one node, an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index ff3c91dee9..7243a9119d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -18,7 +18,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 人类对话记录 -`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。该来源字面量在本地重述,因为 `dsh-compact` 在两个方向上都无法从本程序到达(客户端纯度门禁拒绝值导入;仅类型导入会与 host 的 `Context.sessions` 合并冲突)——`tests/compact-checkpoint-pin.spec.ts` 是漂移陷阱。 +`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩缝隙自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index fc9a9d6bcd..0e98eb8ae5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -129,7 +129,14 @@ export interface CompactionSummaryNode { summary: string | null } -/** Fallback for surface events this UI version does not know. */ +/** + * 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 diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index b79e6ee2fe..4985f263f4 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -12,25 +12,26 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // 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, restated locally. - * - * `dsh-compact` cannot be reached from this program in any form. A VALUE import - * fails the client purity gate (`packages/client/tsdown.client.ts`) and would - * pull the cordis `Service` base into the browser bundle; a TYPE-ONLY import of - * its `COMPACT_CHECKPOINT_SOURCE` fails typecheck, because `dsh-compact`'s root - * 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). The - * literal is pinned to the canonical const by - * `tests/compact-checkpoint-pin.spec.ts`, which runs in the client TEST program - * where that collision does not apply. + * 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 = 'compact' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' /** In-window tool/call index entry (result-card backfill + runningCalls material). */ export interface CallIndexEntry { @@ -126,14 +127,20 @@ 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. */ +/** + * 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) || summary.length === 0) return null + 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') return null + if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue text += candidate.text } return text.trim() === '' ? null : text diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index c657ebd036..f7f345d931 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -1,17 +1,15 @@ /** - * Drift trap for the compaction-checkpoint recognition rule. + * Behavioral half of the compaction-checkpoint drift trap. * - * `TranscriptAdapter` restates the compaction seam's checkpoint source as a - * local literal because it cannot import `dsh-compact` in any form: a VALUE - * import fails the client purity gate, and a TYPE-ONLY import fails typecheck — - * `dsh-compact`'s root reaches `dsh-session`'s root, whose cordis `Context` - * merge declares the HOST `sessions: SessionStore` against the client program's - * `sessions: ISessions` (`TS2717`). This spec runs in the client TEST program, - * which does not carry that collision, and it is the only thing keeping the two - * implementations from drifting: it drives the adapter with a checkpoint built - * from the canonical `COMPACT_CHECKPOINT_SOURCE` itself, so renaming the seam's - * plugin fails HERE instead of silently deleting every compaction marker from - * the web transcript. + * `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. It runs in the client TEST program, + * which can value-import the package root; a `packages/client/*` package + * program cannot, because that root reaches `dsh-session`'s root and collides + * the host `Context.sessions` merge (`TS2717`). */ import { COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource } from '@deepseek-ai/dsh-compact' diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index b53fcb0117..0c8aa9e12b 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -225,7 +225,7 @@ describe('TranscriptAdapter', () => { it.each([ ['absent provenance', undefined], - ['malformed summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])], + ['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')], @@ -240,6 +240,19 @@ describe('TranscriptAdapter', () => { ]) }) + 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, { diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 7d3f05e6c7..d7968cce84 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../compact/compact" + }, { "path": "../../session-projection/session-projection" }, diff --git a/packages/compact/compact/README.i18n.yaml b/packages/compact/compact/README.i18n.yaml index 921ee62a6d..5996c3b801 100644 --- a/packages/compact/compact/README.i18n.yaml +++ b/packages/compact/compact/README.i18n.yaml @@ -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 -README.md: 17a5420ae9fa23ce4021b4d4927ae5b95962f979 -README.zh.md: d98251649cfdcf6b12e89a192a43b08b0f071f38 +# pnpm run verify-translation-pairing --write packages/compact/compact/README.md +README.md: b6386e8fed9c10cf072683fbdf78c85fb8ac8866 +README.zh.md: b59e03ccf846e88d328dfecdd76a2a490a966a0e diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 17a5420ae9..b6386e8fed 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -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 diff --git a/packages/compact/compact/README.zh.md b/packages/compact/compact/README.zh.md index d98251649c..b59e03ccf8 100644 --- a/packages/compact/compact/README.zh.md +++ b/packages/compact/compact/README.zh.md @@ -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 会让那边编译失败。 + ## 模型体验 ### 调用后端时的会话历史 diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 135c688507..f0874990a3 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -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" diff --git a/packages/compact/compact/src/checkpoint.ts b/packages/compact/compact/src/checkpoint.ts new file mode 100644 index 0000000000..9d8b98e4d6 --- /dev/null +++ b/packages/compact/compact/src/checkpoint.ts @@ -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 +} diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 2988a0b780..dd105ca5fb 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -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' diff --git a/tsconfig.base.json b/tsconfig.base.json index 00c19c4b8c..a8dd18d660 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.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-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 81681df038..43f1d635ba 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -36,11 +36,15 @@ // client-side Context merges keep it out of the host program. { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/directory-picker-browse" }, - // Test-only leaf: the client-runtime drift trap for the compaction - // checkpoint source reads the seam's canonical const. It may appear HERE - // but never in a packages/client/* package project — dsh-compact's root - // reaches dsh-session's root, whose Context merge declares the host - // `sessions: SessionStore` and collides with the client's `ISessions`. + // Compaction seam: the client-runtime drift trap value-imports the seam's + // canonical checkpoint const from the package ROOT, which may be reached + // from this TEST program but never from a packages/client/* package + // program — the root reaches dsh-session's root, whose Context merge + // declares the host `sessions: SessionStore` and collides with the + // client's `ISessions`. Package programs use the cordis-free + // dsh-compact/checkpoint leaf instead (a type-only import in + // transcript-adapter.ts), which needs this reference on the runtime + // package project itself. { "path": "./packages/compact/compact" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-primitives" }, From da41677049e0530a696ac335fb359e5d4117a4b8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 17:10:45 +0800 Subject: [PATCH 03/12] fix(web): address transcript review follow-ups --- ...6-07-19-gui-web-client-architecture.i18n.yaml | 4 ++-- .../2026-07-19-gui-web-client-architecture.md | 2 +- .../2026-07-19-gui-web-client-architecture.zh.md | 4 ++-- .../2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 2 +- .../process/2026-07-20-gui-testing-system.zh.md | 2 +- apps/web/tests/seeded-history.e2e.ts | 2 +- docs/core-data-structures/compaction.i18n.yaml | 4 ++-- docs/core-data-structures/compaction.md | 2 +- docs/core-data-structures/compaction.zh.md | 2 +- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/package.json | 2 +- .../src/client/sessions/transcript-adapter.ts | 16 ++++++---------- .../runtime/tests/compact-checkpoint-pin.spec.ts | 13 ++++++------- .../runtime/tests/transcript-adapter.spec.ts | 12 +----------- packages/client/test-runtime/README.i18n.yaml | 4 ++-- packages/client/test-runtime/README.md | 2 +- packages/client/test-runtime/README.zh.md | 2 +- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 3 +++ packages/client/ui-conversation/README.zh.md | 3 +++ pnpm-lock.yaml | 6 +++--- 24 files changed, 47 insertions(+), 56 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 6e1b952682..4530d5ee57 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: abe28b00638b4f01c7a50efe9ece5148e959a5ee -2026-07-19-gui-web-client-architecture.zh.md: 560015191fd8d99e2983a19d0b23aeb4dedff837 +2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb +2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index abe28b0063..63b6f5795c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references. +- **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. - **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. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 560015191f..2d57c12eba 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -68,8 +68,8 @@ Session.handleMuxEnvelope ──► events 窗口(seq 连续升序) Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件 ``` -- **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量 fold;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):不可变快照契约——`nodes`(人类对话记录,日志序)、`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`,构造时挂 `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 会让合批塌回逐帧渲染;禁。 - **TranscriptAdapter / PartialAccumulator**:对话记录是按日志顺序投影的 append 来源 surface(`@deepseek-ai/dsh-session/surface` 的 `isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface,后者遮蔽被替换的范围,会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index 05d448c79b..deafb47f70 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 8c6dafb18f..4a1600bbef 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -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. diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 9a0de4bfa8..2aa5d7f667 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -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 像素或下层状态机细节。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 485c2bc05b..fb3b266625 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -246,7 +246,7 @@ describe('web e2e: seeded history renders through cold resume', () => { timeout: 5_000, }).toBe(1) expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0) - // Collapse again so the aria golden captured after this case is unaffected. + // Restore the shared page state for any later case. await marker.click() await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') }) diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 89783b5d16..a933e793c4 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a -compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b +compaction.md: 3ae4d7e50452b549654b7a7162141a4be505d784 +compaction.zh.md: 448c3aaf298b65ebe88565190c5b3978b975f5f3 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 911b71d00f..3ae4d7e504 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -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. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index 643a116ff2..448c3aaf29 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -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` 拥有阈值、保留尾部策略、溢出上限与失败处理。 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 483d34e8d3..3b19ab54c4 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 51e13d2c68dce3c0c12e04ad0940af8a1e76a4bc -README.zh.md: 9cebc6e7489da65d18d6b308a4991aad06fc1193 +README.md: 63876e2f2c762c5eeff95e065338413017e0a333 +README.zh.md: efa5e841efad1e5ce0f8c3af63eba00bcae1a363 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 51e13d2c68..63876e2f2c 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -24,7 +24,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `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 one node, an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. +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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9cebc6e748..efa5e841ef 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -24,7 +24,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩缝隙自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 -由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 +由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 ## Code Mode 子调用索引 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 3868ab8d8c..a6f96b9d47 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -32,6 +32,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-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:^", @@ -48,7 +49,6 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 4985f263f4..6fae44ede4 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -33,8 +33,8 @@ import { toAssistantBlocks } from './conversation.ts' */ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' -/** In-window tool/call index entry (result-card backfill + runningCalls material). */ -export interface CallIndexEntry { +/** In-window tool/call index entry used to materialize result cards. */ +interface CallIndexEntry { name: string argsRaw: string turn: number @@ -194,11 +194,6 @@ export class TranscriptAdapter { private rev = 0 private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */ - get callIndex(): ReadonlyMap { - return this.callIdx - } - /** * Window rebuild (after open/resync/page prepend): re-index the raw window * and re-project the transcript. @@ -230,9 +225,10 @@ export class TranscriptAdapter { /** * Tail append (live session/event): index the event and, when it belongs to - * the transcript, extend the projection by one node — O(1) per append. An - * event that changes no node (a chunk storm) bumps no revision, so nodes() - * keeps returning the same array reference. + * 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. */ diff --git a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts index f7f345d931..ddc6c8adc5 100644 --- a/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts +++ b/packages/client/runtime/tests/compact-checkpoint-pin.spec.ts @@ -5,14 +5,13 @@ * 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. It runs in the client TEST program, - * which can value-import the package root; a `packages/client/*` package - * program cannot, because that root reaches `dsh-session`'s root and collides - * the host `Context.sessions` merge (`TS2717`). + * 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' +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' @@ -40,7 +39,7 @@ describe('compaction checkpoint recognition', () => { 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', () => { + 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) diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 0c8aa9e12b..cc03d349b8 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -316,15 +316,7 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) }) - it('exposes the in-window call index for runningCalls material', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')]) - 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', () => { + 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: '完成' } } @@ -332,7 +324,6 @@ describe('TranscriptAdapter', () => { ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'), ev.toolResult(1, 1, 'c1', 'listing'), ], [callView, resultView] as never) - expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } }) expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' }, }) @@ -343,7 +334,6 @@ describe('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.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } }) expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ callView: { title: '回声' }, resultView: null, }) diff --git a/packages/client/test-runtime/README.i18n.yaml b/packages/client/test-runtime/README.i18n.yaml index 73a4705b1b..fe40088b11 100644 --- a/packages/client/test-runtime/README.i18n.yaml +++ b/packages/client/test-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md -README.md: 883d71224139dc409229fdfb35362d040e810cc7 -README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe +README.md: dc8ee8cadf5e61af15f04b1b9842af1eb658c031 +README.zh.md: a4c889d8a0291b52c8509403748df6b93567788e diff --git a/packages/client/test-runtime/README.md b/packages/client/test-runtime/README.md index 883d712241..dc8ee8cadf 100644 --- a/packages/client/test-runtime/README.md +++ b/packages/client/test-runtime/README.md @@ -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. diff --git a/packages/client/test-runtime/README.zh.md b/packages/client/test-runtime/README.zh.md index a3daf11294..a4c889d8a0 100644 --- a/packages/client/test-runtime/README.zh.md +++ b/packages/client/test-runtime/README.zh.md @@ -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` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。 +- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 store;wire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产投影永不产出的状态。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 654722b589..d4ea0caf53 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b +README.md: b9a4fd7de61ed8ce01417cc97ebfd80c312d37bf +README.zh.md: 3a79336f3cb40bf5682aabeb6503d71a1ee36746 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3973c14f2b..b9a4fd7de6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (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: `), 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. @@ -34,6 +36,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 text output only; branch remains a chrome stub. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a6bb15c4cd..3a79336f3c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,6 +4,8 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +压缩在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的对话记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 + 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 @@ -34,6 +36,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 ## 已知限制与暂缓事项 +- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1cf3d328..ea8592962e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -938,6 +938,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 @@ -969,9 +972,6 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From e3ece4b22c3000f74774e8d34f9cc0615a324b6d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 18:11:00 +0800 Subject: [PATCH 04/12] test(web): isolate snapshots from user skills --- apps/web/tests/scaffold.ts | 12 ++++++++++++ .../tests/snapshots/code-mode-round/ui.expected.md | 1 - .../tests/snapshots/cordis-tool-round/ui.expected.md | 1 - .../tests/snapshots/fresh-round-trip/ui.expected.md | 1 - .../snapshots/lifecycle-chrome/reloaded.expected.md | 1 - .../snapshots/live-interactions/cancel.expected.md | 1 - .../live-interactions/error-auth.expected.md | 1 - .../snapshots/live-interactions/retry.expected.md | 1 - .../snapshots/question-composer/answered.expected.md | 1 - .../snapshots/queue-actions/editing.expected.md | 1 - .../web/tests/snapshots/queue-actions/ui.expected.md | 1 - .../tests/snapshots/steering/mid-steer.expected.md | 1 - .../web/tests/snapshots/steering/settled.expected.md | 1 - 13 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..4ede8ca908 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -10,6 +10,8 @@ // // Composition divergences from `dsh web`, all deliberate, all via include // patches over the SAME tree (never a second yml): temp persistenceRoot; +// user skill roots redirected to empty temp directories (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 @@ -169,6 +171,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Thu, 30 Jul 2026 23:43:53 +0800 Subject: [PATCH 05/12] Fix invariant startup gate after CI sync --- .github/workflows/ci.yml | 9 +- .../client/connection/tests/node-half.spec.ts | 20 +-- .../runtime/tests/slots-service.spec.ts | 4 +- scripts/test-invariants.spec.ts | 166 +++++++++++++++++- scripts/test-invariants.ts | 61 +++++-- vitest.config.ts | 23 +-- 6 files changed, 235 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f6ec52f0a..e607d83bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,11 +105,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # Failover shrinks the worker bound: the hosted 32-core runner is - # exclusive to one job, but the failover pool shares one 64-core VM - # across six always-on runner instances, and the timing-sensitive - # process suites have documented aggregate-contention failures. - # 8 × 6 instances = 48 workers worst case on 64 cores. + # The hosted 16-core runner uses six coverage workers. The failover pool + # shares one 64-core VM across six always-on runner instances, so each + # instance may use eight while keeping the worst case at 8 × 6 = 48 + # workers; process-bound suites remain isolated in forks. DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }} DSH_GATE_CONCURRENCY: '3' steps: diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2c7fd0b281..9efa9cbd51 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -59,23 +59,9 @@ describe('connection node half', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) - // The apply throw also escapes cordis as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it so the run - // stays clean, same pattern as the webserver bind-failure test. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) - try { - const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) - await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) - expect(routes).toHaveLength(0) - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') - } finally { - process.off('unhandledRejection', onUnhandled) - } + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) }) it('registers the /api prefix route and removes it with the fiber', async () => { diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 07e03b6e9d..b2e510a26f 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -41,8 +41,8 @@ interface Bench { async function boot(): Promise { const ctx = new Context() - ctx.plugin(SlotsService) - await ctx.fiber.await() + const fiber = ctx.plugin(SlotsService) + await fiber // Service accessor (ctx.get reads the reflect store, which Service-class // plugins do not write; the accessor is the product path). const svc = ctx.slots diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 0fb6aeb201..e2406837ca 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, FiberState, Service } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' import { + TEST_INVARIANT_READY_SERVICE, testInvariantCompanionPaths, testInvariantCompanions, + type TestInvariantCompanion, usesManualInvariantTree, } from './test-invariants.ts' @@ -21,6 +24,32 @@ class TestInvariantProbe extends Service { } } +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +async function withFakeCompanions( + create: (path: string, index: number) => () => Promise, + run: () => Promise, +): Promise { + const mutable = testInvariantCompanions as Record Promise> + const originals = Object.entries(mutable) + for (const [index, [path]] of originals.entries()) { + mutable[path] = create(path, index) + } + try { + await run() + } finally { + for (const [path, load] of originals) { + mutable[path] = load + } + } +} + describe('global test invariant host', () => { it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() @@ -85,4 +114,139 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true) expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + const order: string[] = [] + let delayedCompanion: TestInvariantCompanion | undefined + + await withFakeCompanions( + (path, index) => async () => { + const companion: TestInvariantCompanion = { + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + order.push(`companion-start:${path}`) + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + order.push(`companion-active:${path}`) + return () => {} + }, + } + if (index === 0) delayedCompanion = companion + return companion + }, + async () => { + const ctx = new Context() + ctx.provide('testInvariantTargetDependency', true) + let nestedFiber: ReturnType | undefined + const nestedApply = vi.fn(function nestedApply() { + order.push('nested') + }) + const targetApply = Object.assign(vi.fn(function targetApply(targetCtx: Context) { + order.push('target') + nestedFiber = targetCtx.plugin(nestedApply) + }), { + inject: ['testInvariantTargetDependency'], + }) + + const targetFiber = ctx.plugin(targetApply) + expect(ctx.registry.get(targetApply)?.callback).toBe(targetApply) + expect(targetFiber.inject).toEqual({ + testInvariantTargetDependency: null, + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(targetApply).not.toHaveBeenCalled() + + releaseDelayed.resolve() + await targetFiber + if (nestedFiber === undefined) throw new Error('target did not register its nested plugin') + await nestedFiber + + expect(targetFiber.state).toBe(FiberState.ACTIVE) + expect(targetApply).toHaveBeenCalledOnce() + expect(nestedApply).toHaveBeenCalledOnce() + const targetIndex = order.indexOf('target') + expect(targetIndex).toBeGreaterThan(-1) + expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2) + expect(order.at(-1)).toBe('nested') + + if (delayedCompanion === undefined) throw new Error('delayed companion did not load') + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(delayedCompanion) + expect(ctx.registry.get(InvariantService)?.fibers).toHaveLength(1) + expect(ctx.registry.get(delayedCompanion)?.fibers).toHaveLength(1) + }, + ) + }) + + it.each(['load', 'startup'] as const)( + 'rejects a target when a lazy companion fails during %s without starting the target', + async (phase) => { + const failure = new Error(`test invariant companion ${phase} failed`) + await withFakeCompanions( + (_path, index) => phase === 'load' && index === 0 + ? async () => { throw failure } + : async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (phase === 'startup' && index === 0) throw failure + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const targetFiber = ctx.plugin(targetApply) + + await expect(targetFiber).rejects.toBe(failure) + expect(targetApply).not.toHaveBeenCalled() + expect(targetFiber.state).toBe(FiberState.PENDING) + await expect(targetFiber.dispose()).resolves.toBeUndefined() + expect(targetFiber.state).toBe(FiberState.DISPOSED) + }, + ) + }, + ) + + it('disposes a pending target without waiting for companion readiness', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const targetFiber = ctx.plugin(targetApply) + + await delayedStarted.promise + await expect(targetFiber.dispose()).resolves.toBeUndefined() + expect(targetFiber.state).toBe(FiberState.DISPOSED) + expect(targetApply).not.toHaveBeenCalled() + + releaseDelayed.resolve() + await targetFiber + expect(targetApply).not.toHaveBeenCalled() + }, + ) + }) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 62c0102588..00f24e6d2f 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,7 +6,7 @@ */ import { expect } from 'vitest' -import { RegistryService } from 'cordis' +import { FiberState, Inject, RegistryService } from 'cordis' import type { Context, Plugin } from 'cordis' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -25,6 +25,9 @@ export interface TestInvariantCompanion { apply(ctx: Context): Promise<() => void> } +/** Private service dependency that holds ordinary root plugins until invariant startup completes. */ +export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady' + /** * Every package companion as a lazy loader keyed by glob path. Ordinary tests * load only their owner's module; the exhaustive topology test loads and @@ -47,6 +50,7 @@ interface InvariantHost { } type PluginFiber = ReturnType +type PluginCallback = Plugin.Function | Plugin.Constructor const hosts = new WeakMap() // oxlint-disable-next-line typescript/unbound-method -- every call below supplies its RegistryService receiver explicitly. @@ -64,10 +68,18 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing } - const fiber = originalPlugin.call(this, plugin, config, getOuterStack) - // A root-level await is the test's composition boundary. Nested plugin - // fibers must not await their own companion parent through the global host. - if (this.ctx !== root) return fiber + // Nested plugins run inside a target that already crossed the root barrier. + // Adding the same root-owned dependency there would make child lifecycle + // depend on an unrelated isolation scope and can deadlock companion startup. + if (this.ctx !== root) return originalPlugin.call(this, plugin, config, getOuterStack) + if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + + const fiber = originalPlugin.call( + this, + withInvariantReadiness(plugin, callback as PluginCallback), + config, + getOuterStack, + ) return joinInvariantStartup(fiber, host.ready) } @@ -126,8 +138,8 @@ function startInvariantHost(root: Context): InvariantHost { const serviceFiber = mount(InvariantService, { enabled: true }) const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) - const ready = serviceFiber.await().then(async () => { - const companionFibers = await Promise.all(companionPaths.map(async (path) => { + const ready = requireActive(serviceFiber, 'invariant service').then(async () => { + const companions = await Promise.all(companionPaths.map(async (path) => { const load = testInvariantCompanions[path] if (load === undefined) { throw new Error(`test invariants: selected companion vanished at ${path}`) @@ -136,20 +148,43 @@ function startInvariantHost(root: Context): InvariantHost { if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } - return mount(companion) + return { companion, path } })) - await Promise.all(companionFibers.map(fiber => fiber.await())) + const companionFibers = companions.map(({ companion, path }) => ({ + fiber: mount(companion), + path, + })) + await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path))) + root.provide(TEST_INVARIANT_READY_SERVICE, true) }) const host = { byCallback, ready } hosts.set(root, host) return host } +async function requireActive(fiber: PluginFiber, label: string): Promise { + await fiber.await() + if (fiber.state !== FiberState.ACTIVE) { + throw new Error(`test invariants: ${label} settled without becoming active`) + } +} + +function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugin.Object { + return { + apply: callback as Plugin.Function, + inject: { + ...Inject.resolve(plugin.inject), + [TEST_INVARIANT_READY_SERVICE]: null, + }, + ...(plugin.name === undefined ? {} : { name: plugin.name }), + ...(plugin.Config === undefined ? {} : { Config: plugin.Config }), + ...(plugin.provide === undefined ? {} : { provide: plugin.provide }), + ...(plugin.intercept === undefined ? {} : { intercept: plugin.intercept }), + } +} + function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = fiber.await().then(async (loaded) => { - await invariantReady - return loaded - }) + const readiness = invariantReady.then(() => fiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/vitest.config.ts b/vitest.config.ts index aa3e2b441b..0f4aac1e31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,9 +37,9 @@ const testIncludes = [ 'scripts/**/*.spec.ts', ] -// These suites exercise process-global state, process APIs, or timing-sensitive process I/O -// that worker threads cannot isolate reliably under aggregate gate contention. -// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. +// These suites exercise process-global state, process APIs, or timing-sensitive process I/O. +// Keep them in a separate project so Windows, whose main pool uses threads, +// still contains them in forks; POSIX uses forks for both projects. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', @@ -55,8 +55,9 @@ export default defineConfig({ // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), - // One coverage invocation aggregates both projects. Most suites use threads - // for lower startup/IPC overhead; only explicit process-bound suites fork. + // One coverage invocation aggregates both projects. POSIX uses forks to + // contain the Node CJS-lexer abort; Windows keeps threads for the main + // inventory and forks only the explicit process-bound project. projects: [ { plugins: [pathsPlugin()], @@ -156,11 +157,13 @@ export default defineConfig({ 'packages/client/ui-sidebar/src/client/index.ts', 'packages/client/ui-skill/src/client/index.ts', 'packages/client/ui-workspace/src/client/index.ts', - // Typert generator: correctness is pinned by its fixture suites and - // the byte-for-byte catalog reproduction test; per-file coverage - // would put whole-workspace compiler analysis under v8 - // instrumentation — the coverage lane's longest tail. - 'packages/typert/generator/src/*.ts', + // These three whole-workspace Typert passes are pinned by fixture and + // byte-for-byte catalog tests; v8 instrumentation makes them the + // coverage lane's longest tail. The generator's lighter modules and + // future source files retain the 100% per-file threshold. + 'packages/typert/generator/src/analyzer.ts', + 'packages/typert/generator/src/renderer.ts', + 'packages/typert/generator/src/cordis-catalog.ts', 'packages/host/apiproxy/src/index.ts', 'packages/host/apiproxy/src/invariant.ts', 'packages/host/apiproxy/src/api-proxy.ts', From 573a062afd85569f8fa892e78de543750f41a6bd Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 23:54:21 +0800 Subject: [PATCH 06/12] Gate derived test contexts on invariant readiness --- scripts/test-invariants.spec.ts | 51 ++++++++++++++++++++++++++++++++- scripts/test-invariants.ts | 28 ++++++++++++++---- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index e2406837ca..40c93fe9ad 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -120,18 +120,20 @@ describe('global test invariant host', () => { const releaseDelayed = deferred() const order: string[] = [] let delayedCompanion: TestInvariantCompanion | undefined + const companionNestedApply = vi.fn(function companionNestedApply() {}) await withFakeCompanions( (path, index) => async () => { const companion: TestInvariantCompanion = { name: `test-invariant-${index}`, inject: ['invariants'], - async apply() { + async apply(companionCtx) { order.push(`companion-start:${path}`) if (index === 0) { delayedStarted.resolve() await releaseDelayed.promise } + if (index === 1) await companionCtx.plugin(companionNestedApply) order.push(`companion-active:${path}`) return () => {} }, @@ -173,6 +175,7 @@ describe('global test invariant host', () => { expect(targetFiber.state).toBe(FiberState.ACTIVE) expect(targetApply).toHaveBeenCalledOnce() expect(nestedApply).toHaveBeenCalledOnce() + expect(companionNestedApply).toHaveBeenCalledOnce() const targetIndex = order.indexOf('target') expect(targetIndex).toBeGreaterThan(-1) expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2) @@ -187,6 +190,52 @@ describe('global test invariant host', () => { ) }) + it('holds plugins registered on a root-derived context until companion readiness', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const rootApply = vi.fn(function rootApply() {}) + const derivedApply = vi.fn(function derivedApply() {}) + const derived = ctx.extend() + .isolate('testInvariantDerived') + .intercept('testInvariantDerived', {}) + + const rootFiber = ctx.plugin(rootApply) + const derivedFiber = derived.plugin(derivedApply) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(rootApply).not.toHaveBeenCalled() + expect(derivedApply).not.toHaveBeenCalled() + expect(derivedFiber.inject).toEqual({ + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + releaseDelayed.resolve() + await Promise.all([rootFiber, derivedFiber]) + expect(rootFiber.state).toBe(FiberState.ACTIVE) + expect(derivedFiber.state).toBe(FiberState.ACTIVE) + expect(rootApply).toHaveBeenCalledOnce() + expect(derivedApply).toHaveBeenCalledOnce() + }, + ) + }) + it.each(['load', 'startup'] as const)( 'rejects a target when a lazy companion fails during %s without starting the target', async (phase) => { diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 00f24e6d2f..2ba460a9da 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -46,6 +46,7 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ interface InvariantHost { readonly byCallback: ReadonlyMap + readonly barrierOwners: WeakSet readonly ready: Promise } @@ -65,13 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) if (existing !== undefined) { - return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready) } - // Nested plugins run inside a target that already crossed the root barrier. - // Adding the same root-owned dependency there would make child lifecycle - // depend on an unrelated isolation scope and can deadlock companion startup. - if (this.ctx !== root) return originalPlugin.call(this, plugin, config, getOuterStack) + // Causal descendants of a gated target have already crossed the barrier. + // Host service and companion descendants also bypass it so their own startup + // cannot depend on the readiness they are responsible for providing. + if (hasBarrierOwner(host, this.ctx)) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) const fiber = originalPlugin.call( @@ -80,6 +83,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + host.barrierOwners.add(fiber.ctx.fiber) return joinInvariantStartup(fiber, host.ready) } @@ -120,11 +124,13 @@ export function testInvariantCompanionPaths(testPath: string): string[] { function startInvariantHost(root: Context): InvariantHost { const byCallback = new Map() + const barrierOwners = new WeakSet() const mount = (plugin: Plugin, config?: unknown): PluginFiber => { const fiber = originalPlugin.call(root.registry, plugin, config) const callback = root.registry.resolve(plugin) if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') byCallback.set(callback, fiber) + barrierOwners.add(fiber.ctx.fiber) return fiber } @@ -157,11 +163,21 @@ function startInvariantHost(root: Context): InvariantHost { await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path))) root.provide(TEST_INVARIANT_READY_SERVICE, true) }) - const host = { byCallback, ready } + const host = { byCallback, barrierOwners, ready } hosts.set(root, host) return host } +function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean { + let fiber = ctx.fiber + while (true) { + if (host.barrierOwners.has(fiber)) return true + const parent = fiber.parent.fiber + if (parent === fiber) return false + fiber = parent + } +} + async function requireActive(fiber: PluginFiber, label: string): Promise { await fiber.await() if (fiber.state !== FiberState.ACTIVE) { From ed41269e04e706cba15ae463f451a8c657b9bbaf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 23:57:58 +0800 Subject: [PATCH 07/12] Keep pending invariant children behind readiness --- scripts/test-invariants.spec.ts | 45 +++++++++++++++++++++++++++++++++ scripts/test-invariants.ts | 7 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 40c93fe9ad..18c3a7f860 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -236,6 +236,51 @@ describe('global test invariant host', () => { ) }) + it('holds a child registered externally on a pending target context', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const childApply = vi.fn(function childApply() {}) + + const targetFiber = ctx.plugin(targetApply) + const childFiber = targetFiber.ctx.plugin(childApply) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(targetFiber.state).toBe(FiberState.PENDING) + expect(childFiber.state).toBe(FiberState.PENDING) + expect(targetApply).not.toHaveBeenCalled() + expect(childApply).not.toHaveBeenCalled() + expect(childFiber.inject).toEqual({ + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + releaseDelayed.resolve() + await Promise.all([targetFiber, childFiber]) + expect(targetFiber.state).toBe(FiberState.ACTIVE) + expect(childFiber.state).toBe(FiberState.ACTIVE) + expect(targetApply).toHaveBeenCalledOnce() + expect(childApply).toHaveBeenCalledOnce() + }, + ) + }) + it.each(['load', 'startup'] as const)( 'rejects a target when a lazy companion fails during %s without starting the target', async (phase) => { diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 2ba460a9da..8ebf7a6243 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -171,7 +171,12 @@ function startInvariantHost(root: Context): InvariantHost { function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean { let fiber = ctx.fiber while (true) { - if (host.barrierOwners.has(fiber)) return true + if ( + host.barrierOwners.has(fiber) + && (fiber.state === FiberState.LOADING || fiber.state === FiberState.ACTIVE) + ) { + return true + } const parent = fiber.parent.fiber if (parent === fiber) return false fiber = parent From 096fe8b6d7f13794e41ef70a9b3e2f090cebd68e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 00:41:48 +0800 Subject: [PATCH 08/12] Deduplicate invariant barrier fixtures --- scripts/test-invariants.spec.ts | 87 +++++++++++++-------------------- 1 file changed, 33 insertions(+), 54 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 18c3a7f860..edea360095 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -50,6 +50,27 @@ async function withFakeCompanions( } } +async function withDelayedFirstCompanion( + run: (control: { readonly started: Promise; readonly release: () => void }) => Promise, +): Promise { + const started = deferred() + const release = deferred() + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + started.resolve() + await release.promise + } + return () => {} + }, + }), + () => run({ started: started.promise, release: release.resolve }), + ) +} + describe('global test invariant host', () => { it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() @@ -191,22 +212,8 @@ describe('global test invariant host', () => { }) it('holds plugins registered on a root-derived context until companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - (_path, index) => async () => ({ - name: `test-invariant-${index}`, - inject: ['invariants'], - async apply() { - if (index === 0) { - delayedStarted.resolve() - await releaseDelayed.promise - } - return () => {} - }, - }), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const rootApply = vi.fn(function rootApply() {}) const derivedApply = vi.fn(function derivedApply() {}) @@ -217,7 +224,7 @@ describe('global test invariant host', () => { const rootFiber = ctx.plugin(rootApply) const derivedFiber = derived.plugin(derivedApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(rootApply).not.toHaveBeenCalled() @@ -226,7 +233,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([rootFiber, derivedFiber]) expect(rootFiber.state).toBe(FiberState.ACTIVE) expect(derivedFiber.state).toBe(FiberState.ACTIVE) @@ -237,22 +244,8 @@ describe('global test invariant host', () => { }) it('holds a child registered externally on a pending target context', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - (_path, index) => async () => ({ - name: `test-invariant-${index}`, - inject: ['invariants'], - async apply() { - if (index === 0) { - delayedStarted.resolve() - await releaseDelayed.promise - } - return () => {} - }, - }), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const childApply = vi.fn(function childApply() {}) @@ -260,7 +253,7 @@ describe('global test invariant host', () => { const targetFiber = ctx.plugin(targetApply) const childFiber = targetFiber.ctx.plugin(childApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(targetFiber.state).toBe(FiberState.PENDING) @@ -271,7 +264,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([targetFiber, childFiber]) expect(targetFiber.state).toBe(FiberState.ACTIVE) expect(childFiber.state).toBe(FiberState.ACTIVE) @@ -312,32 +305,18 @@ describe('global test invariant host', () => { ) it('disposes a pending target without waiting for companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - (_path, index) => async () => ({ - name: `test-invariant-${index}`, - inject: ['invariants'], - async apply() { - if (index === 0) { - delayedStarted.resolve() - await releaseDelayed.promise - } - return () => {} - }, - }), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const targetFiber = ctx.plugin(targetApply) - await delayedStarted.promise + await started await expect(targetFiber.dispose()).resolves.toBeUndefined() expect(targetFiber.state).toBe(FiberState.DISPOSED) expect(targetApply).not.toHaveBeenCalled() - releaseDelayed.resolve() + release() await targetFiber expect(targetApply).not.toHaveBeenCalled() }, From ed5a82f93072da451f4d187fcaf61a9aed8476b8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:04:21 +0800 Subject: [PATCH 09/12] Fix invariant config validation readiness --- scripts/test-invariants.spec.ts | 32 +++++++++++++++++++++++++++++++- scripts/test-invariants.ts | 23 +++++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index edea360095..d2582d3c68 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service, ValidationError } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import z from 'schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -136,6 +137,35 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + it('preserves config validation failures without starting the rejected plugin', async () => { + const ctx = new Context() + const apply = vi.fn(function invalidConfigApply() { + throw new Error('invalid plugin apply executed') + }) + const plugin = { + apply, + Config: z.object({ + requiredValue: z.string().required(), + }), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError: unknown = await fiber.then( + () => undefined, + (error: unknown) => error, + ) + expect(firstError).toBeInstanceOf(ValidationError) + expect(firstError).toHaveProperty('message', expect.stringMatching(/requiredValue/)) + await ctx.plugin(TestInvariantProbe) + const secondError: unknown = await fiber.then( + () => undefined, + (error: unknown) => error, + ) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 8ebf7a6243..a0fc6f295e 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -75,7 +75,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge if (hasBarrierOwner(host, this.ctx)) { return originalPlugin.call(this, plugin, config, getOuterStack) } - if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + if (callback === undefined) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } const fiber = originalPlugin.call( this, @@ -84,7 +86,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge getOuterStack, ) host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready) + return joinInvariantStartup(fiber, host.ready, true) } /** @@ -204,8 +206,21 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi } } -function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = invariantReady.then(() => fiber.await()) +function joinInvariantStartup( + fiber: PluginFiber, + invariantReady: Promise, + disposePendingFailure = false, +): PluginFiber { + const initialized = disposePendingFailure + ? fiber.await().catch(async (error: unknown) => { + // Config validation is the only failure recorded while a gated fiber + // is still PENDING. Dispose it before readiness publication can + // refresh the rejected fiber with its uninitialized config. + if (fiber.state === FiberState.PENDING) await fiber.dispose() + throw error + }) + : Promise.resolve() + const readiness = initialized.then(() => invariantReady).then(() => fiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined From 9853e74d0e2111f621430757ca1778d5d6ec7a13 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:18:10 +0800 Subject: [PATCH 10/12] Close invariant readiness validation race --- scripts/test-invariants.spec.ts | 82 ++++++++++++++++++++++++++------- scripts/test-invariants.ts | 13 +++--- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index d2582d3c68..b119f9657b 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -33,6 +33,28 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => return { promise, resolve } } +function requiredConfig() { + return z.object({ + requiredValue: z.string().required(), + }) +} + +function invalidConfigApply(): never { + throw new Error('invalid plugin apply executed') +} + +async function rejectionOf(fiber: ReturnType): Promise { + return fiber.then( + () => undefined, + (error: unknown) => error, + ) +} + +function expectRequiredConfigValidation(error: unknown): void { + expect(error).toBeInstanceOf(ValidationError) + expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/)) +} + async function withFakeCompanions( create: (path: string, index: number) => () => Promise, run: () => Promise, @@ -139,33 +161,61 @@ describe('global test invariant host', () => { it('preserves config validation failures without starting the rejected plugin', async () => { const ctx = new Context() - const apply = vi.fn(function invalidConfigApply() { - throw new Error('invalid plugin apply executed') - }) + const apply = vi.fn(invalidConfigApply) const plugin = { apply, - Config: z.object({ - requiredValue: z.string().required(), - }), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError: unknown = await fiber.then( - () => undefined, - (error: unknown) => error, - ) - expect(firstError).toBeInstanceOf(ValidationError) - expect(firstError).toHaveProperty('message', expect.stringMatching(/requiredValue/)) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) await ctx.plugin(TestInvariantProbe) - const secondError: unknown = await fiber.then( - () => undefined, - (error: unknown) => error, - ) + const secondError = await rejectionOf(fiber) expect(secondError).toBe(firstError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }) + it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: z.intersect([ + z.transform(z.any(), () => { + queueMicrotask(() => { + disposeQueuedReadiness = ctx.provide(TEST_INVARIANT_READY_SERVICE, true) + }) + return {} + }, true), + requiredConfig(), + ]), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + + await started + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + disposeQueuedReadiness() + release() + await ctx.plugin(TestInvariantProbe) + + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index a0fc6f295e..649e680cc2 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -85,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + const initiallyPending = fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready, true) + return joinInvariantStartup(fiber, host.ready, initiallyPending) } /** @@ -209,14 +210,14 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise, - disposePendingFailure = false, + disposeInitialFailure = false, ): PluginFiber { - const initialized = disposePendingFailure + const initialized = disposeInitialFailure ? fiber.await().catch(async (error: unknown) => { // Config validation is the only failure recorded while a gated fiber - // is still PENDING. Dispose it before readiness publication can - // refresh the rejected fiber with its uninitialized config. - if (fiber.state === FiberState.PENDING) await fiber.dispose() + // is initially PENDING. Dispose it even if queued readiness publication + // changes its state before this rejection handler runs. + await fiber.dispose() throw error }) : Promise.resolve() From a4747f43bdda74955771a2e3cb236d5db74c1aaa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 01:28:34 +0800 Subject: [PATCH 11/12] Probe invariant readiness on raw fibers --- scripts/test-invariants.spec.ts | 67 ++++++++++++++++++++++++++++++--- scripts/test-invariants.ts | 12 ++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index b119f9657b..2c00bc7439 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -39,6 +39,18 @@ function requiredConfig() { }) } +function queuedReadinessConfig( + ctx: Context, + onPublished: (dispose: () => void) => void, +) { + return z.transform(z.any(), () => { + queueMicrotask(() => { + onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) + }) + return {} + }, true) +} + function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -186,12 +198,9 @@ describe('global test invariant host', () => { const plugin = { apply, Config: z.intersect([ - z.transform(z.any(), () => { - queueMicrotask(() => { - disposeQueuedReadiness = ctx.provide(TEST_INVARIANT_READY_SERVICE, true) - }) - return {} - }, true), + queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), requiredConfig(), ]), } @@ -216,6 +225,52 @@ describe('global test invariant host', () => { ) }) + it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const failure = new Error('valid plugin apply failed') + const applied = deferred() + const apply = vi.fn(function validConfigApply() { + applied.resolve() + throw failure + }) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + } + + const fiber = ctx.plugin(plugin, {}) + const returnedError = rejectionOf(fiber) + try { + await Promise.all([started, applied.promise]) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness() + release() + + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + } finally { + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness?.() + release() + } + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 649e680cc2..9705f590de 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -85,7 +85,7 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) - const initiallyPending = fiber.state === FiberState.PENDING + const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) return joinInvariantStartup(fiber, host.ready, initiallyPending) } @@ -212,16 +212,20 @@ function joinInvariantStartup( invariantReady: Promise, disposeInitialFailure = false, ): PluginFiber { + // RegistryService returns a thenable wrapper whose context still points to + // the raw Fiber. Calling inherited await() on the wrapper would return and + // assimilate that thenable, accidentally following later plugin startup. + const rawFiber = fiber.ctx.fiber const initialized = disposeInitialFailure - ? fiber.await().catch(async (error: unknown) => { + ? rawFiber.await().catch(async (error: unknown) => { // Config validation is the only failure recorded while a gated fiber // is initially PENDING. Dispose it even if queued readiness publication // changes its state before this rejection handler runs. - await fiber.dispose() + await rawFiber.dispose() throw error }) : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => fiber.await()) + const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined From cc0d13872f8933d7cbc6ff9989f61b67b87c1466 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 09:29:19 +0800 Subject: [PATCH 12/12] docs(client): correct checkpoint pin contract --- ...eb-transcript-log-ordered-projection.i18n.yaml | 4 ++-- ...07-30-web-transcript-log-ordered-projection.md | 2 +- ...30-web-transcript-log-ordered-projection.zh.md | 2 +- tsconfig.client.json | 15 ++++++--------- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index 4c078446f1..ff33e2219c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: 0c58373d58e08fbfe260f16f2104b3f7d1ccc3fd -2026-07-30-web-transcript-log-ordered-projection.zh.md: 49de0a9ecbbf2da92113f8ad63afe6478caedadd +2026-07-30-web-transcript-log-ordered-projection.md: 9fb338643774efaeb9deab6f66920a9f4276ce67 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 7962dd432b8bbf115acde9dd480eba9c91f35bfc diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index 0c58373d58..9fb3386437 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -37,7 +37,7 @@ 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**. It runs in the client **test** program, which may value-import the root; a `packages/client/*` package program may not. +`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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index 49de0a9ecb..7962dd432b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -37,7 +37,7 @@ 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` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。它运行在客户端**测试**程序中,那里可以值导入包根;`packages/client/*` 包工程不可以。 +`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。 因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。 diff --git a/tsconfig.client.json b/tsconfig.client.json index 7f072e3c21..e1d4088061 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -37,15 +37,12 @@ // 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 drift trap value-imports the seam's - // canonical checkpoint const from the package ROOT, which may be reached - // from this TEST program but never from a packages/client/* package - // program — the root reaches dsh-session's root, whose Context merge - // declares the host `sessions: SessionStore` and collides with the - // client's `ISessions`. Package programs use the cordis-free - // dsh-compact/checkpoint leaf instead (a type-only import in - // transcript-adapter.ts), which needs this reference on the runtime - // package project itself. + // 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" },