refactor(web): project usage and snapshot request context
This commit is contained in:
@@ -1,39 +0,0 @@
|
||||
# Agent Note: Host-owned Web session metrics
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-host-owned-web-session-metrics.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, while the selected model does not prove that a request used its route or capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage.
|
||||
|
||||
Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request attempt observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults. After the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop publishes one contained `agent/model-request` notification. This boundary observes an attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while short-circuit handles and later lazy adapter construction, iteration failure, or abort still count.
|
||||
|
||||
The tail `session.history` response carries durable usage and pressure, while older pages omit them. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions and preserves metrics across older-page prepend.
|
||||
|
||||
ApiProxy forwards each notification as a distinct `session/model-request` frame only to mux connections already open when dispatch occurs. It never places the frame in `session.history` or a subscription baseline. The client keeps durable `metrics` and transient `modelRequestContextWindow` as separate snapshot fields, replaces or explicitly clears the capacity on the next observed request, and clears both fields on `session/subscribed`; reconnect, restore, and a new subscription therefore start unknown until another request is observed.
|
||||
|
||||
The Web stats line joins the durable projection and live capacity only at presentation. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage only when the current connection observed a capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fold the loaded node window in React.** This cannot survive pagination or compaction and duplicates durable-log semantics in a presentation package.
|
||||
|
||||
**Send usage only with raw assistant events.** Reconnect and older-page stitching would still need the client to reconstruct a full-log aggregate, and duplicate usage forms would need protocol-specific repair there.
|
||||
|
||||
**Reuse one total-token field for cache hit.** Cache reads, cache writes, and uncached input represent distinct provider accounting buckets; combining them would make the displayed rate misleading.
|
||||
|
||||
**Query the selected route before dispatch.** Selection may never produce a request, and a second metadata lookup can race the registration-bound lookup that actually validates and dispatches the call.
|
||||
|
||||
**Persist or replay the latest request capacity.** That would make a former request look current on reconnect or restore even though the new connection observed no request. The denominator is deliberately live and opportunistic.
|
||||
|
||||
## Consequences
|
||||
|
||||
Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached durable projection plus one connection-local denominator instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting.
|
||||
|
||||
The Host performs one incremental log fold per session and schedules durable projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. A new connection omits the percentage until it observes a request with context metadata. A later request without metadata clears the denominator, while deployments without a token meter still retain the durable counters and label context unavailable instead of fabricating pressure.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Agent Note: Host 拥有的 Web 会话指标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-host-owned-web-session-metrics.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 统计行若根据当前加载的会话节点推导指标,其结果会随分页窗口变化。压缩(compaction)可以替换可见内容,却无法保留历史用量;所选模型也不能证明某次请求实际采用了该模型的路由或容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。
|
||||
|
||||
## 决策
|
||||
|
||||
Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。
|
||||
|
||||
当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求尝试。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发布一条 `agent/model-request` 通知,并收容该通知的失败。这个边界观察到的是一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄以及之后的惰性适配器构造、迭代失败或中止仍会计入。
|
||||
|
||||
`session.history` 尾页响应携带持久用量与压力,较早页面则省略这两项。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,并在向前加载较早页面时保留指标。
|
||||
|
||||
ApiProxy 只把每条通知作为独立的 `session/model-request` 帧转发给分派发生时已经打开的 mux 连接。它绝不会把该帧放入 `session.history` 或订阅基线。客户端把持久 `metrics` 与临时 `modelRequestContextWindow` 保存在彼此独立的快照字段中,在观察到下一次请求时替换或显式清除容量,并在收到 `session/subscribed` 时清除这两个字段;因此,重连、恢复和新订阅都会从未知容量开始,直到观察到另一次请求。
|
||||
|
||||
Web 统计行只在展示时结合持久投影与实时容量。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有当前连接观察到容量时,才把当前上下文显示为该容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**在 React 中归并已加载的节点窗口。** 此方案无法跨越分页或压缩保留数据,还会在展示包中重复实现持久日志语义。
|
||||
|
||||
**只随原始 assistant 事件发送用量。** 重连和较早页面拼接仍会要求客户端重建完整日志聚合,而且重复的用量形态需要在客户端按协议专门修复。
|
||||
|
||||
**为缓存命中率复用单一的 token 总数字段。** 缓存读取、缓存写入与未缓存输入是提供方记账中的不同计数项;将它们合并会使显示的比率产生误导。
|
||||
|
||||
**在分派前查询所选路由。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际校验并分派调用的、绑定注册项的查询发生竞态。
|
||||
|
||||
**持久化或回放最新请求的容量。** 即使新连接没有观察到任何请求,这也会让先前请求在重连或恢复后显得仍然有效。该分母刻意只采用实时且恰好可得的数据。
|
||||
|
||||
## 后果
|
||||
|
||||
token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型、脱耦的持久投影与一个连接本地分母,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。
|
||||
|
||||
Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度持久投影更新;文本与推理(reasoning)增量不会发布指标。新连接在观察到带上下文元数据的请求之前不会显示百分比。后续不带元数据的请求会清除该分母;未部署 token 计量器时,系统仍保留持久计数器,并把上下文标示为不可用,而不会虚构压力值。
|
||||
@@ -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 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md
|
||||
2026-07-28-host-owned-web-session-metrics.md: f0a0bdb8ea4c1ba1f983d016c3cfde9c67a0424d
|
||||
2026-07-28-host-owned-web-session-metrics.zh.md: 4535fd8a4b760aa031c11ab766b6be583b9b740d
|
||||
# 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: 3ac8c29f7752828f2d833359293c7b1c513d75ca
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 02b03d1516a39b7727b214b63a9039623b69c56b
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Projected token usage and request context
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-projected-token-usage-and-request-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage. Conversely, context occupancy describes one real request boundary: a selected model is only an intention, and combining token pressure from one moment with capacity resolved for another route creates a false percentage.
|
||||
|
||||
These two values therefore have different lifetimes. Provider-reported billing is durable, replayable session state. Request pressure and registration-bound capacity are an opportunistic live observation that must disappear across a connection generation.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-token-meter` registers the generic `tokenUsage` session projection when `ctx.sessionProjections` is present. The projection 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 replaces the earlier value for the same `(turn, step)` instead of being counted twice. Reasoning tokens remain an output subdivision and are not added again. Compaction and surface replacement do not erase earlier billing.
|
||||
|
||||
The projection uses the standard projection lifecycle and wire path. History tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload all remain generic. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
|
||||
|
||||
`LlmService.prepareCall()` retains context metadata from the exact lookup that also validates reasoning and captures the adapter registration. After the outer stream call returns its handle and before iteration begins, AgentLoop emits one contained `agent/model-request` notification. Preparation or a synchronous outer waterfall failure emits nothing; short-circuit handles and later iterator construction, iteration, or abort failures still count as an observed request attempt.
|
||||
|
||||
ApiProxy handles that notification synchronously. It reads `tokenMeter.measure(agent.session).totalTokens` once when the optional service is present and combines the result with the same prepared call's registration-bound `contextWindow`. It broadcasts one atomic `session/model-request` frame containing the route, turn, step, and whichever of `contextTokens` and `contextWindow` are available. Measurement failure omits only the numerator. The frame goes only to mux connections already open at that instant; history, subscription baselines, reconnect, and restore never replay it.
|
||||
|
||||
The client stores the complete latest request frame as `ConversationSnapshot.modelRequest`. Every later frame replaces the entire snapshot, so omitted fields clear earlier values. `SessionManager` temporarily holds a pre-instantiation frame, while a new subscription generation, disconnect, or session removal clears both resident and pending values. Model selection alone does not change this snapshot.
|
||||
|
||||
The Web `StatsLine` reads `tokenUsage` through the standard `useProjection` hook and reads request telemetry plus visible nodes through `useSession`. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows context occupancy only when one request snapshot contains both numerator and capacity. Visible nodes continue to supply only turn and step counts. The existing inline text UI is retained; the model selector gains no circle or other accessory.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A custom session metrics history field and mux frame.** This duplicated the generic projection protocol, cache, recovery, and seq fencing while coupling durable billing to transient request pressure.
|
||||
|
||||
**Fold the loaded node window in React.** This 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 provider billing.
|
||||
|
||||
**Query capacity from the selected model.** Selection may never produce a request, and a second metadata lookup can disagree with the registration-bound lookup used by the actual call.
|
||||
|
||||
**Persist or replay the latest request snapshot.** A request from a prior connection would appear current after restore even though no new request was observed.
|
||||
|
||||
**Add a context circle beside the model selector.** That placement suggests selected-model state. The existing stats line expresses the request-scoped semantics without introducing a duplicate UI or data path.
|
||||
|
||||
## Consequences
|
||||
|
||||
Token totals stay stable across pagination, compaction, replay, and reconnect because they are ordinary durable projection state. Context occupancy is deliberately unknown after reconnect until a new real request is observed. Deployments without token-meter or without model capacity still publish the request route and clear stale optional fields instead of fabricating a percentage.
|
||||
|
||||
ApiProxy performs one synchronous optional measurement and one frame conversion per observed request. It owns no per-session metrics cache or refresh queue. The browser keeps one generic projection value plus one small connection-local request snapshot, and streaming text deltas do not force the stats line to recompute.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note:token 用量投影与请求上下文
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 统计行若根据当前已加载的会话节点推导,其结果会在分页时依赖当前窗口。压缩(compaction)可以替换可见内容,却不保留历史用量。另一方面,上下文占用率描述的是一个真实请求边界:所选模型只代表意图;若把某一时刻的 token 压力与另一路由解析出的容量组合,就会产生虚假百分比。
|
||||
|
||||
因此,这两个值具有不同的生命周期。提供方报告的计费用量属于持久、可回放的会话状态。请求压力和与注册项绑定的容量则是恰好可得的实时观测,跨连接代次时必须消失。
|
||||
|
||||
## 决策
|
||||
|
||||
当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册通用的 `tokenUsage` 会话投影。该投影将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前值,不会重复计数。推理(reasoning)token 仍是输出的细分项,不会再次累加。压缩和表层替换不会抹除先前的计费用量。
|
||||
|
||||
该投影使用标准的投影生命周期与协议路径。历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载均保持通用机制。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端 seq 防护机制。
|
||||
|
||||
`LlmService.prepareCall()` 会保留精确查询得到的上下文元数据;同一次查询还会校验推理强度并捕获适配器注册项。外层流调用返回句柄后、开始迭代前,AgentLoop 会发出一条失败受收容的 `agent/model-request` 通知。准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知;短路句柄以及之后的迭代器构造失败、迭代失败或中止仍算作一次已观测的请求尝试。
|
||||
|
||||
ApiProxy 会同步处理该通知。当可选服务存在时,它会读取一次 `tokenMeter.measure(agent.session).totalTokens`,并将结果与同一准备完成调用中绑定注册项的 `contextWindow` 合并。它会广播一个原子 `session/model-request` 帧,其中包含路由、轮次、步骤,以及可用的 `contextTokens`/`contextWindow` 字段。测量失败时只省略分子。该帧只发送给当时已经打开的 mux 连接;历史记录、订阅基线、重连和恢复都绝不回放该帧。
|
||||
|
||||
客户端将最新的完整请求帧存储为 `ConversationSnapshot.modelRequest`。每个后续帧都会替换整个快照,因此省略字段会清除先前值。`SessionManager` 会临时保存一个实例化前帧;新订阅代次、断开连接或移除会话时,则会同时清除常驻值和待处理值。仅选择模型不会改变该快照。
|
||||
|
||||
Web `StatsLine` 通过标准 `useProjection` 钩子读取 `tokenUsage`,并通过 `useSession` 读取请求观测数据与可见节点。它分别显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有同一份请求快照同时包含分子与容量时才显示上下文占用率。可见节点仍只提供轮次和步骤计数。系统保留现有的行内文本 UI;模型选择器不增加圆环或其他附属控件。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**自定义会话指标历史字段和 mux 帧。** 这会重复实现通用投影协议、缓存、恢复和 seq 防护机制,并将持久计费用量与临时请求压力耦合。
|
||||
|
||||
**在 React 中归并已加载的节点窗口。** 此方案无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。
|
||||
|
||||
**仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失提供方计费用量。
|
||||
|
||||
**从所选模型查询容量。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际调用所用的、绑定注册项的查询不一致。
|
||||
|
||||
**持久化或回放最新请求快照。** 即使没有观察到任何新请求,先前连接的请求也会在恢复后显得仍是当前请求。
|
||||
|
||||
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。现有统计行可以表达按请求作用域的语义,无需引入重复的 UI 或数据路径。
|
||||
|
||||
## 后果
|
||||
|
||||
token 总量在分页、压缩、回放和重连期间保持稳定,因为它们属于普通的持久投影状态。重连后,上下文占用率会刻意保持未知,直到系统观察到新的真实请求。未部署 token-meter 或模型不提供容量时,系统仍会发布请求路由,并清除陈旧的可选字段,而不会虚构百分比。
|
||||
|
||||
ApiProxy 会为每次已观测请求执行一次可选的同步测量和一次帧转换。它不拥有任何逐会话指标缓存或刷新队列。浏览器只保留一个通用投影值和一个小型连接本地请求快照;流式文本增量不会迫使统计行重新计算。
|
||||
@@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
const inner = child.getBoundingClientRect()
|
||||
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
|
||||
})))
|
||||
const list = rows[0]?.parentElement ?? null
|
||||
const list = card.querySelector<HTMLElement>('[data-question-scroll]')
|
||||
return {
|
||||
rows: rows.length,
|
||||
spill: Math.max(...spill),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
@@ -28,7 +29,7 @@
|
||||
- img
|
||||
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
|
||||
- text: 8.3k uncached input · 252 output · 9k cache read · cache hit 52% · context 7% of 128k · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
@@ -42,7 +43,7 @@
|
||||
- img
|
||||
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
||||
- paragraph: CORDIS_UI_DONE
|
||||
- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps
|
||||
- text: 15.3k uncached input · 312 output · 51.2k cache read · cache hit 77% · context 13% of 128k · 1 turns · 4 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
|
||||
- text: 109 uncached input · 21 output · 7.7k cache read · cache hit 99% · context unknown · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- paragraph: partial
|
||||
- text: 已停止 0 tokens · 1 turns · 1 steps
|
||||
- text: 已停止 0 uncached input · 0 output · 0 cache read · context 4% of 128k · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- text: 0 uncached input · 0 output · 0 cache read · context 4% of 128k · 0 turns · 0 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps
|
||||
- text: 110 uncached input · 79 output · 7.7k cache read · cache hit 99% · context 4% of 128k · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
@@ -25,7 +26,7 @@
|
||||
- img
|
||||
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps
|
||||
- text: 397 uncached input · 180 output · 8.2k cache read · cache hit 95% · context 4% of 128k · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the read tool twice" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- button:
|
||||
- img
|
||||
- text: Read a.txt
|
||||
- button:
|
||||
- img
|
||||
- text: Read b.txt
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
|
||||
- paragraph: DONE
|
||||
- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
|
||||
- text: 339 uncached input · 135 output · 15.5k cache read · cache hit 98% · context unknown · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
@@ -19,9 +20,7 @@
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
|
||||
- button "▸ 问题内容"
|
||||
- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
|
||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 151 uncached input · 115 output · 7.7k cache read · cache hit 98% · context 4% of 128k · 1 turns · 1 steps"
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
@@ -25,7 +26,7 @@
|
||||
- img
|
||||
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
|
||||
- paragraph: Great, let's move forward. BANANA!
|
||||
- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps
|
||||
- text: 323 uncached input · 156 output · 15.5k cache read · cache hit 98% · context 6% of 128k · 1 turns · 2 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -1548,7 +1548,7 @@ Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/ti
|
||||
export type TokenMeterConfig = Record<string, never>
|
||||
```
|
||||
|
||||
Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts)
|
||||
Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter/src/types.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash`
|
||||
|
||||
|
||||
@@ -2031,7 +2031,7 @@ estimateMessage(message: Message): number
|
||||
|
||||
Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md)
|
||||
|
||||
Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts)
|
||||
Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts)
|
||||
|
||||
## `ctx.toolResultPrune` — `ToolResultPruneService`
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:228`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:228`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
|
||||
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
|
||||
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
|
||||
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
GoalsApi, GoalRef,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
AssistantMessage,
|
||||
ContentBlock,
|
||||
MessageSource,
|
||||
TokenUsage,
|
||||
ToolResultMessage,
|
||||
UserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -103,6 +104,16 @@ function sid(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
/** Deterministic provider billing attached to fixture assistant messages. */
|
||||
function fixtureUsage(turn: number, step: number): TokenUsage {
|
||||
return {
|
||||
inputTokens: 20 + turn % 5,
|
||||
outputTokens: 8 + step,
|
||||
cacheReadTokens: turn === 0 ? 0 : 80,
|
||||
cacheWriteTokens: turn % 10 === 0 ? 4 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
|
||||
* mixing reasoning blocks / tool call+result / steering / context. */
|
||||
function buildAlphaLog(): SessionEvent[] {
|
||||
@@ -110,7 +121,17 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
let time = Date.now() - 3_600_000
|
||||
const push = (e: Record<string, unknown>): number => {
|
||||
const seq = events.length
|
||||
events.push({ seq, time: (time += 800), ...e })
|
||||
const data = e['data'] as Record<string, unknown> | undefined
|
||||
const authored = e['type'] === 'assistant/message' && data !== undefined
|
||||
? {
|
||||
...e,
|
||||
data: {
|
||||
...data,
|
||||
usage: fixtureUsage(data['turn'] as number, data['step'] as number),
|
||||
},
|
||||
}
|
||||
: e
|
||||
events.push({ seq, time: (time += 800), ...authored })
|
||||
return seq
|
||||
}
|
||||
for (let turn = 0; turn < 60; turn++) {
|
||||
@@ -369,6 +390,60 @@ function permissionSelectOf(
|
||||
}
|
||||
}
|
||||
|
||||
interface FixtureTokenUsageProjection {
|
||||
uncachedInputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
}
|
||||
|
||||
/** Fixture parallel of token-meter's last-sample-replacing usage projection. */
|
||||
function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection {
|
||||
const totals: FixtureTokenUsageProjection = {
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
let last: {
|
||||
turn: number
|
||||
step: number
|
||||
buckets: FixtureTokenUsageProjection
|
||||
} | null = null
|
||||
for (const event of log) {
|
||||
const item = event as unknown as {
|
||||
type: string
|
||||
data: {
|
||||
turn?: number
|
||||
step?: number
|
||||
usage?: TokenUsage
|
||||
chunk?: { type?: string; usage?: TokenUsage }
|
||||
}
|
||||
}
|
||||
const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
|
||||
? item.data.chunk.usage
|
||||
: item.type === 'assistant/message'
|
||||
? item.data.usage
|
||||
: undefined
|
||||
if (usage === undefined || item.data.turn === undefined || item.data.step === undefined) continue
|
||||
const buckets: FixtureTokenUsageProjection = {
|
||||
uncachedInputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
||||
}
|
||||
const previous = last?.turn === item.data.turn && last.step === item.data.step
|
||||
? last.buckets
|
||||
: undefined
|
||||
totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0)
|
||||
totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0)
|
||||
totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0)
|
||||
totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0)
|
||||
last = { turn: item.data.turn, step: item.data.step, buckets }
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {}
|
||||
const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title')
|
||||
@@ -383,12 +458,28 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
|
||||
values['plan'] = planViewOf(log)
|
||||
// Always present (GoalService unit composed): null before create / after clear.
|
||||
values['goal'] = backscanGoal(log)
|
||||
// Always present (token-meter composed): full-log provider billing.
|
||||
values['tokenUsage'] = tokenUsageOf(log)
|
||||
return values
|
||||
}
|
||||
|
||||
/** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
|
||||
function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
|
||||
const type = (event as { type: string }).type
|
||||
if (
|
||||
(type === 'assistant/chunk'
|
||||
&& (event as unknown as { data: { chunk?: { type?: string } } }).data.chunk?.type === 'usage')
|
||||
|| (type === 'assistant/message'
|
||||
&& (event as unknown as { data: { usage?: TokenUsage } }).data.usage !== undefined)
|
||||
) {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
key: 'tokenUsage',
|
||||
value: tokenUsageOf(log),
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
if (type === 'session/title') {
|
||||
const values = projectionValuesOf(log)
|
||||
/* v8 ignore next -- the advancing title event is in the log, so the key is present. */
|
||||
@@ -853,7 +944,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.delete(id)
|
||||
const done = pieces.slice(0, i).join('')
|
||||
append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
|
||||
append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } })
|
||||
append(id, {
|
||||
type: 'assistant/message',
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: assistantMessage(text(aborted ? `${done}(已中断)` : done)),
|
||||
usage: fixtureUsage(turn, step),
|
||||
},
|
||||
})
|
||||
append(id, { type: 'step/end', data: { turn, step } })
|
||||
append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
|
||||
setRunning(id, false)
|
||||
@@ -1036,6 +1136,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
|
||||
}
|
||||
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
const usage = tokenUsageOf(logOf(id))
|
||||
emitMux({
|
||||
type: 'session/model-request',
|
||||
sessionId: id,
|
||||
turn,
|
||||
step: 0,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
contextTokens: usage.uncachedInputTokens + usage.outputTokens
|
||||
+ usage.cacheReadTokens + usage.cacheWriteTokens,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
startReply(
|
||||
id,
|
||||
turn,
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
|
||||
ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -84,6 +84,12 @@ describe('createFixtureApi', () => {
|
||||
},
|
||||
plan: { active: false, pending: false },
|
||||
goal: null,
|
||||
tokenUsage: {
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
} },
|
||||
})
|
||||
})
|
||||
@@ -188,6 +194,20 @@ describe('createFixtureApi', () => {
|
||||
expect(types).toContain('assistant/chunk')
|
||||
expect(types).toContain('assistant/message')
|
||||
expect(types.at(-1)).toBe('turn/end')
|
||||
expect(frames).toContainEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: id,
|
||||
turn: 0,
|
||||
step: 0,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
contextTokens: 0,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(frames.some(frame =>
|
||||
frame.type === 'session/projection'
|
||||
&& frame.key === 'tokenUsage'
|
||||
&& (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true)
|
||||
const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
|
||||
expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
|
||||
// Idle cancel: no replay in flight, must not explode; running flips false.
|
||||
@@ -219,7 +239,7 @@ describe('createFixtureApi', () => {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
for await (const envelope of api.events.mux(req({}), abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelopes.length >= 8) abort.abort()
|
||||
if (envelopes.length >= 9) abort.abort()
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
@@ -227,16 +247,18 @@ describe('createFixtureApi', () => {
|
||||
const second = await openOnce()
|
||||
expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
|
||||
expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
|
||||
// Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units).
|
||||
// Projection baseline frames follow subscribed (domain units + token usage).
|
||||
expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' })
|
||||
expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' })
|
||||
expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' })
|
||||
expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } })
|
||||
expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[7]?.rpcId).toBe(first[7]?.rpcId)
|
||||
expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
|
||||
expect(first[7]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
|
||||
expect(second[7]?.rpcId).toBe(first[7]?.rpcId) // stable rpcId across replays (host replay semantics)
|
||||
expect(first[8]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
|
||||
expect(second[8]?.rpcId).toBe(first[8]?.rpcId)
|
||||
expect(first.some(envelope => envelope.payload.type === 'session/model-request')).toBe(false)
|
||||
})
|
||||
|
||||
it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
|
||||
|
||||
@@ -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: 879730733f3d89c3962d8c54bcfd53795a980049
|
||||
README.zh.md: 1b1d7f03d1f5f03c054dfeaa790a9f6f91e0dca2
|
||||
README.md: ba9a7d455e8a193f23884411eb1928a10f21ddd0
|
||||
README.zh.md: 873fefca48585efed010589917f2c63653b08e5b
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos` and `title`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. Durable `ConversationSnapshot.metrics` instead comes from the separate history-tail value and live `session/metrics` frames because point-in-time token-meter pressure can advance at the same durable log revision; only nondecreasing log and projection revisions are accepted. `ConversationSnapshot.modelRequestContextWindow` separately retains capacity from the latest `session/model-request` observed on the current mux connection. A later request replaces or clears that value, while `session/subscribed` clears both metrics ordering and capacity; reconnect, restore, and a new subscription therefore show no percentage until another request is observed. Missing metrics remain `null` rather than being inferred from the visible node window.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos` 与 `title`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。持久的 `ConversationSnapshot.metrics` 则来自独立的 history 尾页值与实时 `session/metrics` 帧,因为即时 token-meter 压力可以在相同持久日志修订号上继续变化;客户端只接受日志修订号与投影修订号均不减小的数据。`ConversationSnapshot.modelRequestContextWindow` 另行保留当前 mux 连接观察到的最新 `session/model-request` 容量。后续请求会替换或清除该值,`session/subscribed` 则同时清除指标顺序状态与容量;因此,重连、恢复和新订阅都不会显示百分比,直到观察到另一次请求。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`、`title` 与 `tokenUsage`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。`ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type {
|
||||
RpcError, SessionId, SessionMetrics, ToolCallView, ToolResultView,
|
||||
ModelRequestTelemetry, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
@@ -267,16 +267,6 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/**
|
||||
* Host-owned cumulative usage/current pressure. Independent of `nodes`
|
||||
* pagination; null until a tail response or live metrics frame supplies a
|
||||
* current durable value.
|
||||
*/
|
||||
metrics: SessionMetrics | null
|
||||
/**
|
||||
* Capacity from the latest model-request attempt observed on this mux
|
||||
* generation. Absent before the first such request, after a request whose
|
||||
* registration exposes no capacity, and after `session/subscribed`.
|
||||
*/
|
||||
modelRequestContextWindow?: number
|
||||
/** Latest atomic model-request snapshot on this mux generation. */
|
||||
modelRequest: ModelRequestTelemetry | null
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
|
||||
// List data never enters zustand; React connects via subscribe/getListSnapshot.
|
||||
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostFrame, IApiClient, ModelRequestTelemetry, MuxFrame, RpcError, RpcRequest,
|
||||
RpcResult, SessionId, SessionSummary, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -58,11 +61,11 @@ export class SessionManager {
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
/**
|
||||
* Latest model capacity observed for an uninstantiated session on the
|
||||
* Latest request telemetry observed for an uninstantiated session on the
|
||||
* current mux generation. Unlike durable history, this transient frame
|
||||
* cannot be backfilled when get() lazily creates the Session.
|
||||
*/
|
||||
private readonly modelRequestContextWindows = new Map<SessionId, number>()
|
||||
private readonly modelRequests = new Map<SessionId, ModelRequestTelemetry>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
@@ -171,7 +174,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const modelRequestContextWindow = this.modelRequestContextWindows.get(sessionId)
|
||||
const modelRequest = this.modelRequests.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
@@ -179,7 +182,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
...(modelRequestContextWindow === undefined ? {} : { modelRequestContextWindow }),
|
||||
...(modelRequest === undefined ? {} : { modelRequest }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -355,13 +358,13 @@ export class SessionManager {
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/model-request') {
|
||||
// Transient and non-replayable: retain the latest capacity until lazy
|
||||
// instantiation. An absent value explicitly clears an earlier one.
|
||||
if (frame.contextWindow === undefined) this.modelRequestContextWindows.delete(frame.sessionId)
|
||||
else this.modelRequestContextWindows.set(frame.sessionId, frame.contextWindow)
|
||||
// Transient and non-replayable: retain the whole latest request until
|
||||
// lazy instantiation. Missing fields replace rather than inherit.
|
||||
const { type: _type, sessionId, ...modelRequest } = frame
|
||||
this.modelRequests.set(sessionId, modelRequest)
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.modelRequestContextWindows.delete(frame.sessionId)
|
||||
this.modelRequests.delete(frame.sessionId)
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
@@ -440,7 +443,7 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.modelRequestContextWindows.delete(frame.sessionId) // connection-local request capacity dies with the Host session
|
||||
this.modelRequests.delete(frame.sessionId) // connection-local request telemetry dies with the Host session
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
@@ -481,7 +484,7 @@ export class SessionManager {
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
}
|
||||
this.modelRequestContextWindows.clear()
|
||||
this.modelRequests.clear()
|
||||
for (const session of this.sessions.values()) session.handleReconnecting()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, SessionMetrics, ToolEventView,
|
||||
ModelRequestTelemetry, SessionId, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -43,8 +43,8 @@ export interface SessionOptions {
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
/** Model capacity already observed on this mux generation before lazy construction. */
|
||||
modelRequestContextWindow?: number
|
||||
/** Request telemetry already observed on this mux generation before lazy construction. */
|
||||
modelRequest?: ModelRequestTelemetry
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
@@ -110,10 +110,8 @@ export class Session implements SessionFace {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Host-owned durable usage/current-pressure projection. */
|
||||
private metrics: SessionMetrics | null = null
|
||||
/** Latest capacity observed on this mux connection, independent of durable metrics arrival. */
|
||||
private contextWindow: number | undefined
|
||||
/** Latest atomic request snapshot observed on this mux connection. */
|
||||
private modelRequest: ModelRequestTelemetry | 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<string, readonly CodeSubCall[]>()
|
||||
@@ -175,7 +173,7 @@ export class Session implements SessionFace {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.contextWindow = options.modelRequestContextWindow
|
||||
this.modelRequest = options.modelRequest ?? null
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -331,10 +329,10 @@ export class Session implements SessionFace {
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
// Queue, metrics, and request capacity are NOT cleared here: onConnected
|
||||
// Queue and request telemetry are NOT cleared here: onConnected
|
||||
// (which drives resync) races the mux frames — fresh-generation state may
|
||||
// have landed already, and the host never resends it. session/subscribed
|
||||
// owns the generation reset before the queue snapshot and metrics frames.
|
||||
// have landed already, and the host never resends request telemetry.
|
||||
// session/subscribed owns the reset before the queue snapshot.
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.openPromise = null
|
||||
@@ -415,24 +413,22 @@ export class Session implements SessionFace {
|
||||
this.queueRev++
|
||||
changed = true
|
||||
}
|
||||
if (this.contextWindow !== undefined) {
|
||||
this.contextWindow = undefined
|
||||
changed = true
|
||||
}
|
||||
if (this.metrics !== null) {
|
||||
this.metrics = null
|
||||
if (this.modelRequest !== null) {
|
||||
this.modelRequest = null
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
case 'session/metrics': {
|
||||
this.installMetrics(frame.metrics)
|
||||
return
|
||||
}
|
||||
case 'session/model-request': {
|
||||
if (this.contextWindow === frame.contextWindow) return
|
||||
this.contextWindow = frame.contextWindow
|
||||
const {
|
||||
type: _type,
|
||||
sessionId: _sessionId,
|
||||
...modelRequest
|
||||
} = frame
|
||||
// Whole-frame replacement is load-bearing: an omitted numerator or
|
||||
// capacity clears that field from the preceding request.
|
||||
this.modelRequest = modelRequest
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
@@ -508,17 +504,16 @@ export class Session implements SessionFace {
|
||||
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
|
||||
handleReconnecting(): void {
|
||||
this.openGeneration++
|
||||
if (this.metrics === null && this.contextWindow === undefined) return
|
||||
this.metrics = null
|
||||
this.contextWindow = undefined
|
||||
if (this.modelRequest === null) return
|
||||
this.modelRequest = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */
|
||||
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */
|
||||
handleRemoved(): void {
|
||||
const changed = !this.removed || this.contextWindow !== undefined
|
||||
const changed = !this.removed || this.modelRequest !== null
|
||||
this.removed = true
|
||||
this.contextWindow = undefined
|
||||
this.modelRequest = null
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -567,7 +562,6 @@ export class Session implements SessionFace {
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
result.value.metrics,
|
||||
)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
@@ -579,7 +573,6 @@ export class Session implements SessionFace {
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
result.value.metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -606,7 +599,6 @@ export class Session implements SessionFace {
|
||||
entries: HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
projections: ProjectionsBaseline | undefined,
|
||||
metrics: SessionMetrics | undefined,
|
||||
): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
@@ -615,7 +607,6 @@ export class Session implements SessionFace {
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
if (metrics !== undefined) this.installMetrics(metrics)
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -668,7 +659,6 @@ export class Session implements SessionFace {
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
result.value.metrics,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -854,20 +844,6 @@ export class Session implements SessionFace {
|
||||
return tail === undefined ? null : tail.seq
|
||||
}
|
||||
|
||||
/** Install a metrics snapshot unless a newer durable or publication revision already landed. */
|
||||
private installMetrics(metrics: SessionMetrics): void {
|
||||
const current = this.metrics
|
||||
if (
|
||||
current !== null
|
||||
&& (
|
||||
metrics.logRevision < current.logRevision
|
||||
|| metrics.projectionRevision < current.projectionRevision
|
||||
)
|
||||
) return
|
||||
this.metrics = metrics
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private buildSnapshot(): ConversationSnapshot {
|
||||
const { nodes: folded, degraded } = this.foldAdapter.nodes()
|
||||
// Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
|
||||
@@ -920,10 +896,7 @@ export class Session implements SessionFace {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
metrics: this.metrics,
|
||||
...(this.contextWindow === undefined
|
||||
? {}
|
||||
: { modelRequestContextWindow: this.contextWindow }),
|
||||
modelRequest: this.modelRequest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('runtime client apply', () => {
|
||||
expect(bench.api.callsOf('session.create')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears connection-local Session state on disconnect but not connected', async () => {
|
||||
it('clears connection-local request telemetry on reconnect but not connected', async () => {
|
||||
const bench = await mount()
|
||||
const sessions = bench.ctx.get('sessions') as SessionsService
|
||||
bench.sinks?.onHostEnvelope?.({
|
||||
@@ -112,21 +112,8 @@ describe('runtime client apply', () => {
|
||||
await Promise.resolve()
|
||||
const session = sessions.binding('s-state' as never)?.session
|
||||
if (session === undefined) throw new Error('session binding missing')
|
||||
const currentMetrics = {
|
||||
projectionRevision: 4,
|
||||
logRevision: 10,
|
||||
uncachedInputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 90,
|
||||
cacheWriteTokens: 3,
|
||||
contextTokens: 35,
|
||||
}
|
||||
bench.sinks?.onMuxEnvelope?.({
|
||||
rpcId: 'metrics' as never,
|
||||
payload: { type: 'session/metrics', sessionId: 's-state', metrics: currentMetrics } as never,
|
||||
})
|
||||
bench.sinks?.onMuxEnvelope?.({
|
||||
rpcId: 'capacity' as never,
|
||||
rpcId: 'request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: 's-state',
|
||||
@@ -134,19 +121,20 @@ describe('runtime client apply', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
} as never,
|
||||
})
|
||||
|
||||
bench.sinks?.onConnected?.()
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
metrics: currentMetrics,
|
||||
modelRequestContextWindow: 128_000,
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
bench.sinks?.onDisconnected?.()
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
bench.sinks?.onStateChange?.('reconnecting')
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('stops the stream loop when the plugin fiber unloads', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionMetrics, SessionModels,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels,
|
||||
SessionProjectionsBlock, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -69,7 +69,6 @@ export class FakeApiClient implements IApiClient {
|
||||
events: never[]
|
||||
hasMore: boolean
|
||||
projections?: SessionProjectionsBlock
|
||||
metrics?: SessionMetrics
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
import { entries, plainTurn } from './event-script.ts'
|
||||
@@ -41,7 +41,7 @@ describe('instances', () => {
|
||||
expect(manager.get(S2).getSnapshot().pending).toEqual([])
|
||||
})
|
||||
|
||||
it('retains the latest transient model capacity until lazy instantiation', () => {
|
||||
it('retains the latest transient request snapshot until lazy instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
@@ -53,6 +53,7 @@ describe('instances', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 12_000,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
@@ -65,14 +66,22 @@ describe('instances', () => {
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBe(256_000)
|
||||
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('retains explicit capacity clearing before lazy instantiation', () => {
|
||||
it('retains whole-frame replacement before lazy instantiation', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
@@ -99,10 +108,15 @@ describe('instances', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(manager.get(S1).getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'unknown-capacity',
|
||||
})
|
||||
})
|
||||
|
||||
it('clears retained capacity on subscribed and resident capacity on removal', () => {
|
||||
it('clears retained request telemetry on subscribed and removal', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
manager.handleMuxEnvelope({
|
||||
@@ -122,7 +136,7 @@ describe('instances', () => {
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 },
|
||||
})
|
||||
const session = manager.get(S1)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-after-subscribe' as never,
|
||||
@@ -136,12 +150,12 @@ describe('instances', () => {
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
|
||||
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(256_000)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S1 },
|
||||
})
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'request-before-lazy-removal' as never,
|
||||
@@ -159,28 +173,15 @@ describe('instances', () => {
|
||||
rpcId: 'lazy-removed' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('clears resident metrics and capacity plus lazy capacity before reconnect', () => {
|
||||
it('clears resident and lazy request telemetry on disconnect', () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
const session = manager.get(S1)
|
||||
const currentMetrics: SessionMetrics = {
|
||||
projectionRevision: 4,
|
||||
logRevision: 10,
|
||||
uncachedInputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 90,
|
||||
cacheWriteTokens: 3,
|
||||
contextTokens: 35,
|
||||
}
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'metrics' as never,
|
||||
payload: { type: 'session/metrics', sessionId: S1, metrics: currentMetrics },
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'resident-capacity' as never,
|
||||
rpcId: 'resident-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S1,
|
||||
@@ -188,11 +189,12 @@ describe('instances', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'resident',
|
||||
contextTokens: 35,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
})
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'lazy-capacity' as never,
|
||||
rpcId: 'lazy-request' as never,
|
||||
payload: {
|
||||
type: 'session/model-request',
|
||||
sessionId: S2,
|
||||
@@ -200,19 +202,20 @@ describe('instances', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'lazy',
|
||||
contextTokens: 70,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
metrics: currentMetrics,
|
||||
modelRequestContextWindow: 128_000,
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
model: 'resident',
|
||||
contextTokens: 35,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
manager.handleReconnecting()
|
||||
manager.handleDisconnected()
|
||||
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SessionId, SessionMetrics, SessionProjectionsBlock,
|
||||
SessionId, SessionProjectionsBlock,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
|
||||
@@ -29,34 +29,15 @@ function histResponse(
|
||||
events: SessionEvent[],
|
||||
hasMore = false,
|
||||
projections?: SessionProjectionsBlock,
|
||||
metrics?: SessionMetrics,
|
||||
) {
|
||||
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
|
||||
return Promise.resolve(ok({
|
||||
events: entries(events) as never[],
|
||||
hasMore,
|
||||
...projections === undefined ? {} : { projections },
|
||||
...metrics === undefined ? {} : { metrics },
|
||||
}))
|
||||
}
|
||||
|
||||
function metrics(
|
||||
projectionRevision: number,
|
||||
logRevision: number,
|
||||
over: Partial<SessionMetrics> = {},
|
||||
): SessionMetrics {
|
||||
return {
|
||||
projectionRevision,
|
||||
logRevision,
|
||||
uncachedInputTokens: 10,
|
||||
outputTokens: 4,
|
||||
cacheReadTokens: 90,
|
||||
cacheWriteTokens: 3,
|
||||
contextTokens: 35,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('open', () => {
|
||||
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
@@ -70,19 +51,7 @@ describe('open', () => {
|
||||
expect(snapshot.openState).toBe('open')
|
||||
expect(snapshot.hasMore).toBe(true)
|
||||
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
|
||||
expect(snapshot.metrics).toBeNull()
|
||||
})
|
||||
|
||||
it('installs full-log metrics independently of older history pages', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const tailMetrics = metrics(4, 106)
|
||||
api.onHistory = () => histResponse(plainTurn(100, 3, '问', '答'), true, undefined, tailMetrics)
|
||||
await session.open()
|
||||
expect(session.getSnapshot().metrics).toBe(tailMetrics)
|
||||
|
||||
api.onHistory = () => histResponse(plainTurn(94, 2, '旧问', '旧答'))
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot().metrics).toBe(tailMetrics)
|
||||
expect(snapshot.modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
|
||||
@@ -147,17 +116,8 @@ describe('live event path', () => {
|
||||
expect(session.getSnapshot().nodes).toEqual(before.nodes)
|
||||
})
|
||||
|
||||
it('keeps live capacity separate from durable metrics, replaces or clears it on requests, and resets at subscription', async () => {
|
||||
it('replaces the whole request snapshot, clears omitted fields, and resets at subscription', async () => {
|
||||
const { session } = await opened()
|
||||
const current = metrics(8, 10)
|
||||
session.handleMuxEnvelope('m1' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: current,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
|
||||
session.handleMuxEnvelope('request-1' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
@@ -165,32 +125,17 @@ describe('live event path', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('m2' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: metrics(9, 9, { uncachedInputTokens: 1 }),
|
||||
expect(session.getSnapshot().modelRequest).toEqual({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 32_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
session.handleMuxEnvelope('m3' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: metrics(7, 11, { uncachedInputTokens: 2 }),
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(current)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 })
|
||||
session.handleMuxEnvelope('m4' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: ordinaryUpdate,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(ordinaryUpdate)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('request-2' as never, {
|
||||
type: 'session/model-request',
|
||||
@@ -200,16 +145,19 @@ describe('live event path', () => {
|
||||
provider: 'test',
|
||||
model: 'without-capacity',
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toEqual({
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'without-capacity',
|
||||
})
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
session.handleMuxEnvelope('request-3' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
@@ -217,19 +165,17 @@ describe('live event path', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'beta',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
const nextGeneration = metrics(0, 10, { contextTokens: 20 })
|
||||
session.handleMuxEnvelope('m5' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: nextGeneration,
|
||||
expect(session.getSnapshot().modelRequest).toMatchObject({
|
||||
turn: 3,
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBe(nextGeneration)
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000)
|
||||
})
|
||||
|
||||
it('publishes a subscribed reset when capacity arrived before durable metrics', async () => {
|
||||
it('publishes a subscribed reset when request telemetry arrived first', async () => {
|
||||
const { session } = await opened()
|
||||
session.handleMuxEnvelope('request' as never, {
|
||||
type: 'session/model-request',
|
||||
@@ -238,17 +184,17 @@ describe('live event path', () => {
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 8_000,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000)
|
||||
expect(session.getSnapshot().modelRequest?.contextWindow).toBe(128_000)
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
|
||||
@@ -876,72 +822,61 @@ describe('remaining branches', () => {
|
||||
})
|
||||
|
||||
describe('resync', () => {
|
||||
it('fences pre-disconnect history behind a fresh mux metrics baseline', async () => {
|
||||
it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => stale.promise
|
||||
const opening = session.open()
|
||||
const oldLiveMetrics = metrics(8, 10)
|
||||
session.handleMuxEnvelope('old-metrics' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: oldLiveMetrics,
|
||||
})
|
||||
session.handleMuxEnvelope('old-capacity' as never, {
|
||||
session.handleMuxEnvelope('old-request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'old',
|
||||
contextTokens: 20,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
session.handleReconnecting()
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined()
|
||||
const freshMetrics = metrics(0, 1, { contextTokens: 20 })
|
||||
session.handleMuxEnvelope('fresh-metrics' as never, {
|
||||
type: 'session/metrics',
|
||||
sessionId: SID,
|
||||
metrics: freshMetrics,
|
||||
})
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[],
|
||||
hasMore: false,
|
||||
metrics: metrics(99, 99, { contextTokens: 999 }),
|
||||
}))
|
||||
await opening
|
||||
expect(session.getSnapshot().nodes).toEqual([])
|
||||
expect(session.getSnapshot().metrics).toBe(freshMetrics)
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9])
|
||||
expect(session.getSnapshot().metrics).toBe(freshMetrics)
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => {
|
||||
it('preserves a fresh-generation request snapshot when history resync fails', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const oldMetrics = metrics(8, 10)
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'), false, undefined, oldMetrics)
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().metrics).toBe(oldMetrics)
|
||||
|
||||
session.handleMuxEnvelope('sub' as never, {
|
||||
type: 'session/subscribed',
|
||||
sessionId: SID,
|
||||
lastSeq: 5,
|
||||
})
|
||||
expect(session.getSnapshot().metrics).toBeNull()
|
||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
||||
|
||||
const freshMetrics = metrics(0, 10, { contextTokens: 20 })
|
||||
session.handleMuxEnvelope('fresh-metrics' as never, {
|
||||
type: 'session/metrics',
|
||||
session.handleMuxEnvelope('fresh-request' as never, {
|
||||
type: 'session/model-request',
|
||||
sessionId: SID,
|
||||
metrics: freshMetrics,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'fresh',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
})
|
||||
api.onHistory = () => Promise.resolve(err({
|
||||
code: 'internal',
|
||||
@@ -953,7 +888,14 @@ describe('resync', () => {
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
openState: 'error',
|
||||
metrics: freshMetrics,
|
||||
modelRequest: {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'fresh',
|
||||
contextTokens: 20,
|
||||
contextWindow: 256_000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
promptError: null,
|
||||
blank: false,
|
||||
lastAgentError: null,
|
||||
modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: b74127ffe11df40fcad0c97e2fc6896ec79ad9d1
|
||||
README.zh.md: f6dfc3ca61ea80758c9f64ca98f034b0832ee89e
|
||||
README.md: c60a38ec26d4bca15ce23e51dbaedf3ef36022e4
|
||||
README.zh.md: 65c5554a78960fc4f86a6a370772c95429c648c4
|
||||
|
||||
@@ -20,7 +20,7 @@ Per-session UI state for selection and the active view lives in the declared cha
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
|
||||
|
||||
The chat stats line reads durable token counters/current pressure from `ConversationSnapshot.metrics` and joins them only at presentation with the separate connection-local `modelRequestContextWindow`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history.
|
||||
The chat stats line reads full-log billing from the generic `tokenUsage` projection and joins it only at presentation with the connection-local atomic `ConversationSnapshot.modelRequest`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only when the same observed request snapshot contains both `contextTokens` and `contextWindow`. Before that request, after reconnect/restore/new subscription, or after a request missing either field, context is labeled unknown rather than queried from the selected model or reconstructed from history. The existing inline stats row remains the sole context UI; the model selector has no circle or accessory.
|
||||
|
||||
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
|
||||
|
||||
聊天统计行从 `ConversationSnapshot.metrics` 读取持久的 token 计数/当前压力,并且只在展示时把它们与独立的连接本地 `modelRequestContextWindow` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。
|
||||
聊天统计行从通用 `tokenUsage` 投影读取完整日志计费用量,并且只在展示时把它与连接本地的原子快照 `ConversationSnapshot.modelRequest` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有同一份已观测请求快照同时包含 `contextTokens` 与 `contextWindow` 时才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求缺少任一字段之后,系统都会把上下文标为「未知」,而不会从所选模型查询或根据历史记录重建。现有的行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。
|
||||
|
||||
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
|
||||
@@ -221,7 +221,9 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({
|
||||
useProjection, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
@@ -381,7 +383,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
<StatsLine useSession={useSession} useProjection={useProjection} />
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, UseProjection,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelRequestTelemetry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
type SessionMetrics = NonNullable<ConversationSnapshot['metrics']>
|
||||
|
||||
interface VisibleCounts {
|
||||
turns: number
|
||||
steps: number
|
||||
@@ -44,59 +46,59 @@ export function formatMetricTokens(value: number): string {
|
||||
|
||||
/**
|
||||
* Existing Web cache-hit formula over disjoint uncached and cache-read input.
|
||||
* @param metrics - Host-owned durable usage.
|
||||
* @param usage - full-log token usage projection.
|
||||
* @returns rounded integer percent, or null when no input was billed.
|
||||
*/
|
||||
export function cacheHitPercent(metrics: SessionMetrics): number | null {
|
||||
const denominator = metrics.uncachedInputTokens + metrics.cacheReadTokens
|
||||
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
|
||||
const denominator = usage.uncachedInputTokens + usage.cacheReadTokens
|
||||
return denominator === 0
|
||||
? null
|
||||
: Math.round(metrics.cacheReadTokens / denominator * 100)
|
||||
: Math.round(usage.cacheReadTokens / denominator * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current context occupancy using the TUI's integer rounding and upper clamp.
|
||||
* @param metrics - Host-owned durable pressure.
|
||||
* @param contextWindow - capacity from the latest request observed on this mux generation.
|
||||
* @param request - one atomic request snapshot observed on this mux generation.
|
||||
* @returns occupancy percent, or null when either input is unavailable.
|
||||
*/
|
||||
export function contextPercent(metrics: SessionMetrics, contextWindow: number | undefined): number | null {
|
||||
if (metrics.contextTokens === undefined || contextWindow === undefined) return null
|
||||
return Math.min(100, Math.round(metrics.contextTokens / contextWindow * 100))
|
||||
export function contextPercent(request: ModelRequestTelemetry | null): number | null {
|
||||
if (request?.contextTokens === undefined || request.contextWindow === undefined) return null
|
||||
return Math.min(100, Math.round(request.contextTokens / request.contextWindow * 100))
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
/** Props: standard session hooks handed down by ChatView. */
|
||||
export interface StatsLineProps {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
useProjection: UseProjection
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const metrics = useSession(s => s.metrics)
|
||||
const contextWindow = useSession(s => s.modelRequestContextWindow)
|
||||
const modelRequest = useSession(s => s.modelRequest)
|
||||
const usage = useProjection('tokenUsage')
|
||||
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
|
||||
if (counts.steps === 0 && (
|
||||
metrics === null
|
||||
|| (
|
||||
metrics.uncachedInputTokens === 0
|
||||
&& metrics.outputTokens === 0
|
||||
&& metrics.cacheReadTokens === 0
|
||||
&& (metrics.contextTokens ?? 0) === 0
|
||||
)
|
||||
)) return null
|
||||
const hasUsage = usage !== undefined && (
|
||||
usage.uncachedInputTokens !== 0
|
||||
|| usage.outputTokens !== 0
|
||||
|| usage.cacheReadTokens !== 0
|
||||
|| usage.cacheWriteTokens !== 0
|
||||
)
|
||||
const context = contextPercent(modelRequest)
|
||||
if (counts.steps === 0 && !hasUsage && context === null) return null
|
||||
|
||||
const parts: string[] = []
|
||||
if (metrics === null) {
|
||||
if (usage === undefined) {
|
||||
parts.push('usage unknown')
|
||||
parts.push('context unknown')
|
||||
} else {
|
||||
parts.push(`${formatMetricTokens(metrics.uncachedInputTokens)} uncached input`)
|
||||
parts.push(`${formatMetricTokens(metrics.outputTokens)} output`)
|
||||
parts.push(`${formatMetricTokens(metrics.cacheReadTokens)} cache read`)
|
||||
const cacheHit = cacheHitPercent(metrics)
|
||||
parts.push(`${formatMetricTokens(usage.uncachedInputTokens)} uncached input`)
|
||||
parts.push(`${formatMetricTokens(usage.outputTokens)} output`)
|
||||
parts.push(`${formatMetricTokens(usage.cacheReadTokens)} cache read`)
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`)
|
||||
const context = contextPercent(metrics, contextWindow)
|
||||
parts.push(context === null
|
||||
? 'context unknown'
|
||||
: `context ${context}% of ${formatMetricTokens(contextWindow as number)}`)
|
||||
}
|
||||
parts.push(context === null || modelRequest?.contextWindow === undefined
|
||||
? 'context unknown'
|
||||
: `context ${context}% of ${formatMetricTokens(modelRequest.contextWindow)}`)
|
||||
parts.push(`${counts.turns} turns`)
|
||||
parts.push(`${counts.steps} steps`)
|
||||
return <div className={css.root}>{parts.join(' · ')}</div>
|
||||
|
||||
@@ -120,18 +120,20 @@ describe('small branch tails', () => {
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
metrics: {
|
||||
logRevision: 2,
|
||||
projectionRevision: 0,
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 5_000,
|
||||
},
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 5_000,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps',
|
||||
|
||||
@@ -58,7 +58,7 @@ function snapshotWith(
|
||||
sessionId: SID, nodes, foldDegraded: false, 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, metrics: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
// StatsLine (rendered inside the chat view body): durable metrics presentation + the RFC
|
||||
// StatsLine (rendered inside the chat view body): projection presentation + the RFC
|
||||
// hard acceptance — zero renders during streaming. Bash sample row: the
|
||||
// canonical sub-agent differential decided INSIDE the component off the
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
@@ -7,8 +7,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode, UseProjection,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -31,7 +32,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +54,27 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeProjection(initial?: TokenUsageProjection) {
|
||||
let value = initial
|
||||
const subs = new Set<() => void>()
|
||||
const useValue = bindSnapshotSelector({
|
||||
getSnapshot: () => value,
|
||||
subscribe: (fn: () => void) => {
|
||||
subs.add(fn)
|
||||
return () => subs.delete(fn)
|
||||
},
|
||||
})
|
||||
return {
|
||||
set(next: TokenUsageProjection | undefined) {
|
||||
value = next
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
useProjection: ((_key: 'tokenUsage', selector?: (usage: TokenUsageProjection | undefined) => unknown,
|
||||
eq?: (left: unknown, right: unknown) => boolean) =>
|
||||
useValue(selector ?? (usage => usage), eq)) as UseProjection,
|
||||
}
|
||||
}
|
||||
|
||||
describe('stats derivation', () => {
|
||||
it('counts visible turns and steps without reading node usage', () => {
|
||||
const stats = deriveVisibleCounts([
|
||||
@@ -74,20 +96,23 @@ describe('stats derivation', () => {
|
||||
})
|
||||
|
||||
it('keeps the cache formula disjoint from cache writes and rounds/clamps context like the TUI', () => {
|
||||
const durable = {
|
||||
logRevision: 20,
|
||||
projectionRevision: 2,
|
||||
const usage = {
|
||||
uncachedInputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 900,
|
||||
cacheWriteTokens: 50_000,
|
||||
contextTokens: 34_500,
|
||||
}
|
||||
expect(cacheHitPercent(durable)).toBe(90)
|
||||
expect(contextPercent(durable, 100_000)).toBe(35)
|
||||
expect(contextPercent({ ...durable, contextTokens: 200_000 }, 100_000)).toBe(100)
|
||||
expect(contextPercent(durable, undefined)).toBeNull()
|
||||
expect(cacheHitPercent({ ...durable, uncachedInputTokens: 0, cacheReadTokens: 0 })).toBeNull()
|
||||
const request = {
|
||||
turn: 1, step: 1, provider: 'p', model: 'm',
|
||||
contextTokens: 34_500, contextWindow: 100_000,
|
||||
}
|
||||
expect(cacheHitPercent(usage)).toBe(90)
|
||||
expect(contextPercent(request)).toBe(35)
|
||||
expect(contextPercent({ ...request, contextTokens: 200_000 })).toBe(100)
|
||||
expect(contextPercent({
|
||||
turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: 34_500,
|
||||
})).toBeNull()
|
||||
expect(cacheHitPercent({ ...usage, uncachedInputTokens: 0, cacheReadTokens: 0 })).toBeNull()
|
||||
})
|
||||
|
||||
it('formats large values compactly in the existing en-US style', () => {
|
||||
@@ -98,25 +123,28 @@ describe('stats derivation', () => {
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source) }
|
||||
function props(
|
||||
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
|
||||
projection = makeProjection(),
|
||||
): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source), useProjection: projection.useProjection }
|
||||
}
|
||||
|
||||
it('renders separate durable counters, cache hit, context occupancy, and visible counts', () => {
|
||||
const { source } = makeSource({
|
||||
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
|
||||
metrics: {
|
||||
logRevision: 30,
|
||||
projectionRevision: 4,
|
||||
uncachedInputTokens: 120_237,
|
||||
outputTokens: 13_881,
|
||||
cacheReadTokens: 2_172_544,
|
||||
cacheWriteTokens: 99_999,
|
||||
contextTokens: 89_600,
|
||||
modelRequest: {
|
||||
turn: 1, step: 1, provider: 'p', model: 'm',
|
||||
contextTokens: 89_600, contextWindow: 256_000,
|
||||
},
|
||||
modelRequestContextWindow: 256_000,
|
||||
})
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
const projection = makeProjection({
|
||||
uncachedInputTokens: 120_237,
|
||||
outputTokens: 13_881,
|
||||
cacheReadTokens: 2_172_544,
|
||||
cacheWriteTokens: 99_999,
|
||||
})
|
||||
const view = render(<StatsLine {...props(source, projection)} />)
|
||||
expect(view.getByText(
|
||||
'120.2k uncached input · 13.9k output · 2.2m cache read · cache hit 95% · context 35% of 256k · 1 turns · 1 steps',
|
||||
)).toBeTruthy()
|
||||
@@ -125,68 +153,80 @@ describe('StatsLine', () => {
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders durable counters without a percentage before live capacity is observed', () => {
|
||||
it('renders durable counters without a percentage before a complete request snapshot is observed', () => {
|
||||
const { source } = makeSource({
|
||||
nodes: [assistant(1, 1)],
|
||||
metrics: {
|
||||
logRevision: 4,
|
||||
projectionRevision: 1,
|
||||
uncachedInputTokens: 120,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 30,
|
||||
cacheWriteTokens: 10,
|
||||
modelRequest: {
|
||||
turn: 1, step: 1, provider: 'p', model: 'm',
|
||||
contextTokens: 8_000,
|
||||
},
|
||||
})
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
const projection = makeProjection({
|
||||
uncachedInputTokens: 120,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 30,
|
||||
cacheWriteTokens: 10,
|
||||
})
|
||||
const view = render(<StatsLine {...props(source, projection)} />)
|
||||
expect(view.getByText(
|
||||
'120 uncached input · 20 output · 30 cache read · cache hit 20% · context unknown · 1 turns · 1 steps',
|
||||
)).toBeTruthy()
|
||||
expect(view.container.textContent).not.toContain('% of')
|
||||
})
|
||||
|
||||
it('renders honest unknowns when the host projection is missing', () => {
|
||||
it('renders honest unknowns when the tokenUsage key and request snapshot are missing', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.getByText('usage unknown · context unknown · 1 turns · 1 steps')).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ uncachedInputTokens: 1, outputTokens: 0, cacheReadTokens: 0, contextTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 1, cacheReadTokens: 0, contextTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 1, contextTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, contextTokens: 1 },
|
||||
])('keeps a metrics-only row visible for each nonzero projection bucket', (nonzero) => {
|
||||
const { source } = makeSource({
|
||||
metrics: {
|
||||
logRevision: 1,
|
||||
projectionRevision: 0,
|
||||
cacheWriteTokens: 0,
|
||||
...nonzero,
|
||||
{ uncachedInputTokens: 1, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 1, cacheWriteTokens: 0 },
|
||||
{ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 1 },
|
||||
])('keeps a usage-only row visible for each nonzero projection bucket', (usage) => {
|
||||
const { source } = makeSource()
|
||||
const view = render(<StatsLine {...props(source, makeProjection(usage))} />)
|
||||
expect(view.container.textContent).toContain('0 turns · 0 steps')
|
||||
})
|
||||
|
||||
it('keeps a context-only row visible and hides an all-zero empty session', () => {
|
||||
const context = makeSource({
|
||||
modelRequest: {
|
||||
turn: 1, step: 1, provider: 'p', model: 'm',
|
||||
contextTokens: 1, contextWindow: 10,
|
||||
},
|
||||
})
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.container.textContent).toContain('0 turns · 0 steps')
|
||||
expect(render(
|
||||
<StatsLine {...props(context.source, makeProjection({
|
||||
uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
}))} />,
|
||||
).container.textContent).toContain('context 10% of 10')
|
||||
const empty = makeSource()
|
||||
expect(render(
|
||||
<StatsLine {...props(empty.source, makeProjection({
|
||||
uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
}))} />,
|
||||
).container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
const { set, source } = makeSource({
|
||||
nodes: [assistant(1, 1)],
|
||||
metrics: {
|
||||
logRevision: 4,
|
||||
projectionRevision: 0,
|
||||
uncachedInputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
})
|
||||
const projection = makeProjection({
|
||||
uncachedInputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
})
|
||||
let renders = 0
|
||||
function Counting(p: StatsLineProps) {
|
||||
renders += 1
|
||||
return <StatsLine {...p} />
|
||||
}
|
||||
render(<Counting {...props(source)} />)
|
||||
render(<Counting {...props(source, projection)} />)
|
||||
const before = renders
|
||||
// Chunk frames swap partial only; nodes keeps its reference (object-layer contract).
|
||||
act(() => { set({ partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'a' }] } }) })
|
||||
@@ -194,6 +234,28 @@ describe('StatsLine', () => {
|
||||
act(() => { set({ running: true }) })
|
||||
expect(renders).toBe(before)
|
||||
})
|
||||
|
||||
it('updates for a new usage projection or atomic request snapshot', () => {
|
||||
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const projection = makeProjection()
|
||||
const view = render(<StatsLine {...props(source, projection)} />)
|
||||
expect(view.container.textContent).toContain('usage unknown · context unknown')
|
||||
act(() => {
|
||||
projection.set({
|
||||
uncachedInputTokens: 7, outputTokens: 2, cacheReadTokens: 1, cacheWriteTokens: 0,
|
||||
})
|
||||
})
|
||||
expect(view.container.textContent).toContain('7 uncached input · 2 output · 1 cache read')
|
||||
act(() => {
|
||||
set({
|
||||
modelRequest: {
|
||||
turn: 1, step: 2, provider: 'p', model: 'm',
|
||||
contextTokens: 75, contextWindow: 100,
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(view.container.textContent).toContain('context 75% of 100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bash sample row', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine takes durable counters from metrics while keeping visible node counts', () => {
|
||||
it('StatsLine takes durable counters from tokenUsage while keeping visible node counts', () => {
|
||||
const snap = {
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
@@ -44,18 +44,20 @@ describe('render branch tails', () => {
|
||||
// outputTokens absent: the tokens sum's ?? 0 arm for output.
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
],
|
||||
metrics: {
|
||||
logRevision: 9,
|
||||
projectionRevision: 1,
|
||||
uncachedInputTokens: 9,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
modelRequest: null,
|
||||
}
|
||||
const usage = {
|
||||
uncachedInputTokens: 9,
|
||||
outputTokens: 6,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useProjection={(() => usage)}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText(
|
||||
'9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps',
|
||||
|
||||
@@ -23,7 +23,7 @@ function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): Conversation
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
})
|
||||
const props: InputBarProps = {
|
||||
sessionId: SID,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
sessionId, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
})
|
||||
const barProps: InputBarProps = {
|
||||
sessionId,
|
||||
|
||||
@@ -20,7 +20,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
|
||||
sessionId: SID, nodes: [], foldDegraded: false, 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, metrics: null,
|
||||
promptError: null, blank: false, lastAgentError: null, modelRequest: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
|
||||
@@ -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/core/agent-loop/README.md
|
||||
README.md: d25779a7104bc0896e0063db236515ce543b6b5a
|
||||
README.zh.md: ba9332b64c062d71b721007ac66acb484b02cddd
|
||||
README.md: ffdce143c0023bab22efc49fb1272377bf0bfdcf
|
||||
README.zh.md: 9dd982785590d1de6864ede8f071d3aa884a2827
|
||||
|
||||
@@ -63,7 +63,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
|
||||
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional registration-bound capacity. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional capacity copied from that same prepared call. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及可选的、与注册项绑定的容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及从同一次准备完成的调用中复制的可选容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
|
||||
|
||||
@@ -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/core/agent/README.md
|
||||
README.md: 19e04af3517f34131cd7986720f6a49660bd0b33
|
||||
README.zh.md: c6a4af4eb631e6eb80f33a168e37db314cfd7158
|
||||
README.md: afad797f1f21240610c8fe37aa6d2f55cf06fd26
|
||||
README.zh.md: 4e8d366a1e72071805073088b136a49a4a3703d9
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional registration-bound context capacity for an attempt whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional context capacity copied from the registration-bound prepared call whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 通知的失败会被收容,外层流调用为一次尝试返回句柄后,该通知会报告其路由以及可选的、与注册项绑定的上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;由绑定注册项的准备完成调用发起的外层流返回句柄后,失败受收容的 `agent/model-request` 通知会报告其路由,以及从该调用复制的可选上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。
|
||||
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 5d5f1e4cff97756e650cca02abef221dd9bef8f5
|
||||
README.zh.md: 3b70aec65189b4a9e71170c4194ea5850fc4cffb
|
||||
README.md: 872d653a194bc08ec7a4a2125137af51d0105a52
|
||||
README.zh.md: 5470b7680b1fea42cc3383ebcc121f05015f5226
|
||||
|
||||
@@ -22,9 +22,9 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
|
||||
|
||||
`session.history` pages on message boundaries. Its tail page (no `beforeSeq`) carries the generic `projections` baseline, including the `todos` whole-list value when that unit is mounted, plus separate `metrics`: full-log usage deduplicated by `(turn, step)` and current token-meter pressure. Older pages omit both session-level carriers. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
|
||||
`session.history` pages on message boundaries. Its tail page (no `beforeSeq`) carries only the generic `projections` baseline for registered units; older pages omit it. When token-meter is composed with the projection registry, full-log provider billing rides the ordinary `tokenUsage` key. Its usage chunks and final messages are deduplicated by `(turn, step)`, while cache reads and writes remain disjoint buckets. ApiProxy owns no token-specific history field, mux frame, projector, revision counter, or refresh queue.
|
||||
|
||||
Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. The frame carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value.
|
||||
Request context uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. In the same synchronous event boundary, ApiProxy optionally reads `tokenMeter.measure(session).totalTokens` once and combines it with capacity from that exact prepared call. The atomic frame carries turn, step, final provider/model, and optional `contextTokens`/`contextWindow` only to mux connections already open at that instant. Measurement failure omits only the numerator. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay an earlier snapshot, and missing fields in a later frame replace rather than inherit prior values.
|
||||
|
||||
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
|
||||
|
||||
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)携带通用 `projections` 基线(挂载对应单元时包含 `todos` 整表值),以及独立的 `metrics`:按 `(turn, step)` 去重的完整日志用量与当前 token 计量压力。较早的页面省略这两种会话级载体。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
|
||||
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)只携带已注册单元的通用 `projections` 基线;较早页面则省略该基线。当 token-meter 与投影注册表组合时,完整日志中的提供方计费用量会通过普通 `tokenUsage` 键承载。系统按 `(turn, step)` 对其用量分片和最终消息去重,缓存读取与写入则仍是相互独立的计数项。ApiProxy 不拥有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或刷新队列。
|
||||
|
||||
上下文容量使用独立的临时 `session/model-request` mux 帧。外层流调用为一次观察到的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。
|
||||
请求上下文使用独立的临时 `session/model-request` mux 帧。外层流调用为一次已观测的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。在同一同步事件边界内,ApiProxy 会可选地读取一次 `tokenMeter.measure(session).totalTokens`,并将结果与该次准备完成调用的容量合并。这个原子帧携带轮次、步骤、最终提供方/模型与可选的 `contextTokens`/`contextWindow`,且只发送给当时已经打开的 mux 连接。测量失败时只省略分子。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放更早的快照;后续帧中缺失的字段会清除对应的先前值,而不是继承它。
|
||||
|
||||
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import type {
|
||||
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { affectsSessionMetrics, SessionMetricsProjector } from './session-metrics.ts'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import { openNativePath } from './native-path-opener.ts'
|
||||
|
||||
@@ -496,56 +495,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
for (const queue of muxQueues) queue.push(envelope)
|
||||
}
|
||||
|
||||
const pendingMetricSessions = new Set<Session>()
|
||||
let metricFlushScheduled = false
|
||||
const metricsProjector = new SessionMetricsProjector(ctx)
|
||||
|
||||
/** Queue one full-log metrics publication after synchronous session listeners drain. */
|
||||
function scheduleMetrics(session: Session): void {
|
||||
if (muxQueues.size === 0) return
|
||||
pendingMetricSessions.add(session)
|
||||
if (metricFlushScheduled) return
|
||||
metricFlushScheduled = true
|
||||
queueMicrotask(() => {
|
||||
metricFlushScheduled = false
|
||||
const sessions = [...pendingMetricSessions]
|
||||
pendingMetricSessions.clear()
|
||||
for (const current of sessions) {
|
||||
broadcast({
|
||||
type: 'session/metrics',
|
||||
sessionId: current.id,
|
||||
metrics: metricsProjector.snapshot(current),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (affectsSessionMetrics(event)) scheduleMetrics(session)
|
||||
}),
|
||||
ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
|
||||
ctx.on('agent/model-request', (agent, turn, step, request) => {
|
||||
broadcast({
|
||||
type: 'session/model-request',
|
||||
sessionId: agent.session.id,
|
||||
turn,
|
||||
step,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...request.contextWindow === undefined
|
||||
? {}
|
||||
: { contextWindow: request.contextWindow },
|
||||
})
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
|
||||
]
|
||||
return () => {
|
||||
pendingMetricSessions.clear()
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}, 'api-proxy: session metrics')
|
||||
return ctx.on('agent/model-request', (agent, turn, step, request) => {
|
||||
const tokenMeter = ctx.get('tokenMeter') as {
|
||||
measure(session: Session): { totalTokens: number }
|
||||
} | undefined
|
||||
let contextTokens: number | undefined
|
||||
if (tokenMeter !== undefined) {
|
||||
try {
|
||||
contextTokens = tokenMeter.measure(agent.session).totalTokens
|
||||
} catch {
|
||||
// A malformed or temporarily unmeasurable replay omits only the
|
||||
// numerator; this request still replaces stale telemetry.
|
||||
}
|
||||
}
|
||||
broadcast({
|
||||
type: 'session/model-request',
|
||||
sessionId: agent.session.id,
|
||||
turn,
|
||||
step,
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...contextTokens === undefined ? {} : { contextTokens },
|
||||
...request.contextWindow === undefined
|
||||
? {}
|
||||
: { contextWindow: request.contextWindow },
|
||||
})
|
||||
})
|
||||
}, 'api-proxy: model request telemetry')
|
||||
|
||||
// Projection change feed → session/projection push frames. The carrier
|
||||
// mints the wire frame (the seam package holds no wire vocabulary); the
|
||||
@@ -991,16 +968,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
|
||||
// the one path that never needs fresh projection or metrics state.
|
||||
// the one path that never needs fresh projection state.
|
||||
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
|
||||
const metrics = beforeSeq === undefined
|
||||
? metricsProjector.snapshot(found.agent.session)
|
||||
: undefined
|
||||
return ok(request, {
|
||||
events: entries,
|
||||
hasMore: page.hasMore,
|
||||
...projections === undefined ? {} : { projections },
|
||||
...metrics === undefined ? {} : { metrics },
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1501,11 +1474,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
muxQueues.add(queue)
|
||||
for (const session of ctx.sessions.list()) {
|
||||
subscribeSession(queue, session)
|
||||
queue.push(frame({
|
||||
type: 'session/metrics',
|
||||
sessionId: session.id,
|
||||
metrics: metricsProjector.snapshot(session),
|
||||
}))
|
||||
}
|
||||
for (const pending of pendingQuestions.values()) {
|
||||
queue.push({
|
||||
@@ -1556,11 +1524,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}),
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
subscribeSession(queue, session)
|
||||
queue.push(frame({
|
||||
type: 'session/metrics',
|
||||
sessionId: session.id,
|
||||
metrics: metricsProjector.snapshot(session),
|
||||
}))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
openCalls.delete(session.id)
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
|
||||
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
|
||||
import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, sessionEventSchema, sessionIdSchema, sessionMetricsSchema, toolEventViewSchema,
|
||||
contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
@@ -37,7 +37,6 @@ const messageSchema = z.object({
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||
z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }),
|
||||
z.object({
|
||||
type: z.literal('session/model-request'),
|
||||
sessionId: sessionIdSchema,
|
||||
@@ -45,6 +44,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
step: z.number().int().positive(),
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
contextTokens: z.number().int().nonnegative().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { SessionMetrics } from './sessions.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
@@ -32,6 +31,18 @@ export type ToolEventView =
|
||||
| { for: 'call'; view: ToolCallView }
|
||||
| { for: 'result'; view: ToolResultView }
|
||||
|
||||
/** Atomic telemetry captured at one observed model-request boundary. */
|
||||
export interface ModelRequestTelemetry {
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
model: string
|
||||
/** Token-meter pressure measured synchronously for this exact request. */
|
||||
contextTokens?: number
|
||||
/** Registration-bound capacity from this exact prepared call. */
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
|
||||
export interface EventsApi {
|
||||
/**
|
||||
@@ -58,23 +69,18 @@ export interface EventsApi {
|
||||
export type MuxFrame =
|
||||
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
|
||||
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
|
||||
| { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics }
|
||||
/**
|
||||
* One request attempt observed by this already-open mux connection after its
|
||||
* final route and outer `llm/stream` handle were obtained. This does not prove
|
||||
* provider I/O began. The frame is transient: mux baselines, reconnects, and
|
||||
* session history never replay it. An absent `contextWindow` explicitly clears
|
||||
* a capacity observed from an earlier request on the same connection.
|
||||
* session history never replay it. The optional numerator and capacity are
|
||||
* one atomic request snapshot; absent fields explicitly replace, rather
|
||||
* than inherit from, the preceding request.
|
||||
*/
|
||||
| {
|
||||
| ({
|
||||
type: 'session/model-request'
|
||||
sessionId: SessionId
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
model: string
|
||||
contextWindow?: number
|
||||
}
|
||||
} & ModelRequestTelemetry)
|
||||
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
|
||||
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
|
||||
|
||||
@@ -29,14 +29,16 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock,
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type {
|
||||
EventsApi, HostFrame, ModelRequestTelemetry, MuxFrame, ToolCallView, ToolEventView, ToolResultView,
|
||||
} from './events.ts'
|
||||
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionProjectionsBlock, SessionSummary,
|
||||
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
@@ -151,23 +151,11 @@ export const sessionProjectionsBlockSchema = z.object({
|
||||
values: z.record(z.string(), z.unknown()),
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** Host-owned durable usage and current-pressure projection. */
|
||||
export const sessionMetricsSchema = z.object({
|
||||
logRevision: z.number().int().nonnegative(),
|
||||
projectionRevision: z.number().int().nonnegative(),
|
||||
uncachedInputTokens: z.number().nonnegative(),
|
||||
outputTokens: z.number().nonnegative(),
|
||||
cacheReadTokens: z.number().nonnegative(),
|
||||
cacheWriteTokens: z.number().nonnegative(),
|
||||
contextTokens: z.number().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionMetrics>>
|
||||
|
||||
/** session.history response value (projections and metrics ride the tail page only). */
|
||||
/** session.history response value (projections ride the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
metrics: sessionMetricsSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
|
||||
/** session.models request payload. */
|
||||
@@ -223,4 +211,3 @@ export const sessionCancelRequestSchema = z.object({
|
||||
export const sessionCancelValueSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>
|
||||
|
||||
|
||||
@@ -35,29 +35,6 @@ export interface HistoryEntry {
|
||||
view?: ToolEventView
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned token metrics for one durable session revision. Provider usage
|
||||
* buckets are cumulative across the full log; current context pressure
|
||||
* describes the replayed request surface at this revision and is absent when
|
||||
* the Host cannot measure it.
|
||||
*/
|
||||
export interface SessionMetrics {
|
||||
/** Number of durable events included in this projection. */
|
||||
logRevision: number
|
||||
/** Monotone ordering within one Host process and mux subscription generation. */
|
||||
projectionRevision: number
|
||||
/** Cumulative uncached provider input. */
|
||||
uncachedInputTokens: number
|
||||
/** Cumulative provider output. */
|
||||
outputTokens: number
|
||||
/** Cumulative provider cache reads. */
|
||||
cacheReadTokens: number
|
||||
/** Cumulative provider cache writes; excluded from the Web cache-hit formula. */
|
||||
cacheWriteTokens: number
|
||||
/** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */
|
||||
contextTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline riding the history tail page: one synchronous cut
|
||||
* over every registered projection unit, read from the registry's watermark
|
||||
@@ -211,17 +188,14 @@ export interface SessionsApi {
|
||||
* the client needs a fresh baseline already pulls the tail page, and
|
||||
* loadOlder (the only beforeSeq path) is the only path that never needs one.
|
||||
* A deployment without the registry serves histories without the block.
|
||||
* The same tail-only rule carries `metrics`, whose cumulative usage and
|
||||
* current token-meter pressure are Host projections over the full log
|
||||
* rather than products of the returned page. Live model capacity is
|
||||
* connection-local telemetry and is never reconstructed here.
|
||||
* Model-request telemetry is connection-local and is never reconstructed
|
||||
* from history.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: SessionProjectionsBlock
|
||||
metrics?: SessionMetrics
|
||||
}>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Full-log usage and current-context projection for Web clients.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-host-apiproxy/session-metrics
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionMetrics } from './api/sessions.ts'
|
||||
|
||||
interface UsageState {
|
||||
logRevision: number
|
||||
projectionRevision: number
|
||||
uncachedInputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
byStep: Map<string, TokenUsage>
|
||||
}
|
||||
|
||||
interface TokenMeterLike {
|
||||
measure(session: Session): { totalTokens: number }
|
||||
}
|
||||
|
||||
function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
|
||||
}
|
||||
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
||||
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an appended event can change cumulative usage or token-meter
|
||||
* pressure. Text/reasoning stream deltas remain outside both projections.
|
||||
* @param event - appended durable event.
|
||||
* @returns true when the Host must publish a fresh metrics snapshot.
|
||||
*/
|
||||
export function affectsSessionMetrics(event: SessionEvent): boolean {
|
||||
if (event.type === 'assistant/chunk') return event.data.chunk.type === 'usage'
|
||||
if (event.type === 'request/header') return true
|
||||
return 'surfaceOp' in event
|
||||
}
|
||||
|
||||
function recordUsage(state: UsageState, turn: number, step: number, usage: TokenUsage): void {
|
||||
const key = `${turn}:${step}`
|
||||
const previous = state.byStep.get(key)
|
||||
if (previous !== undefined) {
|
||||
state.uncachedInputTokens -= previous.inputTokens
|
||||
state.outputTokens -= previous.outputTokens
|
||||
state.cacheReadTokens -= previous.cacheReadTokens ?? 0
|
||||
state.cacheWriteTokens -= previous.cacheWriteTokens ?? 0
|
||||
}
|
||||
state.byStep.set(key, usage)
|
||||
state.uncachedInputTokens += usage.inputTokens
|
||||
state.outputTokens += usage.outputTokens
|
||||
state.cacheReadTokens += usage.cacheReadTokens ?? 0
|
||||
state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
|
||||
}
|
||||
|
||||
/** Projects durable cumulative usage and synchronous current context pressure. */
|
||||
export class SessionMetricsProjector {
|
||||
private readonly usage = new WeakMap<Session, UsageState>()
|
||||
|
||||
/** @param ctx - Host context providing an optional token-meter service. */
|
||||
constructor(private readonly ctx: Context) {}
|
||||
|
||||
/**
|
||||
* Read a fresh detached projection through the session's durable tail.
|
||||
* @param session - authoritative durable log owner.
|
||||
* @returns cumulative usage and any currently measurable pressure.
|
||||
*/
|
||||
snapshot(session: Session): SessionMetrics {
|
||||
const state = this.syncUsage(session)
|
||||
const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined
|
||||
let contextTokens: number | undefined
|
||||
if (tokenMeter !== undefined) {
|
||||
try {
|
||||
contextTokens = tokenMeter.measure(session).totalTokens
|
||||
} catch {
|
||||
// A malformed or temporarily unmeasurable replay has no honest pressure value.
|
||||
}
|
||||
}
|
||||
return {
|
||||
logRevision: state.logRevision,
|
||||
projectionRevision: state.projectionRevision++,
|
||||
uncachedInputTokens: state.uncachedInputTokens,
|
||||
outputTokens: state.outputTokens,
|
||||
cacheReadTokens: state.cacheReadTokens,
|
||||
cacheWriteTokens: state.cacheWriteTokens,
|
||||
...contextTokens === undefined ? {} : { contextTokens },
|
||||
}
|
||||
}
|
||||
|
||||
private syncUsage(session: Session): UsageState {
|
||||
let state = this.usage.get(session)
|
||||
if (state === undefined) {
|
||||
state = {
|
||||
logRevision: 0,
|
||||
projectionRevision: 0,
|
||||
uncachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
byStep: new Map(),
|
||||
}
|
||||
this.usage.set(session, state)
|
||||
}
|
||||
while (state.logRevision < session.events.length) {
|
||||
const event = session.events[state.logRevision]
|
||||
/* v8 ignore next -- Session events are append-only and dense; logRevision is bounded by length. */
|
||||
if (event === undefined) break
|
||||
const usage = usageFrom(event)
|
||||
if (usage !== undefined) recordUsage(state, usage.turn, usage.step, usage.usage)
|
||||
state.logRevision++
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -22,7 +22,7 @@ async function nextFrame<K extends MuxFrame['type']>(
|
||||
}
|
||||
|
||||
describe('ApiProxy model-request telemetry', () => {
|
||||
it('forwards only to open mux connections and never backfills history or reconnect baselines', async () => {
|
||||
it('atomically measures the observed request, forwards only live, and degrades per field', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -35,6 +35,8 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
const measure = vi.fn(() => ({ totalTokens: 321 }))
|
||||
const removeTokenMeter = ctx.provide('tokenMeter' as never, { measure } as never)
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
@@ -48,13 +50,13 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
primaryAbort.signal,
|
||||
)[Symbol.asyncIterator]()
|
||||
expect((await nextFrame(primary, 'session/subscribed')).sessionId).toBe(session.id)
|
||||
expect((await nextFrame(primary, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
|
||||
|
||||
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
expect(measure).toHaveBeenCalledWith(session)
|
||||
expect(await nextFrame(primary, 'session/model-request')).toEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: session.id,
|
||||
@@ -62,6 +64,7 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
step: 2,
|
||||
provider: 'test',
|
||||
model: 'alpha',
|
||||
contextTokens: 321,
|
||||
contextWindow: 128_000,
|
||||
})
|
||||
|
||||
@@ -70,7 +73,8 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
payload: { sessionId: session.id },
|
||||
})
|
||||
if (!history.result.ok) throw new Error('history failed')
|
||||
expect(history.result.value.metrics).not.toHaveProperty('contextWindow')
|
||||
expect(history.result.value).not.toHaveProperty('metrics')
|
||||
expect(history.result.value).not.toHaveProperty('modelRequest')
|
||||
|
||||
const reconnectAbort = new AbortController()
|
||||
const reconnect = api.events.mux(
|
||||
@@ -78,8 +82,8 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
reconnectAbort.signal,
|
||||
)[Symbol.asyncIterator]()
|
||||
expect((await nextFrame(reconnect, 'session/subscribed')).sessionId).toBe(session.id)
|
||||
expect((await nextFrame(reconnect, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
|
||||
|
||||
measure.mockImplementation(() => { throw new Error('unmeasurable replay') })
|
||||
agentEvents(ctx, agent).emit('agent/model-request', 2, 1, {
|
||||
provider: 'test',
|
||||
model: 'without-capacity',
|
||||
@@ -95,6 +99,22 @@ describe('ApiProxy model-request telemetry', () => {
|
||||
})
|
||||
}
|
||||
|
||||
removeTokenMeter()
|
||||
agentEvents(ctx, agent).emit('agent/model-request', 3, 1, {
|
||||
provider: 'test',
|
||||
model: 'without-meter',
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
expect(await nextFrame(primary, 'session/model-request')).toEqual({
|
||||
type: 'session/model-request',
|
||||
sessionId: session.id,
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'test',
|
||||
model: 'without-meter',
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
|
||||
primaryAbort.abort()
|
||||
reconnectAbort.abort()
|
||||
await primary.return?.()
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
|
||||
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
|
||||
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, sessionMetricsSchema,
|
||||
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
|
||||
} from '../src/api/sessions.schema.ts'
|
||||
import {
|
||||
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
|
||||
@@ -153,29 +153,13 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
|
||||
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
|
||||
expect(sessionHistoryValueSchema.parse({
|
||||
const history = sessionHistoryValueSchema.parse({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
projections: { asOfSeq: 11, values: { todos: [] } },
|
||||
metrics: {
|
||||
logRevision: 12,
|
||||
projectionRevision: 4,
|
||||
uncachedInputTokens: 1_000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 4_000,
|
||||
cacheWriteTokens: 500,
|
||||
contextTokens: 8_000,
|
||||
},
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}).metrics?.contextTokens).toBe(8_000)
|
||||
expect(() => sessionMetricsSchema.parse({
|
||||
logRevision: 1,
|
||||
projectionRevision: 0,
|
||||
uncachedInputTokens: -1,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
})).toThrow()
|
||||
})
|
||||
expect(history.projections).toEqual({ asOfSeq: 11, values: { todos: [] } })
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
@@ -377,18 +361,6 @@ describe('events frame schemas', () => {
|
||||
const frames = [
|
||||
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
|
||||
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
|
||||
{
|
||||
type: 'session/metrics',
|
||||
sessionId: 's',
|
||||
metrics: {
|
||||
logRevision: 3,
|
||||
projectionRevision: 1,
|
||||
uncachedInputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 300,
|
||||
cacheWriteTokens: 40,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'session/model-request',
|
||||
sessionId: 's',
|
||||
@@ -396,6 +368,7 @@ describe('events frame schemas', () => {
|
||||
step: 1,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
contextTokens: 8_000,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
{
|
||||
@@ -419,6 +392,7 @@ describe('events frame schemas', () => {
|
||||
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
||||
for (const invalid of [
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: -1 },
|
||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts'
|
||||
|
||||
function assistant(
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
usage: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
},
|
||||
): void {
|
||||
session.append('assistant/chunk', {
|
||||
turn,
|
||||
step,
|
||||
chunk: { type: 'usage', usage },
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: `answer-${turn}-${step}` }],
|
||||
source: { provider: 'test', model: 'alpha' },
|
||||
}),
|
||||
usage,
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('SessionMetricsProjector', () => {
|
||||
it('filters text/reasoning deltas while retaining usage, headers, and surface mutations', () => {
|
||||
const session = new Session(SessionId('metrics-filter'))
|
||||
const text = session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'x' },
|
||||
})
|
||||
const usage = session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
})
|
||||
const header = session.append('request/header', {
|
||||
header: { config: { provider: 'test', model: 'alpha' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const surface = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'question' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const plain = session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
expect(affectsSessionMetrics(text)).toBe(false)
|
||||
expect(affectsSessionMetrics(usage)).toBe(true)
|
||||
expect(affectsSessionMetrics(header)).toBe(true)
|
||||
expect(affectsSessionMetrics(surface)).toBe(true)
|
||||
expect(affectsSessionMetrics(plain)).toBe(false)
|
||||
})
|
||||
|
||||
it('folds usage by turn and step while synchronous pressure follows surface replacement', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('tokenMeter', {
|
||||
measure(session: Session) {
|
||||
return { totalTokens: session.surface.nodes.length * 100 }
|
||||
},
|
||||
})
|
||||
const session = new Session(SessionId('metrics-fold'))
|
||||
const first = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'large old surface' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
assistant(session, 1, 1, {
|
||||
inputTokens: 11,
|
||||
outputTokens: 3,
|
||||
cacheReadTokens: 89,
|
||||
cacheWriteTokens: 8,
|
||||
})
|
||||
|
||||
const projector = new SessionMetricsProjector(ctx)
|
||||
expect(projector.snapshot(session)).toMatchObject({
|
||||
uncachedInputTokens: 11,
|
||||
outputTokens: 3,
|
||||
cacheReadTokens: 89,
|
||||
cacheWriteTokens: 8,
|
||||
contextTokens: 200,
|
||||
})
|
||||
|
||||
const assistantSeq = session.surface.nodes.at(-1)
|
||||
if (assistantSeq === undefined) throw new Error('assistant surface missing')
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'compact summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq },
|
||||
sourceEventSeqs: [first.seq, assistantSeq],
|
||||
})
|
||||
expect(projector.snapshot(session)).toMatchObject({
|
||||
uncachedInputTokens: 11,
|
||||
outputTokens: 3,
|
||||
cacheReadTokens: 89,
|
||||
cacheWriteTokens: 8,
|
||||
contextTokens: 100,
|
||||
})
|
||||
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 12, outputTokens: 4, cacheReadTokens: 88, cacheWriteTokens: 9 },
|
||||
},
|
||||
})
|
||||
assistant(session, 1, 2, { inputTokens: 1_000, outputTokens: 500 })
|
||||
|
||||
expect(projector.snapshot(session)).toMatchObject({
|
||||
logRevision: session.events.length,
|
||||
projectionRevision: 2,
|
||||
uncachedInputTokens: 1_012,
|
||||
outputTokens: 504,
|
||||
cacheReadTokens: 88,
|
||||
cacheWriteTokens: 9,
|
||||
contextTokens: 200,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits pressure when the token meter is absent or cannot measure the replay', () => {
|
||||
const session = new Session(SessionId('metrics-pressure-unknown'))
|
||||
const withoutMeter = new SessionMetricsProjector(new Context()).snapshot(session)
|
||||
expect(withoutMeter.contextTokens).toBeUndefined()
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.provide('tokenMeter', {
|
||||
measure() {
|
||||
throw new Error('unmeasurable replay')
|
||||
},
|
||||
})
|
||||
expect(new SessionMetricsProjector(ctx).snapshot(session).contextTokens).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: cfc3aafc03c27f8a4d879066ab5b46c13e1c2009
|
||||
README.zh.md: 946d2d5d9acc369d37515f54feca47d4c061f92c
|
||||
README.md: 1b815cfba8d2ee1dd69e1f00b8a3911c7d9a788c
|
||||
README.zh.md: 4ba2d340429077a43f88132dceacdabff1f9cf92
|
||||
|
||||
@@ -16,7 +16,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus available context metadata in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call.
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus detached context metadata (including capacity when available) in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
|
||||
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。
|
||||
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置与可用上下文元数据,并将其当前适配器注册捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置与脱耦的上下文元数据(可用时包括容量),并将其当前适配器注册捕获为一次可取消、一次性调用。
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
|
||||
|
||||
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
|
||||
README.md: 578728ded9cf51a12abcd70d541404e995028f26
|
||||
README.zh.md: 51518e98c43e740822970c1a39154f550ea962dc
|
||||
README.md: 5ab1315d8f5885fb24ea48d4e97b7ab9a596483c
|
||||
README.zh.md: 063370e90cd01414e899bbde76a68dc3b603c06b
|
||||
|
||||
@@ -21,6 +21,12 @@ The fold tracks full request-header snapshots, step boundaries, surface appends
|
||||
|
||||
Usage accounting sums disjoint input, cache-read, cache-write, and output buckets; reasoning is not added again. Every successful call records an assistant anchor, including content-less calls. An explicit empty provenance list means a known empty provider stream, while absent legacy provenance conservatively treats the durable assistant output as provider output.
|
||||
|
||||
## Session projection
|
||||
|
||||
When the composition provides `ctx.sessionProjections`, token-meter registers the `tokenUsage` unit through an optional child fiber. Its client-safe value is the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision.
|
||||
|
||||
The unit uses the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes the key. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior.
|
||||
|
||||
## Composition
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -21,6 +21,12 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
|
||||
|
||||
用量计量会求和不重叠的输入、cache-read、cache-write 与输出 bucket;不会再次添加 reasoning。每次成功调用都会记录一个 assistant 锚点,包括无内容调用。显式空溯源列表表示已知空提供方流,而缺失的遗留溯源会保守地将持久 assistant 输出视为提供方输出。
|
||||
|
||||
## 会话投影
|
||||
|
||||
当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册 `tokenUsage` 单元。其可安全传给客户端的值包含完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。
|
||||
|
||||
该单元使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除该键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。
|
||||
|
||||
## 组合
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
@@ -25,7 +25,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1076,6 +1076,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection
|
||||
'@deepseek-ai/dsh-token-meter':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/token-meter
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
|
||||
Reference in New Issue
Block a user