From 6d58953f30a49219b48f66431f4080e0be1c67b0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 30 Jul 2026 17:22:15 +0800 Subject: [PATCH] fix(token-meter): close projected usage review gaps --- ...-token-usage-and-request-context.i18n.yaml | 4 +- ...ojected-token-usage-and-request-context.md | 8 +- ...cted-token-usage-and-request-context.zh.md | 8 +- .../snapshots/code-mode-round/ui.expected.md | 1 - .../cordis-tool-round/ui.expected.md | 1 - .../snapshots/fresh-round-trip/ui.expected.md | 1 - .../lifecycle-chrome/reloaded.expected.md | 1 - .../live-interactions/cancel.expected.md | 3 +- .../live-interactions/error-auth.expected.md | 1 - .../live-interactions/retry.expected.md | 1 - .../question-composer/answered.expected.md | 1 - .../queue-actions/editing.expected.md | 1 - .../snapshots/queue-actions/ui.expected.md | 1 - .../snapshots/steering/mid-steer.expected.md | 1 - .../snapshots/steering/settled.expected.md | 1 - docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 22 ++--- docs/core-data-structures/session.zh.md | 22 ++--- docs/persistence-catalog.md | 6 +- .../client/connection/src/client/fixture.ts | 99 ++++++++++--------- .../client/connection/tests/fixture.spec.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/StatsLine.tsx | 55 +++++++---- .../tests/chat-branch-tails.spec.tsx | 4 +- .../tests/chat-stats-bash-sample.spec.tsx | 38 ++++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 28 +++--- .../tests/request-reconstruction.spec.ts | 37 +++++-- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/index.ts | 4 +- packages/core/session/src/types.ts | 16 +-- .../core/session/tests/request-header.spec.ts | 6 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 3 +- packages/llm/token-meter/README.zh.md | 3 +- packages/llm/token-meter/src/projection.ts | 8 +- .../llm/token-meter/src/usage-projection.ts | 18 ++-- .../tests/token-usage-projection.spec.ts | 29 +++++- scripts/gen-cordis-catalog.ts | 1 - 43 files changed, 280 insertions(+), 183 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 15c8855359..047cccbce3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: 5eff5315d8e566b2d089bdfa2b7a75179a6288bc -2026-07-29-projected-token-usage-and-request-context.zh.md: efcee20d225479a4fafc8c57976ae45dcc39cd5a +2026-07-29-projected-token-usage-and-request-context.md: 1e2c5ff067928620dee3d0937c247bec245e34f2 +2026-07-29-projected-token-usage-and-request-context.zh.md: 811d92e134b1df0fc6725e6c8d38b37efb57b3aa diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index 5eff5315d8..1e2c5ff067 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -16,15 +16,15 @@ Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-tok `tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing. -`contextPressure` carries `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and the optional `contextWindow` from the newest `request/context` record. +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional `contextWindow` from the newest `request/context` record. Neither field is synthesized before its source exists. -`request/context` is a new log-only session event recording the registration-bound capacity of the route a request resolved to. AgentLoop appends it inside the step beside `request/header`, from the context metadata `prepareCall()` now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record, and omitted entirely for a route whose adapter advertises no capacity. +`request/context` is a new log-only session event recording registration-bound metadata for the route a request resolved to. AgentLoop appends it inside the step beside `request/header`, from the context metadata `prepareCall()` now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, clearing an older route's denominator. Capacity deliberately stays out of `EpochHeader`. That type is the reconstruction contract — what a request was built from — and `headerEquals` compares it field-wise to decide whether a snapshot is a real `change`. Capacity is adapter metadata describing a route, so placing it there would let a capacity change masquerade as a request-envelope change and would drag it into the loop's reconstruction invariant. Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence. -The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. A deployment without token-meter drops the token groups; a route with no known capacity drops the occupancy group rather than rendering a placeholder. +The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. ## Context occupancy is approximate, and that is the decision @@ -56,4 +56,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route change. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. +Each session log gains one small `request/context` record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index efcee20d22..811d92e134 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -16,15 +16,15 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗 `tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前样本,不会重复计数。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。 -`contextPressure` 携带 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。 +`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。在各自来源出现前,两个字段都不会被合成。 -`request/context` 是新增的仅入日志会话事件,记录请求所解析到的路由的、绑定注册项的容量。AgentLoop 在步骤内紧随 `request/header` 追加它,数据取自 `prepareCall()` 现在与已解析配置一并返回的上下文元数据:正是那次已经校验过推理的、绑定注册项的查询,因此不会发生第二次解析。当提供方、模型和容量都与上一条记录相同时会跳过;适配器不公布容量的路由则完全不记录。 +`request/context` 是新增的仅入日志会话事件,记录请求所解析到的路由的、绑定注册项的元数据。AgentLoop 在步骤内紧随 `request/header` 追加它,数据取自 `prepareCall()` 现在与已解析配置一并返回的上下文元数据:正是那次已经校验过推理的、绑定注册项的查询,因此不会发生第二次解析。当提供方、模型和容量都与上一条记录相同时会跳过。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,从而清除较早路由的分母。 容量刻意不进入 `EpochHeader`。该类型是重建契约,即请求由什么构建而成,而 `headerEquals` 会逐字段比较它,以判定某个快照是否真的是一次 `change`。容量是描述路由的适配器元数据,把它放进去会让容量变化伪装成请求封装的变化,还会把它拖进 AgentLoop 的重建不变式。 两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。 -Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。未部署 token-meter 时会去掉 token 分组;容量未知的路由会去掉占用率分组,而不是渲染占位符。 +Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。 ## 上下文占用率是近似值,而这正是决策本身 @@ -56,4 +56,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由变化增加一条小型 `request/context` 记录。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 投影是持久会话投影用量语义的正典所有方;TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture 会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 628df57044..48a3a84f9f 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index bac7cc3692..dcb1e839b4 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to:": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index ee6d7ecc02..181c5544c0 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index d96354498d..49beb75b0b 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to reply with a single word. Let me comply.": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 70e44c788a..ac9a1ad53c 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - paragraph: partial - text: 已停止 - button "复制": @@ -27,4 +26,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Context 0% of 128K Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 91ecee3783..274b2b3132 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index bf52c8e4ee..2e2c63d9e6 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -11,7 +11,6 @@ - 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 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index bb75e5b04a..ead4211fa6 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -11,7 +11,6 @@ - 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 diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 5db2dac6c2..311c961450 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - paragraph: partial - list: - listitem: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 4118025a72..f56d4e1970 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - paragraph: partial - list: - listitem: diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 4ef431a402..59328ee1e7 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -11,7 +11,6 @@ - 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 diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 74bbf93eba..1f49c09fd9 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -11,7 +11,6 @@ - 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 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 732ea2073d..1d1bd79e69 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: d315c63daec2fd7dae23406e4a513a746b05c1b8 -session.zh.md: c616626c8b022e9cbd94f55153286f8626926588 +session.md: bc1f8533756cc4f1bf64c9e482d4c4979d086fcc +session.zh.md: 1e5c058d95716bf832b1170e22dbb4bb560b7e92 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d315c63dae..bc1f853375 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -92,13 +92,13 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context capacity for the route a request resolved to, + * Registration-bound context metadata for the route a request resolved to, * appended inside its step beside `request/header` and only when the route * or capacity differs from the last record. It is log-only and deliberately * NOT part of {@link EpochHeader}: capacity is adapter metadata about a * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. Absent for a route whose - * adapter advertises no capacity. + * in request reconstruction or header equality. `contextWindow` is absent + * when the route's adapter advertises no capacity. */ 'request/context': RequestContext /** @@ -176,21 +176,21 @@ Canonical form represents an empty system prompt or tool list as an absent field ### The route capacity event: `request/context` -The context window of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, or capacity differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals`: capacity describes a route, not a request input, so folding it in would let a capacity change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header`, it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally. A route whose adapter advertises no capacity appends nothing, which consumers read as "capacity unknown". +The context metadata of the route a request resolved to is separate logged state, appended beside `request/header` inside the same step and only when the provider, model, or capacity differs from the previous record. It stays outside `EpochHeader` because that type is the reconstruction contract compared field-wise by `headerEquals`: capacity describes a route, not a request input, so folding it in would let a capacity change register as a request-envelope `change` and would pull adapter metadata into the loop's reconstruction invariant. Like `request/header`, it is not a `SurfaceEventType` and produces no LLM message. `session.requestContext()` folds the latest record incrementally. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, so the new record clears an older route's capacity. ```ts type-equiv /** - * Registration-bound context capacity of one resolved model route. Adapter + * Registration-bound context metadata of one resolved model route. Adapter * metadata about a route rather than a request input, which is why it lives * outside {@link EpochHeader}. */ interface RequestContext { - /** Registered provider route the capacity was resolved through. */ + /** Registered provider route the metadata was resolved through. */ provider: string - /** Provider-owned model id the capacity belongs to. */ + /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens. */ - contextWindow: number + /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + contextWindow?: number } ``` @@ -453,11 +453,11 @@ declare class Session { */ requestHeader(): EpochHeader | undefined; /** - * The route capacity in force after the log's last `request/context` event — + * The route metadata in force after the log's last `request/context` event — * what the NEXT request deduplicates against — or undefined before any such * record. Maintained incrementally like {@link requestHeader}, so a per-step * read costs O(new events). - * @returns the folded capacity record, or undefined when none exists yet. + * @returns the folded context record, or undefined when none exists yet. */ requestContext(): RequestContext | undefined; /** diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index c616626c8b..1e5c058d95 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -92,13 +92,13 @@ interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context capacity for the route a request resolved to, + * Registration-bound context metadata for the route a request resolved to, * appended inside its step beside `request/header` and only when the route * or capacity differs from the last record. It is log-only and deliberately * NOT part of {@link EpochHeader}: capacity is adapter metadata about a * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. Absent for a route whose - * adapter advertises no capacity. + * in request reconstruction or header equality. `contextWindow` is absent + * when the route's adapter advertises no capacity. */ 'request/context': RequestContext /** @@ -178,21 +178,21 @@ interface EpochHeader { ### 路由容量事件:`request/context` -请求所解析到的路由的上下文窗口是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建契约:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由不追加任何记录,消费方将此读作「容量未知」。 +请求所解析到的路由的上下文元数据是独立的已记录状态,在同一步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。它保持在 `EpochHeader` 之外,因为该类型是由 `headerEquals` 逐字段比较的重建契约:容量描述的是路由,不是请求输入,把它折叠进去会让一次容量变化被登记为请求信封的 `change`,也会把适配器元数据拉进 loop 的重建不变式。与 `request/header` 一样,它不是 `SurfaceEventType`,也不产生 LLM 消息。`session.requestContext()` 以增量方式归并最新一条记录。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,因此新记录可以清除较早路由的容量。 ```ts type-equiv /** - * Registration-bound context capacity of one resolved model route. Adapter + * Registration-bound context metadata of one resolved model route. Adapter * metadata about a route rather than a request input, which is why it lives * outside {@link EpochHeader}. */ interface RequestContext { - /** Registered provider route the capacity was resolved through. */ + /** Registered provider route the metadata was resolved through. */ provider: string - /** Provider-owned model id the capacity belongs to. */ + /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens. */ - contextWindow: number + /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + contextWindow?: number } ``` @@ -455,11 +455,11 @@ declare class Session { */ requestHeader(): EpochHeader | undefined; /** - * The route capacity in force after the log's last `request/context` event — + * The route metadata in force after the log's last `request/context` event — * what the NEXT request deduplicates against — or undefined before any such * record. Maintained incrementally like {@link requestHeader}, so a per-step * read costs O(new events). - * @returns the folded capacity record, or undefined when none exists yet. + * @returns the folded context record, or undefined when none exists yet. */ requestContext(): RequestContext | undefined; /** diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index ee38af6f8f..a463fc5a30 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -373,13 +373,13 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s ```ts persistence-catalog /** - * Registration-bound context capacity for the route a request resolved to, + * Registration-bound context metadata for the route a request resolved to, * appended inside its step beside `request/header` and only when the route * or capacity differs from the last record. It is log-only and deliberately * NOT part of {@link EpochHeader}: capacity is adapter metadata about a * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. Absent for a route whose - * adapter advertises no capacity. + * in request reconstruction or header equality. `contextWindow` is absent + * when the route's adapter advertises no capacity. */ 'request/context': RequestContext ``` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8d2ae22940..a28d0df773 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -475,6 +475,33 @@ interface FixtureTokenUsageProjection { cacheWriteTokens: number } +interface FixtureUsageSample { + turn: number + step: number + usage: TokenUsage +} + +/** Read one provider usage sample from either durable carrier. */ +function usageSampleOf(event: SessionEvent): FixtureUsageSample | undefined { + 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 + return usage === undefined || item.data.turn === undefined || item.data.step === undefined + ? undefined + : { turn: item.data.turn, step: item.data.step, usage } +} + /** Fixture parallel of token-meter's last-sample-replacing usage projection. */ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection { const totals: FixtureTokenUsageProjection = { @@ -489,47 +516,40 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection 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 sample = usageSampleOf(event) + if (sample === undefined) continue const buckets: FixtureTokenUsageProjection = { - uncachedInputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cacheReadTokens: usage.cacheReadTokens ?? 0, - cacheWriteTokens: usage.cacheWriteTokens ?? 0, + uncachedInputTokens: sample.usage.inputTokens, + outputTokens: sample.usage.outputTokens, + cacheReadTokens: sample.usage.cacheReadTokens ?? 0, + cacheWriteTokens: sample.usage.cacheWriteTokens ?? 0, } - const previous = last?.turn === item.data.turn && last.step === item.data.step + const previous = last?.turn === sample.turn && last.step === sample.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 } + last = { turn: sample.turn, step: sample.step, buckets } } return totals } -/** Latest log-only capacity record, or undefined before any request ran. */ +interface FixtureRequestContext { + provider: string + model: string + contextWindow?: number +} + +/** Latest log-only route context, or undefined before any request ran. */ function lastRequestContext( log: readonly SessionEvent[], -): { provider: string; model: string; contextWindow: number } | undefined { +): FixtureRequestContext | undefined { const event = log.findLast(item => (item as { type: string }).type === 'request/context') return event === undefined ? undefined - : (event as unknown as { data: { provider: string; model: string; contextWindow: number } }).data + : (event as unknown as { data: FixtureRequestContext }).data } /** @@ -539,26 +559,18 @@ function lastRequestContext( */ function contextPressureOf( log: readonly SessionEvent[], -): { pressureTokens: number; contextWindow?: number } { - let pressureTokens = 0 +): { pressureTokens?: number; contextWindow?: number } { + let pressureTokens: number | undefined for (const event of log) { - const item = event as unknown as { - type: string - data: { 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) continue - pressureTokens = usage.inputTokens - + (usage.cacheReadTokens ?? 0) - + (usage.cacheWriteTokens ?? 0) + const sample = usageSampleOf(event) + if (sample === undefined) continue + pressureTokens = sample.usage.inputTokens + + (sample.usage.cacheReadTokens ?? 0) + + (sample.usage.cacheWriteTokens ?? 0) } const contextWindow = lastRequestContext(log)?.contextWindow return { - pressureTokens, + ...pressureTokens === undefined ? {} : { pressureTokens }, ...contextWindow === undefined ? {} : { contextWindow }, } } @@ -588,12 +600,7 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record[] { const type = (event as { type: string }).type // One usage sample advances both token-meter units. - 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) - ) { + if (usageSampleOf(event) !== undefined) { return [ { type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, { type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 20ebc24a57..d6538429ab 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -90,8 +90,8 @@ describe('createFixtureApi', () => { cacheReadTokens: 0, cacheWriteTokens: 0, }, - // No request ran, so pressure is zero and no capacity is known yet. - contextPressure: { pressureTokens: 0 }, + // No request ran, so neither pressure nor capacity is known yet. + contextPressure: {}, } }, }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0193796933..1bdacfe21a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 84051dbe29503bcb20e317247db2ba2026db4528 -README.zh.md: 36a0983f95dafc55a4fc76a5bc82112fb369efce +README.md: c5b10490abf42bcb04a675d50b3c487fdbf3f3b8 +README.zh.md: e8bca83ae56c638e7a8fb852c35164b1941d749f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 84051dbe29..c5b10490ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,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, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (cache hit is `cacheRead / (uncachedInput + cacheRead)`, excluding cache writes) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting. A deployment without token-meter drops the token groups, and a route whose adapter advertises no capacity drops the occupancy group instead of rendering a placeholder. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The inline stats row remains the sole context UI; the model selector has no circle or accessory. +The chat stats line takes its token accounting from two generic token-meter projections read through the standard-kit `useProjection`: `tokenUsage` for full-log billing (billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total) and `contextPressure` for context occupancy. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. A deployment without token-meter drops the token groups, and occupancy stays hidden until both provider pressure and route capacity are known. Occupancy is deliberately an approximation — its numerator and capacity are independent last-wins projection fields, not one atomic request observation ([rationale](../../llm/token-meter/README.md)). The 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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 36a0983f95..e8bca83ae5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -22,7 +22,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 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(缓存命中率为 `cacheRead / (uncachedInput + cacheRead)`,不计入缓存写入),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM 和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目。未组合 token-meter 的部署会整组省略 token 分组;适配器未公布容量的路由会省略占用率分组,而不是渲染占位文案。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后者胜」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM 和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后者胜」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 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/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 5c8e22b3c5..4cb5df2565 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -78,23 +78,38 @@ export function formatDuration(ms: number): string { * @returns rounded integer percent, or null when no input was billed. */ export function cacheHitPercent(usage: TokenUsageProjection): number | null { - const denominator = usage.uncachedInputTokens + usage.cacheReadTokens + const denominator = billedInputTokens(usage) return denominator === 0 ? null : Math.round(usage.cacheReadTokens / denominator * 100) } +/** Sum the three disjoint prompt-side billing buckets. */ +function billedInputTokens(usage: TokenUsageProjection): number { + return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens +} + +interface ContextOccupancy { + percent: number + contextWindow: number +} + /** * Approximate context occupancy, using the TUI's integer rounding and upper * clamp. The numerator and capacity are independent last-wins projection * fields, so this is a reference figure rather than an exact measurement of one * request (see the token-meter README). * @param pressure - the session's context-pressure projection value. - * @returns occupancy percent, or null when no capacity is known. + * @returns occupancy and its denominator, or null until both values are known. */ -export function contextPercent(pressure: ContextPressureProjection | undefined): number | null { - if (pressure?.contextWindow === undefined) return null - return Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)) +export function contextOccupancy( + pressure: ContextPressureProjection | undefined, +): ContextOccupancy | null { + if (pressure?.pressureTokens === undefined || pressure.contextWindow === undefined) return null + return { + percent: Math.min(100, Math.round(pressure.pressureTokens / pressure.contextWindow * 100)), + contextWindow: pressure.contextWindow, + } } /** Props: the conversation-snapshot selector plus the projection read seat. */ @@ -108,29 +123,31 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection }: const usage = useProjection('tokenUsage') const pressure = useProjection('contextPressure') const stats = useMemo(() => deriveStats(nodes), [nodes]) - if (stats.steps === 0) return null // Pipe-separated groups (figma stats strip); a group with no data drops out whole. - const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`] - const durations: string[] = [] - if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) - if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) - if (durations.length > 0) groups.push(durations.join(' · ')) - const context = contextPercent(pressure) - // Capacity absent (no token-meter composed, or an adapter that advertises - // none) drops the group: an unknown denominator has no percentage to show. - if (context !== null && pressure?.contextWindow !== undefined) { - groups.push(`Context ${context}% of ${formatTokens(pressure.contextWindow)}`) + const groups: string[] = [] + if (stats.steps > 0) { + groups.push(`${stats.turns} turns · ${stats.steps} steps`) + const durations: string[] = [] + if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) + if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) + if (durations.length > 0) groups.push(durations.join(' · ')) + } + const context = contextOccupancy(pressure) + if (context !== null) { + groups.push(`Context ${context.percent}% of ${formatTokens(context.contextWindow)}`) } // Billing rides the durable projection, so these survive paging and - // compaction; a deployment without token-meter drops the groups entirely. - if (usage !== undefined) { + // compaction. Suppress the empty projection on a brand-new session. + if (usage !== undefined + && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { const cacheHit = cacheHitPercent(usage) if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`) groups.push( - `Input ${formatTokens(usage.uncachedInputTokens + usage.cacheReadTokens)} tok` + `Input ${formatTokens(billedInputTokens(usage))} tok` + ` · Output ${formatTokens(usage.outputTokens)} tok`, ) } + if (groups.length === 0) return null return (
{groups.map((group, i) => ( diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 918706d347..31d43998ad 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -218,8 +218,8 @@ describe('small branch tails', () => { }) it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { - // Cache hit is null only when uncached input and cache reads are both zero - // (pure output accounting) — any input makes it a real 0%. + // Cache hit is null only when all three prompt buckets are zero (pure + // output accounting) — any billed input makes it a real 0%. const snap = { nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }], } diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 5e62cb22f1..569eca36f3 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -122,17 +122,30 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source), useProjection: projections(values) } } - it('renders the grouped stats row and hides with zero steps', () => { + it('renders the grouped stats row and hides a brand-new empty session', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render() // No timing on the fixture: the duration group drops out whole. Tokens come // from the projection, so paging the window cannot change them. expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok') const empty = makeSource() - const emptyView = render() + const emptyView = render() expect(emptyView.container.textContent).toBe('') }) + it('keeps durable token and context groups after the visible step window is empty', () => { + const { source } = makeSource() + const view = render() + expect(view.container.textContent) + .toBe('Context 25% of 128K|Cache hit 90%|Input 100 tok · Output 5 tok') + }) + it('renders context occupancy only when the projection knows a capacity', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const withCapacity = render( { contextPressure: { pressureTokens: 32_000 }, })} />) expect(noCapacity.container.textContent).not.toContain('Context') + // Capacity arrives before usage in the log; no provider sample means there + // is no numerator yet, rather than a synthetic 0%. + const noPressure = render() + expect(noPressure.container.textContent).not.toContain('Context') }) it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => { @@ -173,6 +193,20 @@ describe('StatsLine', () => { expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok') }) + it('includes cache writes in billed input and the cache-hit denominator', () => { + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render() + expect(view.container.textContent) + .toBe('1 turns · 1 steps|Cache hit 45%|Input 200 tok · Output 7 tok') + }) + it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => { const { set, source } = makeSource({ nodes: [assistant(1, 1)] }) let renders = 0 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 54c569ab45..ff79f4abc2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2105,7 +2105,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RequestContext', - declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow: number;\n}', + declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n}', }, { name: 'RequestHeaderReason', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 581d54b64c..bce1142a34 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -44,7 +44,7 @@ import { } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' -import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' +import type { AssistantMessage, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' @@ -668,21 +668,21 @@ export class ReactLoopAgent implements Agent { session.append('request/header', { header, reason: 'change' }) } - // Capacity of the route this request resolved to, recorded from the same + // Context metadata for the route this request resolved to, recorded from the same // registration-bound lookup that prepared the call (no second resolve). - // Deduplicated against the last record: an unchanged route logs nothing. + // A route with unknown capacity is still recorded so it clears any older + // denominator; an unchanged route logs nothing. const contextWindow = preparedCall?.context?.contextWindow - if (contextWindow !== undefined) { - const previous = session.requestContext() - if (previous?.provider !== config.provider - || previous.model !== config.model - || previous.contextWindow !== contextWindow) { - session.append('request/context', { - provider: config.provider, - model: config.model, - contextWindow, - }) - } + const requestContext: RequestContext = { + provider: config.provider, + model: config.model, + ...contextWindow === undefined ? {} : { contextWindow }, + } + const previous = session.requestContext() + if (previous?.provider !== requestContext.provider + || previous.model !== requestContext.model + || previous.contextWindow !== requestContext.contextWindow) { + session.append('request/context', requestContext) } const request = markAgentLoopRequest(deepFreeze({ diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 0a2cc85448..36943ce237 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -578,13 +578,38 @@ describe('request/context capacity records', () => { .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) }) - it('records nothing when the adapter advertises no capacity', async () => { - // The absent-capacity path must stay silent rather than log a placeholder: - // consumers read "no capacity known" and omit their percentage entirely. - const ctx = await harness(new MockAdapter([textResponse('a')])) + it('records and deduplicates a route whose adapter advertises no capacity', async () => { + const ctx = await harness(new MockAdapter([textResponse('a'), textResponse('b')])) const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' }) - send(agent, 'go') + send(agent, 'first') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'request/context')).toBe(false) + send(agent, 'second') + await waitForIdle(ctx, agent) + expect(agent.session.events + .filter(event => event.type === 'request/context') + .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) + }) + + it('clears a previous capacity when the next route advertises none', async () => { + const adapter = capacityAdapter({ known: 64_000 }, [textResponse('a'), textResponse('b')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) + let model = 'known' + ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent + ? Promise.resolve({ provider: 'mock', model }) + : next()) + + send(agent, 'first') + await waitForIdle(ctx, agent) + model = 'unknown' + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(agent.session.events + .filter(event => event.type === 'request/context') + .map(event => event.data)).toEqual([ + { provider: 'mock', model: 'known', contextWindow: 64_000 }, + { provider: 'mock', model: 'unknown' }, + ]) }) }) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index d5882f09c2..e2ce4e72ab 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 861b96c453e677807bded2475fe8e62a74bcd299 -README.zh.md: 6974a072f5cb32f4e850846bbb02af59cda93303 +README.md: fe96b5c9735d48d4f92210970b7707749a920787 +README.zh.md: 6f8aaeef2464a946e10aeef0cfe13b36ab303aeb diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 861b96c453..fe96b5c973 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -65,7 +65,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ `request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`request/context` records the registration-bound `contextWindow` of the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity appends nothing. +`request/context` records registration-bound metadata for the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity is still recorded with `contextWindow` absent, clearing any older known capacity. A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 6974a072f5..6f8aaeef24 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -65,7 +65,7 @@ `request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。 -`request/context` 记录请求所解析到的路由的、绑定注册项的 `contextWindow`,在其所属步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由不追加任何记录。 +`request/context` 记录请求所解析到的路由的、绑定注册项的元数据,在其所属步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由仍会被记录,但 `contextWindow` 字段缺失,从而清除较早的已知容量。 `user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 0cc7b1de42..697983b68e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -584,11 +584,11 @@ export class Session { private contextFoldSeq = 0 /** - * The route capacity in force after the log's last `request/context` event — + * The route metadata in force after the log's last `request/context` event — * what the NEXT request deduplicates against — or undefined before any such * record. Maintained incrementally like {@link requestHeader}, so a per-step * read costs O(new events). - * @returns the folded capacity record, or undefined when none exists yet. + * @returns the folded context record, or undefined when none exists yet. */ requestContext(): RequestContext | undefined { if (this.contextFoldSeq < this.log.length) { diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index b60c79d89f..5dc70fd210 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -170,17 +170,17 @@ export interface EpochHeader { } /** - * Registration-bound context capacity of one resolved model route. Adapter + * Registration-bound context metadata of one resolved model route. Adapter * metadata about a route rather than a request input, which is why it lives * outside {@link EpochHeader}. */ export interface RequestContext { - /** Registered provider route the capacity was resolved through. */ + /** Registered provider route the metadata was resolved through. */ provider: string - /** Provider-owned model id the capacity belongs to. */ + /** Provider-owned model id the metadata belongs to. */ model: string - /** Maximum combined request and response context in tokens. */ - contextWindow: number + /** Maximum combined request and response context in tokens; absent when the adapter advertises none. */ + contextWindow?: number } /** @@ -265,13 +265,13 @@ export interface SessionEventMap { */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Registration-bound context capacity for the route a request resolved to, + * Registration-bound context metadata for the route a request resolved to, * appended inside its step beside `request/header` and only when the route * or capacity differs from the last record. It is log-only and deliberately * NOT part of {@link EpochHeader}: capacity is adapter metadata about a * route, not an input the request was built from, so it must not participate - * in request reconstruction or header equality. Absent for a route whose - * adapter advertises no capacity. + * in request reconstruction or header equality. `contextWindow` is absent + * when the route's adapter advertises no capacity. */ 'request/context': RequestContext /** diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index dff3f7e4b4..d940290c60 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -96,7 +96,7 @@ describe('Session.requestContext', () => { const CAPACITY = { provider: 'mock', model: 'm', contextWindow: 128_000 } /** A turn-enclosed capacity record; the invariant rejects one outside a turn. */ - function seedWith(...records: { provider: string; model: string; contextWindow: number }[]): SessionEvent[] { + function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] { const events: SessionEvent[] = [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }] @@ -127,6 +127,8 @@ describe('Session.requestContext', () => { expect(session.requestContext()).toEqual(CAPACITY) session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 }) expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 }) + session.append('request/context', { provider: 'mock', model: 'unknown' }) + expect(session.requestContext()).toEqual({ provider: 'mock', model: 'unknown' }) }) it('folds a batch appended between two reads', () => { @@ -143,6 +145,6 @@ describe('Session.requestContext', () => { const held = session.requestContext() if (held === undefined) throw new Error('expected a folded capacity record') expect(Object.isFrozen(held)).toBe(true) - expect(() => { (held as { contextWindow: number }).contextWindow = 1 }).toThrow() + expect(() => { (held as { contextWindow?: number }).contextWindow = 1 }).toThrow() }) }) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 8b4163ee5b..3b895b9852 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md -README.md: b9bf1dfa253e424e5ec35cd3e7bf0f52af579077 -README.zh.md: c97fc0b87364dfe9ca46139f0ec82519e191b772 +README.md: 701893b342f9a93a75bec175634b1054f3d17151 +README.zh.md: a5844e8788422bba669632ed587fb87e1e2a1e58 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index b9bf1dfa25..701893b342 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -27,7 +27,7 @@ When the composition provides `ctx.sessionProjections`, token-meter registers tw `tokenUsage` carries 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 single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again. -`contextPressure` carries `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and the optional `contextWindow` from the newest `request/context` record. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage. +`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — and optional `contextWindow` from the newest `request/context` record. Pressure stays absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so the numerator holds still while a turn streams and steps forward when the next request reports its usage. Both units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes both keys. A headless or TUI composition without the projection seam keeps the measurement service's existing behavior. @@ -62,3 +62,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks. - **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation. - **Legacy provenance is conservative** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream. +- **The TUI and browser fixture retain parallel folds** — `tokenUsage` owns durable session-projection semantics; the TUI keeps its live per-step map because its composition does not mount the generic projection seam, while the browser fixture mirrors the unit for standalone demo data. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index c97fc0b873..a5844e8788 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -27,7 +27,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 `tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。 -`contextPressure` 携带 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。 +`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前压力保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间分子保持不动,等到下一个请求报告用量时才前进。 两个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这两个键。不带投影 seam 的 headless 或 TUI 组合会保留测量服务的既有行为。 @@ -62,3 +62,4 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 - **每次测量都会克隆当前表层**:一致且不可变的快照使读取成为 O(surface),包括低于阈值的压力检查。 - **提供方用量只能为完全相同的规范 envelope 复用**:提示词、前缀、工具、提供方、模型或调用配置变更都会有意回退到完整启发式估算。 - **遗留溯源采取保守策略**:没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与 listener 改写,因此 fold 不会声称已知空流或精确分片流。 +- **TUI 与浏览器 fixture 仍保留并行 fold**:`tokenUsage` 拥有持久会话投影语义;TUI 的组合未挂载通用投影 seam,因此继续维护实时的逐步骤 map,而浏览器 fixture 会为独立 demo 数据镜像该单元。 diff --git a/packages/llm/token-meter/src/projection.ts b/packages/llm/token-meter/src/projection.ts index 8016eacaf3..2740b4954f 100644 --- a/packages/llm/token-meter/src/projection.ts +++ b/packages/llm/token-meter/src/projection.ts @@ -20,8 +20,8 @@ export interface TokenUsageProjection { /** * Approximate context occupancy for a status display. * - * The two fields are deliberately NOT one atomic request observation: - * `pressureTokens` is the newest provider-reported prompt size in the log, + * The two fields, when present, are deliberately NOT one atomic request + * observation: `pressureTokens` is the newest provider-reported prompt size, * `contextWindow` the newest recorded route capacity. Switching models can * therefore pair a fresh capacity with the previous route's pressure until the * next request reports usage. This is an intentional trade — the value is a @@ -33,9 +33,9 @@ export interface ContextPressureProjection { /** * Provider-reported prompt size of the most recent request: uncached input * plus cache reads and writes. Response output is excluded, so this does not - * grow as the current turn streams. + * grow as the current turn streams. Absent until a provider reports usage. */ - pressureTokens: number + pressureTokens?: number /** Newest recorded route capacity; absent when no adapter advertised one. */ contextWindow?: number } diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts index 362f503907..302485da8e 100644 --- a/packages/llm/token-meter/src/usage-projection.ts +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -56,10 +56,10 @@ const projectionSchema = z.object({ cacheWriteTokens: z.number().int().nonnegative(), }).strict() -// Cast for the optional capacity: under exactOptionalPropertyTypes zod infers -// `number | undefined` where the interface declares an absent-or-number field. +// Cast for the optional values: under exactOptionalPropertyTypes zod infers +// `number | undefined` where the interface declares absent-or-number fields. const pressureSchema = z.object({ - pressureTokens: z.number().int().nonnegative(), + pressureTokens: z.number().int().nonnegative().optional(), contextWindow: z.number().int().positive().optional(), }).strict() as unknown as z.ZodType @@ -128,12 +128,14 @@ export const contextPressureProjectionDefinition: ProjectionDefinition<'contextPressure', ContextPressureProjection> = { key: 'contextPressure', schema: pressureSchema, - init: () => ({ pressureTokens: 0 }), + init: () => ({}), apply: (state, event) => { if (event.type === 'request/context') { - return event.data.contextWindow === state.contextWindow - ? state - : { ...state, contextWindow: event.data.contextWindow } + const contextWindow = event.data.contextWindow + if (contextWindow === state.contextWindow) return state + if (contextWindow !== undefined) return { ...state, contextWindow } + const { contextWindow: _removed, ...withoutContextWindow } = state + return withoutContextWindow } const usage = event.type === 'assistant/chunk' && event.data.chunk.type === 'usage' ? event.data.chunk.usage @@ -147,5 +149,5 @@ ProjectionDefinition<'contextPressure', ContextPressureProjection> = { : { ...state, pressureTokens } }, view: state => state, - stateVersion: 1, + stateVersion: 2, } diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index a9ebf1b1b0..4079916a8c 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -227,14 +227,25 @@ const pressure = (ctx: Context, session: Session): ContextPressureProjection => return value } -function recordContext(session: Session, model: string, contextWindow: number): void { - session.append('request/context', { provider: 'mock', model, contextWindow }) +function recordContext(session: Session, model: string, contextWindow?: number): void { + session.append('request/context', { + provider: 'mock', + model, + ...contextWindow === undefined ? {} : { contextWindow }, + }) } describe('contextPressure session projection', () => { - it('serves zero pressure and no capacity for an empty log', async () => { + it('serves no pressure or capacity for an empty log', async () => { const { ctx, session } = await harness() - expect(pressure(ctx, session)).toEqual({ pressureTokens: 0 }) + expect(pressure(ctx, session)).toEqual({}) + }) + + it('does not synthesize zero pressure before a provider usage sample', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + recordContext(session, 'small', 64_000) + expect(pressure(ctx, session)).toEqual({ contextWindow: 64_000 }) }) it('sums prompt-side buckets and excludes response output', async () => { @@ -271,6 +282,15 @@ describe('contextPressure session projection', () => { expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 }) }) + it('removes an older capacity when the newest route advertises none', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + recordContext(session, 'small', 64_000) + usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1) + recordContext(session, 'unknown') + expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 }) + }) + it('pushes no change for unrelated events or a restated capacity', async () => { // The registry gates its change feed on Object.is, so a unit that rebuilt // state for an event it does not care about would push phantom updates. @@ -299,6 +319,7 @@ describe('contextPressure session projection', () => { const checkpoint = JSON.parse(JSON.stringify( ctx.sessionProjections.checkpoint(session), )) as ReturnType + expect(checkpoint.contextPressure?.ver).toBe(2) await meterFiber.dispose() expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 43c1cf58a0..4c30f8f2c5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -216,7 +216,6 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ /** Project types deliberately documented outside the core-data catalog. */ export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', - AgentModelRequest: 'event-local live request metadata is owned by packages/core/agent/README.md', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',