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..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 @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-gui-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642 -2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb +2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c 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..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 @@ -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; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. -- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. +- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark. - **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory. ## The React face (`packages/client/web-react`) 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..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 @@ -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 数组�建但元素引用�自缓存;未�的�结构�用上一快照的引用。 +- **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 会让�批塌回�帧渲染;�。 -- **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/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml new file mode 100644 index 0000000000..047cccbce3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +2026-07-29-projected-token-usage-and-request-context.md: 1e2c5ff067928620dee3d0937c247bec245e34f2 +2026-07-29-projected-token-usage-and-request-context.zh.md: 811d92e134b1df0fc6725e6c8d38b37efb57b3aa diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md new file mode 100644 index 0000000000..1e2c5ff067 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -0,0 +1,59 @@ +# Agent Note: Projected token usage and context occupancy + +Status: implemented + +English | [中文](2026-07-29-projected-token-usage-and-request-context.zh.md) + +## Problem + +The Web stats line derived token totals from the currently loaded conversation nodes. That window is paged, so scrolling changed the totals, and compaction replaces visible content without preserving the billing behind it. Durable provider billing needs a source that survives both. + +Context occupancy needs a numerator and a denominator that no existing surface carried to the browser: the prompt size of the latest request, and the capacity of the route it used. + +## Decision + +Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present. + +`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing. + +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional `contextWindow` from the newest `request/context` record. Neither field is synthesized before its source exists. + +`request/context` is a new log-only session event recording registration-bound metadata for the route a request resolved to. AgentLoop appends it inside the step beside `request/header`, from the context metadata `prepareCall()` now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, clearing an older route's denominator. + +Capacity deliberately stays out of `EpochHeader`. That type is the reconstruction contract — what a request was built from — and `headerEquals` compares it field-wise to decide whether a snapshot is a real `change`. Capacity is adapter metadata describing a route, so placing it there would let a capacity change masquerade as a request-envelope change and would drag it into the loop's reconstruction invariant. + +Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence. + +The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. + +## Context occupancy is approximate, and that is the decision + +`pressureTokens` and `contextWindow` are independent last-wins fields, not one atomic observation. Switching models pairs a fresh capacity with the previous route's pressure until the next request reports usage, and the numerator describes the last request rather than the surface as it currently stands. + +This was accepted deliberately. An occupancy percentage is a user-facing reference figure: nothing in the harness makes decisions from it, and compaction reads `measure()` directly instead. The TUI status line has always computed occupancy this way, dividing a `measure()` total by a capacity resolved separately for the selected model — so an atomic variant here would have been the outlier, not the norm. + +Reviewers should not treat the non-atomicity as a defect awaiting a fix. A consumer that genuinely needs an exact same-boundary figure should call `ctx.tokenMeter.measure()` at its own request boundary, where both values are available together, rather than read this projection. + +## Alternatives considered + +**An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision of this branch emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison. + +That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. + +**Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics. + +**Publish usage only with final assistant messages.** A request that reports a usage chunk and then fails would lose its billing. + +**Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written. + +**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model`, and `ui-conversation` cannot depend on `ui-model`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write. + +**Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path. + +## Consequences + +Token totals stay stable across pagination, compaction, replay, restart, and reconnect, because they are ordinary durable projection state recovered through the generic paths. The cross-stream reordering race is gone by construction rather than fenced. + +Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. + +Each session log gains one small `request/context` record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md new file mode 100644 index 0000000000..811d92e134 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -0,0 +1,59 @@ +# Agent Note: token 用�投影与上下文�用率 + +Status: implemented + +[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文 + +## 问题 + +Web 统计行原先从当�已加载的会�节点推导 token 总�。该窗�是分页的,因此滚动会改�总�;压缩(compaction)�会替���内容,而��留其背�的计费用�。�久的�供方计费用�需�一个能�时��这两者的数��。 + +上下文�用率需�一个分�和一个分�,而这两者都�曾由任何既有接��达�览器:最新一个请求的�示�规模,以�该请求所用路由的容�。 + +## 决策 + +这两个值都是普通的�久会�投影状�。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个�元。 + +`tokenUsage` 将完整�久日志归并为未缓存输入�输出�缓存读�和缓存写入四类计数项。�使�续请求失败,`assistant/chunk` 用�样本�会�留;�一 `(turn, step)` 的 `assistant/message` 用�值会替�先�样本,�会��计数。推�(reasoning)�是输出的细分项。压缩和表层替��会抹除先�的计费用�。 + +`contextPressure` �带�选的 `pressureTokens`(�供方报告的最新�示�规模,为未缓存输入加缓存读�与写入之和,��输出),以��自最新一� `request/context` 记录的�选 `contextWindow`。在�自��出现�,两个字段都�会被��。 + +`request/context` 是新增的仅入日志会�事件,记录请求所解�到的路由的�绑定注册项的元数�。AgentLoop 在步骤内紧� `request/header` 追加它,数��自 `prepareCall()` 现在与已解��置一并返回的上下文元数�:正是那次已�校验过推�的�绑定注册项的查询,因此�会�生第二次解�。当�供方�模型和容�都与上一�记录相�时会跳过。适�器�公布容�的路由会以缺失 `contextWindow` 的形�记录,从而清除较早路由的分�。 + +容�刻��进入 `EpochHeader`。该类型是�建契约,�请求由什么构建而�,而 `headerEquals` 会�字段比较它,以判定�个快照是�真的是一次 `change`。容�是�述路由的适�器元数�,把它放进去会让容��化伪装�请求�装的�化,还会把它拖进 AgentLoop 的�建���。 + +两个�元都沿用标准投影生命周期:历�尾页基线�`session/projection` 实时帧�seq 高者胜的客户端存储�JSON 检查点�缓存��和�元�载。系统没有任何 token 专用的历�字段�mux 帧�投影器�修订计数器或客户端栅�。 + +Web `StatsLine` 通过标准 `useProjection` 席�读�两者。窗�内节点��供轮次和步骤计数,以� LLM(大语言模型)与工具的墙钟时间:它们回答的是「�幕上有什么�,按窗�作用域正是正确的。压缩使�� assistant 步骤归零�,�久 token 与上下文分组�会�留。缓存写入会计入计费输入和缓存命中率分�。未部署 token-meter 时会去掉 token 分组;�有压力与容�都已知时�显示�用率。 + +## 上下文�用率是近似值,而这正是决策本身 + +`pressureTokens` 与 `contextWindow` 是两个�自�者胜的独立字段,�是一次原�观测。切�模型时,新容�会与上一路由的压力�对,直到下一个请求报告用�为止;分��述的是最�一个请求,而�是此刻的表层。 + +这是刻�接�的结果。�用率百分比是��用户的�考数字:harness 中没有任何环节��它�决策,压缩改为直接读� `measure()`。TUI 状�行一直以这�方�计算�用率,�用 `measure()` 总�除以为所选模型�独解�出的容�;因此在这里��原�版本�是异类,而�是常�。 + +评审人�应把这��原�性当作待修的缺陷。确实需��一边界精确数字的消费方,应在自己的请求边界调用 `ctx.tokenMeter.measure()`,那里两个值�时�得,而�是读�该投影。 + +## 备选方案 + +**以临时 mux 帧交付请求边界上的原�快照(已实现,���决)。** 本分支较早的一个修订版会�出 `session/model-request`:一个��回放的帧,�带在�一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它�了 mux �上唯一的��回放类别。Host �与 mux �是两�独立的 SSE(Server-Sent Events)�,彼此之间没有顺���:在移除之��出的请求�能在 `host/session-removed` 之��到达,让一个已死会�的�测数��活;而�用�一 id 的新生命周期的�法请求,��能被一�迟到的移除拦下。`session/subscribed` �能�明生命周期:它�说明�个队列开始订阅�个 id,而�说明新的内存会�替�了较早的会�;`lastSeq` 则是两个生命周期�以共用的�久水�线。正确的修法需�在帧上�订阅上和移除上都带一个�调递增的生命周期代次,�加上一次客户端水�线比较。 + +这份代价��的是更差的显示:�用率在�次�连��为空白,而且会�增长期间从�移动。它还把 ApiProxy ��一个测�点,�个请求都�调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处�的�连接打开时的�� `cancelled` 错误�表达�连状�。 + +**在 React 中归并已加载的节点窗�。** 无法跨分页或压缩�留数�,还会迫使展示包(package)�建日志语义。 + +**仅�最终 assistant 消��布用�。** 如果请求报告一个用�分片�失败,就会丢失自己的计费用�。 + +**在 token-meter 内部解�容�。** 该包自述与模型路由无关,且在其他方�是一个从��日志追加内容的纯读�方。AgentLoop 在写入请求头的�置已��有已解�的元数�。 + +**为 `session.models` RPC 增加容�字段。** 其处�器已�解�出容��将其丢弃,因此这个字段几乎是�费的;但 `StatsLine` �于 `ui-conversation`,模型目录�于 `ui-model`,而 `ui-conversation` �能�赖 `ui-model`。��达它,就得增加第二个 dock �目�把一行文本拆到两个�件里,或者�一次跨�件的 store 写入。 + +**在模型选择器�增加上下文圆环。** 该�置会让人以为这是所选模型的状�。统计行�以承载该数字,无需引入��的 UI 或数�路径。 + +## �果 + +token 总�在分页�压缩�回放���和�连期间��稳定,因为它们是通过通用路径��的普通�久投影状�。跨��排�竞�从构造上就�存在,而�是被栅�挡�。 + +�用率在上文记录的�义上是近似值。由于两个字段都是�久的,它在��或�连�立��用;代价是它�述的是最�一�已记录的请求,而�是精确的当�边界。 + +�个会�日志会为�次路由或已公布容��化增加一��型 `request/context` 记录。token-meter 投影是�久会�投影用�语义的正典所有方;TUI 未挂载通用投影 seam,因此�留自己的实时�步骤 map,而独立�览器 fixture 会镜�该�元。ApiProxy ��带任何 token 专用代�,�拥有�会�指标缓存,也�执行测�。�览器��留两个通用投影值,��留连接本地的�测数�;��文本增���会迫使统计行�新计算。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index a56a91c980..bca3fb39ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 -2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c +2026-07-30-client-locale-full-rollout.md: a357f20734d1aa8df60efbf28fd5b8a1a814d63e +2026-07-30-client-locale-full-rollout.zh.md: d22b743f0597405e7f42374ec2caed5523e85595 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c080d9f240..a357f20734 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure. -**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default. +**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 062d982e3d..d22b743f05 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -25,7 +25,7 @@ typed locale 标准席�(`locale:` 注册声明 → 框架注入强类型 `t` **派生层��纯函数,本地化�在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组�字典模�;blank 会�/未分组桶的存储标题��,渲染按 `blank` 标志/`workspaceId` 缺席替�本地化文案;**�索� blank 行一律排除**(�语标题无法与�语查询稳定匹�)。日期�引 Intl:格�模�进字典(消�时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格�化函数� `t` �数��纯。 -**测试与 e2e �径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜��务查找链(首个命中字典胜出�key 兜底�`{name}` �值),组件测试的 `t` 桩统一用它并以真实 props 席�定型。web e2e 统一 `newEnglishPage`(boot �钉 `dsh.locale=en`),built-boot snapshot �样钉 en——golden 对语言�移�疫;settings 语言切�用例刻�绕开该 helper 覆盖 zh 默认�。 +**测试与 e2e �径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜��务查找链(首个命中字典胜出�key 兜底�`{name}` �值),组件测试的 `t` 桩统一用它并以真实 props 席�定型。web e2e 统一 `newEnglishPage`(boot �钉 `dsh.locale=en`),built-boot snapshot �样钉 en——golden 对语言�移�疫;settings 语言切�用例绕开该 helper 并开� `zh-CN` �览器,因为�始 locale 跟� `navigator`([由�览器推导�始 locale](../feature/2026-07-31-browser-derived-initial-locale.md))。 [settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` �注册刷新 label"的机制已被本决定�代(thunk + revision 生命周期)。 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-tui-adapter-registration-race.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.i18n.yaml new file mode 100644 index 0000000000..b3d987b985 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.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-tui-adapter-registration-race.md +2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8 +2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md new file mode 100644 index 0000000000..fd08e7b613 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.md @@ -0,0 +1,29 @@ +# Agent Note: TUI model-context resolution defers on the adapter-registration race + +Status: implemented + +English | [中文](2026-07-30-tui-adapter-registration-race.zh.md) + +## Problem + +Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked). + +## Decision + +The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice. + +## Alternatives considered + +**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction. + +**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free. + +**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration. + +**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself. + +**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes. + +## Consequences + +A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md new file mode 100644 index 0000000000..0c6bba4bbc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-tui-adapter-registration-race.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI 模型上下文解�在适�器注册竞争时延��试 + +Status: implemented + +[English](2026-07-30-tui-adapter-registration-race.md) | 中文 + +## Problem + +Cordis 按�务�用性而��置顺�激活�件,因此 TUI(其 `inject` ��求 `llm` �务)�能在 `dsh-llm-pi-ai` 这类已�置的适�器�件完��供方路由注册之�就挂载。TUI 的模型控制器在挂载时立�解�所选模型的上下文窗�;当 agent 的路由指�尚未注册的�供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒�,于是�个新会�都会打� `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的�置报出的虚�错误(适�器几毫秒�就完�注册,对�也一切正常)。 + +## Decision + +TUI 模型控制器把上下文窗�解�中的 `NO_ADAPTER` 拒�视为瞬�状�而�错误:�默�置这次解�,并在下一次 `llm/adapters-updated` �交时�新解�——这是 `LlmService` 本就在�个路由�交点�出的无载�注册表通知。若�次�交�缺少该路由,等待会被�次�置,因此无关的拓扑�化��沉默。任何目标�更都会�新进入解�并清除挂起的等待,因此延�状���会相对当�选择�陈旧;其他所有解�错误�照常打�通知。 + +## Alternatives considered + +**让 TUI 等�动结算��解�。** TUI ��赖 Loader(测试和嵌入方在没有 Loader 的环境下�行),而且"已结算"在�件内部��观测;为一次外观性的解�引入 Loader 耦�会颠倒�赖方�。 + +**用定时器轮询或�试。** 定时器�能猜测激活延迟,�到慢适�器�会误报,还会引入一个没有归属者的�调�数。注册表本就通过 `llm/adapters-updated` 公告�次�交;订阅它既精确�零�本。 + +**调整�置顺�让适�器先加载。** Loader 中行顺��承载加载语义(激活按设计由�务驱动),因此这无法用�置表达。 + +**彻底压制 NO_ADAPTER 错误。** 那样的�,永久缺失的适�器(�供方�字拼错)在上下文窗�路径上就永远�会暴露。延��试�留了信�:错误的�供方�字�会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而�动竞争则自行化解。 + +**改为在�次�交消�时解�上下文窗�,而�是在挂载时。** ��路径本就按步解�(`prepareCall()`),且指示器是�续显示的,��在��时;按�交解�显示值会让指示器在首�消�之�一直空白,并为一个仅在路由�化时��的值��执行适�器 I/O。 + +## Consequences + +真正�置错误的�供方��在�动时打�上下文解�错误——它改在首次分派时暴露,那�是该失败�以被处�的地方。控制器订阅�次 `llm/adapters-updated` �交,但�在有等待被�置时�动作;监�器的 disposer �由控制器的 `detach()` 在频�的 `detachListeners()` 中释放,与�级频�监�器��对称。由三个 TUI 测试覆盖:延�的解�在无关�交中��沉默�在该路由的�交到�时完�;目标�更丢弃陈旧等待;频� detach 之�注册表�交���新进入解�。 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..ff33e2219c --- /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: 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 new file mode 100644 index 0000000000..9fb3386437 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -0,0 +1,72 @@ +# Agent Note: The browser conversation is a log-ordered human transcript + +Status: implemented + +English | [中文](2026-07-30-web-transcript-log-ordered-projection.zh.md) + +## Problem + +The browser client built its conversation from the model-visible surface: `FoldAdapter` ran the core `SurfaceManager` over the history window and read `surface.nodes`. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the web flow collapsed every message it shadowed into a single dim context row — conversation the user had already read. Nothing was lost from the log; the defect was entirely in the projection, and [the terminal and the host gateway were fixed the same way](2026-07-29-human-transcript-append-origin.md) while the browser was left for this change. + +Surface order made two further problems structural. It is not seq-ascending after a replacement — `SurfaceManager` splices the high-seq checkpoint into the position of the range it shadows — so log-only nodes merged into that array by numeric seq (slash-command rows, interrupted frozen nodes) could be flushed ahead of the checkpoint and never interleave into the retained tail again. And because pagination no longer spends `maxMessages` quota on replacement copies, a page can now carry a checkpoint whose `surfaceOp.start` lies outside the window; the core fold rejects that range, so `nodes()` fell back to a lenient linear scan behind a `console.error` and published a `foldDegraded` flag describing the failure. + +## Decision + +`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the raw window in log order: every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends. + +Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged. + +`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended. + +The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text. + +No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. + +## Recognizing a checkpoint: one declaration, pinned at compile time + +Recognition needs all three conditions, as in the terminal: `event.type === 'user/message'`, the compaction seam's checkpoint plugin source, **and** `isReplacementSurfaceEvent(event)`. A plugin-sourced `user/message` that *appends* is injected context — a session-reference card — not a compaction. + +What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root**, not the package. The root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against the client's `sessions: ISessions` — `TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout) — and that holds for a type-only import too, because the collision is a compiler fact rather than a bundler one. + +The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import: + +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` + +Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`. + +`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it. + +The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type. + +## What #835's positional anchors were for, and why they are dissolved rather than lost + +The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here. + +## Alternatives considered + +**Value-import the predicate** from the new leaf and add `dsh-compact` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf. + +**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk. + +**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate. + +**Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product. + +**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract. + +## Consequences + +Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files. + +`ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor. + +The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. + +The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface. + +## Deferred + +Compaction **progress** — an indicator while a compaction runs — needs the bracket-first ordering the queued manual-compaction work introduces, and stays out of scope here as it did in the terminal. The marker also carries no **scale**: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. Both belong together, where the reader meets the two halves of the same information. 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..7962dd432b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -0,0 +1,72 @@ +# Agent Note: �览器会�是按日志顺�投影的人类对�记录 + +Status: implemented + +[English](2026-07-30-web-transcript-log-ordered-projection.md) | 中文 + +## Problem + +�览器客户端从模型��的 surface 构建会�:`FoldAdapter` 在历�窗�上�行核心 `SurfaceManager` 并读� `surface.nodes`。一次�功的压缩会用一个检查点节点替�一段 surface 范围,因此该替�一�地,Web �就把它所�蔽的��消�折��一行�暗的上下文——那是用户已�读过的对�。日志中什么都没丢失;缺陷完全在投影层,而[终端与宿主历�网关已按�一方�修�](2026-07-29-human-transcript-append-origin.md),�览器留给了本次�更。 + +surface 顺�还让�外两个问题�为结构性的。一次替�之�它并�按 seq ��——`SurfaceManager` 把高 seq 的检查点拼接到它所�蔽范围的�置上——因此按数值 seq 归并进该数组的仅日志节点(斜�命令行�被打断的冻结节点)�能被冲刷到检查点之�,�也无法交错回�留下�的尾部。而且由于分页��为 replacement 副本消耗 `maxMessages` �度,一页现在�以�带一个 `surfaceOp.start` �在窗�之外的检查点;核心 fold 拒�该范围,于是 `nodes()` 退回到一次宽容的线性扫��打�一� `console.error`,并�布一个�述该失败的 `foldDegraded` 标志。 + +## Decision + +`TranscriptAdapter` �代 `FoldAdapter`,并且从�查询 surface 顺�。它按日志顺�投影原始窗�:�个 append ��的 surface 事件(`isAppendSurfaceEvent`)�在它自己的日志�置上,外加�次�地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次�地的压缩会�留它在模型侧�蔽掉的对�,标记报告模型从哪里开始看��那段历�,而�是把它抹掉。仅模型��的 replacement 副本�进入记录:被�剪的 `tool/result` 和�新生�的 `assistant/message` �为模型�写一个节点,�在对�中标记任何边界。凡必须��模型所�内容的一切�读 surface;这是人类投影,两者现在在两个�端上都已分离。 + +节点顺�天然按 seq �调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折�� `CommandNode`,按 seq �入一个本已�调的数组——无锚点,无�排。`Session` �留被打断的冻结节点的归属,用一次普通排�按其分数 seq 归并,而这现在�好就是�顺�。检查点所引被�蔽范围�在窗�之外的窗�没有范围需�解�,因此标记正常渲染且�打�任何日志。 + +`foldDegraded` 从 `ConversationSnapshot` 消失,�之消失的是哨兵填充�它们所需的 `baseSeq` 算术,以� `degradedSeqs()`。它们的存在�为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所�述的 fold 已���行。删除该标志是修�的一部分,而�修�之�的清�——`degradedSeqs()` 本身已几乎就是按日志顺�的投影,�是作为抛错�的�点而�本�到达。 + +标记的摘�文本�自检查点自己的 `compact/summary` 溯�,���自�框的检查点载�——那是为模型撰写的指令信�。窗�切分把溯�留在窗�外时该行��展开而�空白,与无调用的工具结果�一�软退让;�续补上溯�的分页会解�出文本。 + +没有任何�久化事件�RPC 信��压缩事务或模型�� surface �生�化,也�需��移。 + +## 识别检查点:�一份声明,在编译期钉� + +识别需�三个�件�时�立,与终端一致:`event.type === 'user/message'`�压缩�隙的检查点�件��,**以�** `isReplacementSurfaceEvent(event)`。一� append 的�件�� `user/message` 是注入上下文——跨会�引用�片——�是压缩。 + +从 `packages/client/*` 程�无法到达的是 `dsh-compact` 的**根部**,而�是这个包。根部会到达 `dsh-session` 的根部,�者的 cordis `Context` �并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲�——`TS2717`,� [development.md](../../../../docs/development.md#typescript-project-layout) 中�侧一个 program 的规则;这一点对仅类型导入�样�立,因为该冲�是编译器事实而�打包器事实。 + +本仓库对这一情形的既有答案是�� cordis 的���路径,本次�更就新增了一个:`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource` 现在�在 `packages/compact/compact/src/checkpoint.ts`,它�导入 cordis�也�增强任何模�(� `dsh-commands/brand` / `dsh-llm/message` 的形状),而包根�新导出两者,因此�个宿主侧消费方——终端的 chat helper�`dsh-session-reference` 的投影——都�需改动。适�器用仅类型导入把它的字��钉在该声明上: + +```ts +import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' +const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' +``` + +�命��隙的�件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须��**仅类型**——任何既�平�模��� inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门�(`packages/client/tsdown.client.ts`)拒�,而它自己的报错信�就记录�仅类型导入会被擦除�永�抵达该门�。仅类型的��导入�时需� `tsconfig.base.json` 的一� `paths` �目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`:composite 的 `rootDir` 规则�样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。 + +`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的�一��留,用由��**值**构造的检查点驱动适�器。该测试以值导入方�从�� cordis 的 `@deepseek-ai/dsh-compact/checkpoint` ��路径�得该值,并刻��加载 compact 包根或�由它�达的宿主侧 `Context` �并。 + +因此与终端的分歧很窄:两个�端都从�一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里�适用任何门�),客户端钉�类型。 + +## #835 的�置锚点是为什么而存在,以�为什么它是被溶解而�丢失 + +尚未�并的排队�手动压缩分支用�一�方�修�一个交错缺陷:为�个事件记录一个锚点——追加时的 surface 尾部——并把被�蔽的锚点�定�到检查点上。该机制的存在是为了让�置锚点在 surface **�排**中存活。人类对�记录永�被�排,因此锚点没有任何东西需��定�:��被移除,修�并未被丢弃。该机制在本基线上并�存在,本次也�撰写它。 + +## Alternatives considered + +**从新��值导入该谓�**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白��。已拒�:客户端需�的是�件 id,�是谓�——一个类型就够了,而被擦除的导入根本�会抵达纯度门�,因此无需�它放行任何东西。白���在值导入时�有�义,而在那里它是笔糟糕的交�:`INLINE_SAFE` 按标识符*�缀*匹�,因此放行该包会连它那个会导入 cordis 的根部一起放行。 + +**一�纯形状规则**——任何 replacement `user/message` 都是压缩。已拒�:它今天正确�因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改�便无任何机制能�获。那个 pin 测试�花一个文件,就精确消除了这一风险。 + +**在宿主侧给检查点打标**,�投影或线�议。已拒�:这最贴�“� cordis �务�作�的规则,但客户端今天折�的是原始 `SessionEvent`,因此这�味�一次线�议契约�更——为一个纯谓�付出的代价��比例。 + +**把冻结节点的归属移进适�器**(`nodes(extraNodes)`),�那个未�并分支所�的那样。已拒�:被打断的节点�自 `Session` 已�在窗�上�行的 `turn/end` 清扫,而在按 seq �调的记录之上,简�形�就是正确的——适�器返回节点,会�按 seq 归并冻结节点。加宽适�器签�什么也��到,还会把清扫与它的产物拆开。 + +**把 `foldDegraded` 留作一个防御性标志。** 已拒�:它�述的是一个已���行的 fold 的特定失败。一个消费方无法�以行动��能通过 `console.error` 到达的标志,是一份虚�契约。 + +## Consequences + +压缩��抹掉 Web 历�;一个被压缩多次的会�按日志顺�显示�次�地压缩一个标记,而�一窗�在实时与冷��之�渲染完全相�。分页缺�是被构造性闭�而�被防御,`ConversationSnapshot` 少了一个已�布字段,这触��三个文件。 + +`ConversationNode` 增加第八个分支,因此�个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无�元格�分支,使标记�贡献�元格但�推进耗时游标。 + +性能契约未�,且现在更易表述:一次追加物化一个节点,�改�任何节点的事件��上一次的数组引用——因此分片风暴零�本�`nodes()` 甚至�会�算——未�化的节点��其对象标识。窗���会�长度而�� surface 增长,这正是本修�存在所��的交�;一次压缩过去�好为压缩所�务的长会��制了投影规模。 + +Web e2e 场景现在在它录制的那一轮之上播�一次真实的压缩事务,因此 aria 基准�真实宿主与真实�览器钉�修�的两�:录制的�问与完整工具输出�在�幕上,其���一个标记。录制本身未被触碰���模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。 + +## Deferred + +压缩**进度**——压缩�行期间的指示——需�排队�手动压缩工作引入的“先开括��顺�,与终端一样�在本次范围内。标记�样��带**规模**信�:检查点的 `sourceEventSeqs` 已�包�被�蔽的数�,因此一个计数或区间�以告诉读者�一行折�了多少内容。两者应当放在一起,读者正是在那里�到�一份信�的两�。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.i18n.yaml new file mode 100644 index 0000000000..c95b2a60b7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.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-31-composer-glyph-layer-tracks-the-textarea.md +2026-07-31-composer-glyph-layer-tracks-the-textarea.md: d60a100be98683b5f7a7c88edf7585d275134730 +2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md: eab3f9e3fe3bddb426836113d08f1839329119d5 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md new file mode 100644 index 0000000000..d60a100be9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-glyph-layer-tracks-the-textarea.md @@ -0,0 +1,77 @@ +# Agent Note: The composer's glyph layer tracks the textarea's scroll offset + +Status: implemented + +English | [中文](2026-07-31-composer-glyph-layer-tracks-the-textarea.zh.md) + +## Problem + +A composer draft longer than the 14-line cap could not be scrolled. The caret moved and the selection moved, but the words stayed frozen at line 1 — no wheel gesture, drag, or arrow key brought the end of a long draft on screen, so the bottom of anything past ~14 lines was unreachable and unreadable while writing it. + +The cap itself was working. The composer paints its text in two stacked layers ([InputBar](../../../../packages/client/ui-conversation/src/client/skeleton/InputBar.tsx)): the `