Parcourir la source

feat(web): show exact per-turn token usage (#3005)

* feat(web): show exact per-turn token usage

* test(runtime): refresh exact token usage snapshots

* refactor(token-meter): own per-turn usage folding

* perf(ui-chat): bound paging anchor layout reads

* test(web): align usage golden with system prompt row

* fix(test): resolve token-meter client from source

* test(token-meter): cover retry without usage

---------

Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Ziya il y a 1 mois
Parent
commit
b565df3442
69 fichiers modifiés avec 1669 ajouts et 221 suppressions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
  2. 4 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
  3. 4 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.i18n.yaml
  5. 31 0
      .agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.md
  6. 31 0
      .agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.zh.md
  7. 29 1
      apps/web/tests/turn-tail-actions.e2e.ts
  8. 2 2
      docs/module-graph.i18n.yaml
  9. 2 1
      docs/module-graph.md
  10. 2 1
      docs/module-graph.zh.md
  11. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  12. 9 1
      docs/subsystems/llm-streaming.md
  13. 9 1
      docs/subsystems/llm-streaming.zh.md
  14. 3 3
      packages/client/tsdown.client.ts
  15. 2 2
      packages/client/ui-chat/README.i18n.yaml
  16. 1 0
      packages/client/ui-chat/README.md
  17. 1 0
      packages/client/ui-chat/README.zh.md
  18. 17 26
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  19. 2 62
      packages/client/ui-chat/src/client/chat/StatsLine.tsx
  20. 7 0
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  21. 17 13
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  22. 87 0
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
  23. 86 0
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
  24. 98 0
      packages/client/ui-chat/src/client/chat/token-format.ts
  25. 25 0
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  26. 9 1
      packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
  27. 22 0
      packages/client/ui-chat/src/client/locale.ts
  28. 2 1
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  29. 55 1
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  30. 38 0
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  31. 5 0
      packages/client/ui-chat/tests/turn-metrics.client.spec.ts
  32. 76 0
      packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
  33. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  34. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  35. 1 1
      packages/llm/llm-deepseek/README.md
  36. 1 1
      packages/llm/llm-deepseek/README.zh.md
  37. 10 1
      packages/llm/llm-deepseek/src/translate.ts
  38. 2 0
      packages/llm/llm-deepseek/src/types.ts
  39. 1 1
      packages/llm/llm-deepseek/tests/adapter.spec.ts
  40. 24 8
      packages/llm/llm-deepseek/tests/translate.spec.ts
  41. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  42. 1 1
      packages/llm/llm-pi-ai/README.md
  43. 1 1
      packages/llm/llm-pi-ai/README.zh.md
  44. 3 1
      packages/llm/llm-pi-ai/src/stream.ts
  45. 1 1
      packages/llm/llm-pi-ai/tests/adapter.spec.ts
  46. 5 4
      packages/llm/llm-pi-ai/tests/convert.spec.ts
  47. 8 0
      packages/llm/llm/src/types.ts
  48. 2 2
      packages/llm/token-meter/README.i18n.yaml
  49. 3 1
      packages/llm/token-meter/README.md
  50. 3 1
      packages/llm/token-meter/README.zh.md
  51. 2 0
      packages/llm/token-meter/package.json
  52. 3 1
      packages/llm/token-meter/src/client.ts
  53. 2 2
      packages/llm/token-meter/src/invariant.ts
  54. 271 0
      packages/llm/token-meter/src/turn-usage.ts
  55. 12 6
      packages/llm/token-meter/src/usage-projection.ts
  56. 61 1
      packages/llm/token-meter/tests/token-usage-projection.spec.ts
  57. 397 0
      packages/llm/token-meter/tests/turn-usage.spec.ts
  58. 3 0
      packages/llm/token-meter/tsconfig.json
  59. 3 0
      pnpm-lock.yaml
  60. 3 0
      scripts/client-bundle-purity.spec.ts
  61. 64 32
      scripts/snapshots/python-sdk-single-exe/advanced/result.json
  62. 2 2
      scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl
  63. 2 2
      scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl
  64. 14 14
      scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl
  65. 2 2
      scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl
  66. 2 2
      scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl
  67. 4 4
      snapshots/web/turn-tail-actions/session.jsonl
  68. 64 0
      snapshots/web/turn-tail-actions/usage-expanded.expected.md
  69. 1 0
      tsconfig.base.json

+ 2 - 2
.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: f2179885512bcb216ecb191ce98b535db571807a
-2026-07-29-projected-token-usage-and-request-context.zh.md: e4435b6245d1e20b51fc2cc1d73151ced8d94731
+2026-07-29-projected-token-usage-and-request-context.md: 063f2300f378f6f7763bce87b11add5da3093230
+2026-07-29-projected-token-usage-and-request-context.zh.md: 37b8741d09e9ec56f6b9f273e05460b2deb4f6f9

+ 4 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md

@@ -14,7 +14,9 @@ Context occupancy needs a numerator and a denominator that no existing surface c
 
 Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
 
-`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
+`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 replaces the earlier sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new attempt. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
+
+Token-meter also owns the shared pure attempt/Turn fold over durable events. It applies the same retry boundary while adding the stricter completeness and exact-total checks required by an exact per-Turn disclosure. A presentation consumer may select a complete Turn window and invoke that fold, but does not own or duplicate the accounting semantics.
 
 `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.
 
@@ -56,4 +58,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 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.
+Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. 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.

+ 4 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md

@@ -14,7 +14,9 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗
 
 这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
 
-`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前样本,不会重复计数。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
+`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;`assistant/message` 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
+
+token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。
 
 `contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。在各自来源出现前,两个字段都不会被合成。
 
@@ -56,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定,
 
 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
 
-每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 投影是持久会话投影用量语义的正典所有方;TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。
+每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.md
+2026-08-24-web-per-turn-token-usage.md: 91aab0f2c261e2141ee964c828e7209ba2b3f72f
+2026-08-24-web-per-turn-token-usage.zh.md: f9c424fa0f84b802e98280bea9eaf4038bf31f19

+ 31 - 0
.agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.md

@@ -0,0 +1,31 @@
+# Agent Note: Exact Web per-Turn token usage
+
+Status: implemented
+
+English | [中文](2026-08-24-web-per-turn-token-usage.zh.md)
+
+## Problem
+
+Web Chat exposes cumulative session token usage near the composer, but that value cannot explain the cost of one completed Turn. A paged history window may begin inside a Turn, retries may consume several model calls, streaming and final events may repeat one attempt's usage, and optional cache fields do not prove an exact total. Displaying a partial subtotal as Turn usage would make recorded provider facts look more complete than they are.
+
+## Decision
+
+The shared `TokenUsage` value carries optional `totalTokens` for one model call. Adapters publish it only from an exact provider total or authoritative aggregate prompt and output counters. DeepSeek checks its prompt-plus-completion aggregate against any wire total, and pi-ai preserves its provided total.
+
+Token-meter owns a browser-safe pure Turn-local fold over durable session events, shared with its retry-aware cumulative usage projection. `step/start` and `llm/retry-started` open actual attempts; a final assistant message replaces the same attempt's streaming sample; terminal failures, retries, and step boundaries close attempts without double counting. Every started attempt must close with safe non-negative integer usage and an exact total. Optional cache, reasoning, and route aggregates appear only when every contributing attempt reports them, and reasoning remains a subset of output.
+
+Web Chat selects a Turn only when its loaded match window includes `turn/start`, passes that complete durable-event window to the token-meter fold, and renders the result. A complete, exact result appears through a local-state `DisclosureRow` above the existing actions; incomplete or contradictory evidence produces no row. Chat owns no token-accounting state machine.
+
+## Alternatives considered
+
+**Subtract neighboring cumulative session values.** Rejected because pagination, compaction, retry coverage, and projection completeness can make adjacent values incomparable; subtraction would infer data that no call reported.
+
+**Publish historical per-Turn values through a new client session projection.** Rejected because the loaded per-Turn view already has the durable attempt events it needs, while a history-growing projection would add transport, persistence, and versioning costs. Reusing token-meter's pure fold keeps one accounting owner without adding another wire value.
+
+**Show known buckets without an exact total.** Rejected because a lower-bound subtotal presented in a completed Turn footer is indistinguishable from a complete bill.
+
+## Consequences
+
+New provider records can expose exact per-Turn accounting without a new transport or persisted UI state. Older sessions and adapters without enough evidence simply omit the disclosure. Model routes disappear as a group when any billed attempt lacks attribution, while trustworthy token totals remain visible.
+
+Focused adapter, token-meter fold/projection, component, pagination, and assembled Web replay tests pin total preservation, retry-attempt separation, fail-closed validation, optional-field omission, interaction, and full-window publication. The cumulative projection and exact Turn fold now share token-meter ownership; the projection remains a whole-log bucket view, while the fold alone makes the stricter exactness and completeness claim required by the disclosure.

+ 31 - 0
.agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Web 单轮次精确 token 用量
+
+Status: implemented
+
+[English](2026-08-24-web-per-turn-token-usage.md) | 中文
+
+## Problem
+
+Web Chat 在编辑框附近显示会话累计 token 用量,但该值无法解释一个已完成轮次的消耗。分页历史窗口可能从轮次中间开始,重试可能消耗多次模型调用,流式事件与最终事件可能重复携带同一次 attempt 的用量,而可选 cache 字段也不能证明精确总量。将局部小计显示成轮次用量,会让已记录的提供方事实显得比实际更完整。
+
+## Decision
+
+共享 `TokenUsage` 值为一次模型调用携带可选的 `totalTokens`。适配器只从提供方精确总量,或权威的提示词与输出聚合计数发布该字段。DeepSeek 会将提示词加输出的聚合值与协议提供的总量核对,pi-ai 则保留其提供的总量。
+
+token-meter 拥有一份可安全用于浏览器的纯轮次局部 fold,并与其具备重试感知能力的累计用量投影共享记账所有权。`step/start` 与 `llm/retry-started` 打开真实 attempt;最终 assistant 消息替换同一 attempt 的流式样本;终止失败、重试与步骤边界关闭 attempt,且不会重复计数。每个已开始的 attempt 都必须以安全的非负整数用量和精确总量关闭。只有每个参与聚合的 attempt 都报告时,才会显示可选的 cache、推理与路由聚合值;推理仍是输出的子集。
+
+Web Chat 只选择已加载匹配窗口包含 `turn/start` 的 Turn,将该完整的持久事件窗口交给 token-meter fold,再渲染结果。完整且精确的结果通过现有 actions 上方、仅保留本地状态的 `DisclosureRow` 显示;证据不完整或矛盾时不显示该行。Chat 不拥有 token 记账状态机。
+
+## Alternatives considered
+
+**对相邻的会话累计值做减法。** 不采用,因为分页、压缩、重试覆盖范围与投影完整性可能让相邻值无法比较;减法会推断任何调用都未报告的数据。
+
+**通过新的客户端会话投影发布历史单轮次值。** 不采用,因为已加载的单轮次视图已经拥有所需的持久 attempt 事件,而随历史增长的投影会增加传输、持久化与版本成本。复用 token-meter 的纯 fold,可以在不新增 wire 值的前提下保持唯一记账所有方。
+
+**缺少精确总量时仍显示已知 bucket。** 不采用,因为在已完成轮次 footer 中展示的下界小计与完整账单无法区分。
+
+## Consequences
+
+新的提供方记录无需新增传输接口或持久化 UI 状态,即可显示精确的单轮次记账。证据不足的旧会话与适配器只会省略 disclosure。任一计费 attempt 缺少归属时,模型路由会整体消失,可信 token 总量仍可显示。
+
+定向的适配器、token-meter fold/投影、组件、分页与组装 Web 回放测试固定了总量保留、重试 attempt 分离、fail-closed 校验、可选字段省略、交互与完整窗口发布。累计投影与精确 Turn fold 现在同归 token-meter 所有;投影仍是完整日志的 bucket 视图,只有 fold 会作出 disclosure 所需的更严格精确性与完整性声明。

+ 29 - 1
apps/web/tests/turn-tail-actions.e2e.ts

@@ -27,6 +27,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // Two goldens for the same message: parked mid-turn, then settled.
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
+const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 // The recording must carry text in the SAME assistant message as the tool
@@ -156,7 +157,34 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
 
+  it.skipIf(MODE === 'record')('shows exact completed-Turn usage and expands its available facts', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-usage-expanded'))
+    const { settled } = await sendPrompt(120_000)
+    await settled
+
+    const disclosure = page.getByRole('button', { name: /Turn usage/ })
+    await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
+    expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
+    expect(await page.getByText('15.8K tok · Cache hit 49.7%', { exact: true }).count()).toBe(1)
+
+    await disclosure.click()
+    expect(await disclosure.getAttribute('aria-expanded')).toBe('true')
+    expect(await page.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('7,891 tok', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('7,808 tok', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('15,811 tok', { exact: true }).count()).toBe(1)
+
+    const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 120_000)
+
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'session.jsonl', 'settled.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md',
+    ])
   })
 })

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: cc8eaf8b49dc95568d34a4d57e9cbff8d30656c1
-module-graph.zh.md: 73ba9081455a265194aae943fb96efc0ec95d38f
+module-graph.md: b407080d634c0e70a00f494c686f55f85998046e
+module-graph.zh.md: 542ea6be5a1f1f41c5b39e4e83b2c49c97439332

+ 2 - 1
docs/module-graph.md

@@ -741,6 +741,7 @@ flowchart TD
   pkg_token_meter --> pkg_compaction
   pkg_token_meter --> pkg_invariants
   pkg_token_meter --> pkg_llm
+  pkg_token_meter --> pkg_llm_retry
   pkg_token_meter --> pkg_session
   pkg_token_meter --> pkg_session_projection
   pkg_agent_loop --> pkg_agent
@@ -1778,7 +1779,7 @@ flowchart TD
 | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) |
-| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 2 - 1
docs/module-graph.zh.md

@@ -743,6 +743,7 @@ flowchart TD
   pkg_token_meter --> pkg_compaction
   pkg_token_meter --> pkg_invariants
   pkg_token_meter --> pkg_llm
+  pkg_token_meter --> pkg_llm_retry
   pkg_token_meter --> pkg_session
   pkg_token_meter --> pkg_session_projection
   pkg_agent_loop --> pkg_agent
@@ -1780,7 +1781,7 @@ flowchart TD
 | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) |
-| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
+| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |

+ 2 - 2
docs/subsystems/llm-streaming.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/subsystems/llm-streaming.md
-llm-streaming.md: bdc830a5d387cde6967575551ec9b0a9b2626f46
-llm-streaming.zh.md: b602336bc06cd88a2634f5259eff117da3dcd986
+llm-streaming.md: 29efabd2b01659bdf2cc798ceadb4bb495e1731e
+llm-streaming.zh.md: 21ad56e526b9a507644b436b41ad063c5310b2ce

+ 9 - 1
docs/subsystems/llm-streaming.md

@@ -278,7 +278,7 @@ interface AppIdentity {
 
 ## `TokenUsage`
 
-Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again.
+Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. Optional `totalTokens` is an exact aggregate prompt-plus-output count preserved from the provider or reconstructed from authoritative aggregate counters; adapters omit it when unavailable or inconsistent. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again.
 
 ```ts type-equiv
 /**
@@ -292,6 +292,14 @@ Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached in
 interface TokenUsage {
   inputTokens: number
   outputTokens: number
+  /**
+   * Exact full-call total including aggregate prompt and output tokens.
+   *
+   * Adapters preserve a provider total or derive it from authoritative
+   * aggregate prompt/output counters; they omit it when unavailable or
+   * inconsistent.
+   */
+  totalTokens?: number
   cacheReadTokens?: number
   cacheWriteTokens?: number
   reasoningTokens?: number

+ 9 - 1
docs/subsystems/llm-streaming.zh.md

@@ -282,7 +282,7 @@ interface AppIdentity {
 
 ## `TokenUsage`
 
-逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。
+逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。可选的 `totalTokens` 是精确的提示词与输出聚合计数,由适配器保留提供方原值或从权威聚合计数重建;不可用或不一致时省略。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。
 
 ```ts type-equiv
 /**
@@ -296,6 +296,14 @@ interface AppIdentity {
 interface TokenUsage {
   inputTokens: number
   outputTokens: number
+  /**
+   * Exact full-call total including aggregate prompt and output tokens.
+   *
+   * Adapters preserve a provider total or derive it from authoritative
+   * aggregate prompt/output counters; they omit it when unavailable or
+   * inconsistent.
+   */
+  totalTokens?: number
   cacheReadTokens?: number
   cacheWriteTokens?: number
   reasoningTokens?: number

+ 3 - 3
packages/client/tsdown.client.ts

@@ -53,12 +53,12 @@ function styleInjectionModule(
 }
 
 /**
- * Wire/type layers a client bundle may inline: browser-safe contracts
- * with no runtime identity to share (no Symbol/instanceof/singleton state).
+ * Contract layers and pure folds a client bundle may inline: browser-safe
+ * values with no runtime identity to share (no Symbol/instanceof/singleton state).
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: ef9dc65de0d6b990fd0066c387518dc932bd4d2e
-README.zh.md: c4de06b18077485d7d65734b9bb38ff7745a4d67
+README.md: cc79de10289069ef94105397bd77a5194b4e6808
+README.zh.md: 3d4eb91492a497ff4544bd6378ae212810342c64

+ 1 - 0
packages/client/ui-chat/README.md

@@ -19,3 +19,4 @@ None; Chat presentation does not assemble or mutate provider requests.
 ## Known Limitations and Deferred Work
 
 - **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page.
+- **Per-Turn token usage is fail-closed** — a completed Turn shows its disclosure only when the loaded window includes `turn/start` and every started model attempt has safe, exact usage. Missing buckets are omitted, and incomplete or contradictory accounting hides the whole disclosure.

+ 1 - 0
packages/client/ui-chat/README.zh.md

@@ -19,3 +19,4 @@ Chat 会为非空的初始或恢复请求、显式序列起点,或 system 字
 ## 已知限制与暂缓事项
 
 - **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。
+- **单轮次 token 用量采用 fail-closed 方式**——只有已加载窗口包含 `turn/start`,且每个已开始的模型 attempt 都具有安全、精确的用量时,已完成轮次才显示 disclosure。缺失的 bucket 会被省略,记账不完整或矛盾时则隐藏整条 disclosure。

+ 17 - 26
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -13,7 +13,6 @@ import { formatRunDuration } from './message-chrome.ts'
 import css from './ChatView.module.css'
 
 const FOLLOW_THRESHOLD = 24
-const MAX_PAGING_ANCHOR_PROBES = 64
 
 /** Active column host when present; otherwise the view-local scroller. */
 function scrollerOf(from: HTMLElement): HTMLElement {
@@ -46,38 +45,30 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
   const viewport = scrollport.getBoundingClientRect()
   const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
   const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
-  // Scroll events are hot: walk down one hit-test line and stop at the first
-  // hit row with layout before considering the full mounted set. Starting at the
-  // viewport edge preserves the reader's leading row when a later row is
-  // inserted between already-visible messages. The fallback keeps jsdom and
-  // pre-layout states deterministic; a virtualizer naturally bounds it.
+  // The leading edge preserves nested call identity when it hits a row.
+  // Chrome/gap misses use logarithmic layout reads over the ordered flex rows.
   if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
     const content = list.getBoundingClientRect()
     const left = Math.max(viewport.left, content.left)
     const right = Math.min(viewport.right, content.right)
     const x = left + Math.max(0, right - left) / 2
-    const height = visibleBottom - viewport.top
-    let probes = 0
-    for (
-      let offset = 1;
-      offset < height && probes < MAX_PAGING_ANCHOR_PROBES;
-      offset = offset === 1 ? 16 : offset + 16
-    ) {
-      probes++
-      for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
-        const row = element instanceof HTMLElement
-          ? element.closest<HTMLElement>('[data-chat-anchor-key]')
-          : null
-        if (row !== null && list.contains(row)) return row
-      }
+    for (const element of document.elementsFromPoint(x, viewport.top + 1)) {
+      const row = element instanceof HTMLElement
+        ? element.closest<HTMLElement>('[data-chat-anchor-key]')
+        : null
+      if (row !== null && list.contains(row)) return row
     }
   }
-  const rows = [...list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
-  const visibleRows = rows.filter((row) => {
-    const rect = row.getBoundingClientRect()
-    return rect.bottom > viewport.top && rect.top < visibleBottom
-  })
-  return visibleRows[0] ?? rows[0] ?? null
+  const rows = list.querySelectorAll<HTMLElement>('[data-chat-flow] > [data-chat-flow-key]:not(:empty)')
+  let low = 0
+  let high = rows.length
+  while (low < high) {
+    const middle = (low + high) >>> 1
+    if (rows.item(middle).getBoundingClientRect().bottom > viewport.top) high = middle
+    else low = middle + 1
+  }
+  const row = rows[low]
+  return row !== undefined && row.getBoundingClientRect().top < visibleBottom ? row : rows[0] ?? null
 }
 
 type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>

+ 2 - 62
packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -13,6 +13,7 @@ import type { ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatSnapshot } from '../contract/snapshot.ts'
 import { formatTokensPerSecond } from './message-chrome.ts'
 import { assistantStepReading } from '../contract/turn-metrics.ts'
+import { formatCacheHitPercent, formatTokens } from './token-format.ts'
 import css from './StatsLine.module.css'
 
 interface WindowStats {
@@ -77,19 +78,6 @@ export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats
   return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
 }
 
-/**
- * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
- * @param n - token count.
- * @returns display string.
- */
-export function formatTokens(n: number, t: ChatViewSlotProps['t']): string {
-  const scaled = (v: number): string =>
-    v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
-  if (n < 1_000) return String(n)
-  if (n < 1_000_000) return t('number.thousand', { value: scaled(n / 1_000) })
-  return t('number.million', { value: scaled(n / 1_000_000) })
-}
-
 /**
  * Compact duration: 45.2s under a minute, 2m42s from there on.
  * @param ms - duration in milliseconds.
@@ -105,26 +93,6 @@ export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
   })
 }
 
-/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */
-function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
-  const denominatorQuotient = Math.floor(denominator / 200)
-  const denominatorRemainder = denominator % 200
-  let lower = 0
-  let upper = 100
-  while (lower < upper) {
-    const candidate = Math.floor((lower + upper + 1) / 2)
-    const factor = candidate * 2 - 1
-    const threshold = factor * denominatorQuotient
-      + Math.ceil(factor * denominatorRemainder / 200)
-    if (cacheReadTokens >= threshold) {
-      lower = candidate
-    } else {
-      upper = candidate - 1
-    }
-  }
-  return lower
-}
-
 /**
  * Display-ready cache-hit share of prompt-side input over the whole durable log.
  * @param usage - the session's token-usage projection value.
@@ -134,35 +102,7 @@ function roundedIntegerPercent(cacheReadTokens: number, denominator: number): nu
  */
 export function cacheHitPercent(usage: TokenUsageProjection): string | null {
   const denominator = billedInputTokens(usage)
-  if (denominator === 0) return null
-  const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens
-  if (missedInputTokens === 0) return '100'
-
-  const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator)
-  if (integerPercent < 100) return String(integerPercent)
-
-  // At the first distinguishing precision, the rounded result is 100 minus
-  // one to five units in the final decimal place. Scale only while the next
-  // multiplication remains at or below the denominator, then derive that
-  // final digit through exact small-factor comparisons.
-  let decimalPlaces = 1
-  let scaledDoubleGap = missedInputTokens * 200
-  const denominatorTens = Math.floor(denominator / 10)
-  while (scaledDoubleGap <= denominatorTens) {
-    scaledDoubleGap *= 10
-    decimalPlaces += 1
-  }
-  const denominatorOnes = denominator % 10
-  let roundedLoss = 5
-  for (let loss = 1; loss < 5; loss += 1) {
-    const factor = loss * 2 + 1
-    const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
-    if (scaledDoubleGap <= threshold) {
-      roundedLoss = loss
-      break
-    }
-  }
-  return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
+  return formatCacheHitPercent(usage.cacheReadTokens, denominator)
 }
 
 /**

+ 7 - 0
packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css

@@ -4,6 +4,13 @@
   gap: 16px;
 }
 
+.footer {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
 .actions {
   margin-left: -6px;
 }

+ 17 - 13
packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx

@@ -2,6 +2,7 @@ import { memo } from 'react'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import { MessageIconActions } from './MessageIconActions.tsx'
+import { TurnUsageDisclosure } from './TurnUsageDisclosure.tsx'
 import { assistantText } from './turn-assistant.ts'
 import css from './TurnTailNodeView.module.css'
 
@@ -35,19 +36,22 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
   return (
     <div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
       {tail}
-      <MessageIconActions
-        text={assistantText(closing.blocks)}
-        time={closing.time}
-        runMs={runMs}
-        ttftMs={data.ttftMs}
-        tokensPerSecond={data.tokensPerSecond}
-        clock="end"
-        onBranch={() => { forkAt(closing.finalNode.seq) }}
-        branchUnavailable={data.branchUnavailable || hasLaterChatNode}
-        className={css.actions}
-        extraActions={assistantActions}
-        t={t}
-      />
+      <div className={css.footer}>
+        {data.tokenUsage === undefined ? null : <TurnUsageDisclosure usage={data.tokenUsage} t={t} />}
+        <MessageIconActions
+          text={assistantText(closing.blocks)}
+          time={closing.time}
+          runMs={runMs}
+          ttftMs={data.ttftMs}
+          tokensPerSecond={data.tokensPerSecond}
+          clock="end"
+          onBranch={() => { forkAt(closing.finalNode.seq) }}
+          branchUnavailable={data.branchUnavailable || hasLaterChatNode}
+          className={css.actions}
+          extraActions={assistantActions}
+          t={t}
+        />
+      </div>
     </div>
   )
 })

+ 87 - 0
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css

@@ -0,0 +1,87 @@
+.root {
+  min-width: 0;
+}
+
+.root[data-open] {
+  padding-bottom: 4px;
+}
+
+.root [data-disclosure-row]:focus-visible {
+  border-radius: 6px;
+  outline: 2px solid var(--dsw-alias-label-tertiary);
+  outline-offset: -2px;
+}
+
+.chevron {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.separator {
+  flex: none;
+  width: 2px;
+  height: 2px;
+  margin: 0 8px;
+  border-radius: 1px;
+  background: var(--dsw-alias-label-caption);
+}
+
+.summary {
+  min-width: 0;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 14px;
+  font-variant-numeric: tabular-nums;
+  line-height: 24px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.details {
+  display: grid;
+  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
+  gap: 6px 16px;
+  box-sizing: border-box;
+  width: calc(100% - 22px);
+  margin: 4px 0 0 22px;
+  padding: 10px 16px 12px 12px;
+  border-radius: 8px;
+  background: var(--dsw-alias-markdown-code-block);
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.details dt,
+.details dd {
+  min-width: 0;
+  margin: 0;
+}
+
+.details dd {
+  color: var(--dsw-alias-label-secondary);
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.details .route {
+  overflow-wrap: anywhere;
+}
+
+.reasoning {
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}
+
+.totalLabel,
+.details .totalValue {
+  padding-top: 6px;
+  border-top: 1px solid var(--dsw-alias-separator-primary);
+  color: var(--dsw-alias-label-primary);
+}
+
+@media (max-width: 480px) {
+  .details {
+    grid-template-columns: minmax(72px, auto) minmax(0, 1fr);
+    gap-inline: 10px;
+  }
+}

+ 86 - 0
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx

@@ -0,0 +1,86 @@
+import { useState } from 'react'
+import { DisclosureRow, IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
+import css from './TurnUsageDisclosure.module.css'
+
+export interface TurnUsageDisclosureProps {
+  usage: TurnTokenUsage
+  t: ChatViewSlotProps['t']
+}
+
+function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
+  return t('message.turnUsage.count', { count: formatTokens(value, t) })
+}
+
+function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
+  return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
+}
+
+/** Compact per-Turn usage summary with an opt-in bucket breakdown. */
+export function TurnUsageDisclosure({ usage, t }: TurnUsageDisclosureProps) {
+  const [open, setOpen] = useState(false)
+  const cacheHit = usage.cacheReadTokens === undefined
+    ? null
+    : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
+  const total = formatCompactCount(usage.totalTokens, t)
+  const summary = cacheHit === null
+    ? total
+    : t('message.turnUsage.summaryWithCache', { total, percent: cacheHit })
+  const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
+
+  return (
+    <DisclosureRow
+      icon={<IconDataOutline16 />}
+      title={t('message.turnUsage.title')}
+      open={open}
+      expandable
+      onToggle={() => { setOpen(value => !value) }}
+      expandOnRowClick
+      keepContentWhenOpen
+      collapsedContent={(
+        <>
+          <span className={css.separator} aria-hidden />
+          <span className={css.summary}>{summary}</span>
+        </>
+      )}
+      className={css.root}
+      chevronClassName={css.chevron}
+    >
+      <dl className={css.details} data-turn-usage-details>
+        {routes !== '' && (
+          <>
+            <dt>{t('message.turnUsage.model')}</dt>
+            <dd className={css.route}>{routes}</dd>
+          </>
+        )}
+        <dt>{t('message.turnUsage.input')}</dt>
+        <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
+        {usage.cacheReadTokens !== undefined && (
+          <>
+            <dt>{t('message.turnUsage.cacheRead')}</dt>
+            <dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
+          </>
+        )}
+        {usage.cacheWriteTokens !== undefined && (
+          <>
+            <dt>{t('message.turnUsage.cacheWrite')}</dt>
+            <dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
+          </>
+        )}
+        <dt>{t('message.turnUsage.output')}</dt>
+        <dd>
+          {formatExactCount(usage.outputTokens, t)}
+          {usage.reasoningTokens !== undefined && (
+            <span className={css.reasoning}>
+              {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
+            </span>
+          )}
+        </dd>
+        <dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
+        <dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
+      </dl>
+    </DisclosureRow>
+  )
+}

+ 98 - 0
packages/client/ui-chat/src/client/chat/token-format.ts

@@ -0,0 +1,98 @@
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+
+/**
+ * Compact token count: 517 / 12.2K / 517K / 1.2M.
+ * @param value - non-negative token count.
+ * @param t - Chat locale seat.
+ * @returns locale-owned compact display string.
+ */
+export function formatTokens(value: number, t: ChatViewSlotProps['t']): string {
+  const scaled = (candidate: number): string =>
+    candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10)
+  if (value < 1_000) return String(value)
+  if (value < 1_000_000) return t('number.thousand', { value: scaled(value / 1_000) })
+  return t('number.million', { value: scaled(value / 1_000_000) })
+}
+
+/**
+ * Exact integer token count with locale-owned digit grouping.
+ * @param value - non-negative safe integer token count.
+ * @param t - Chat locale seat.
+ * @returns an unrounded display string.
+ */
+export function formatExactTokens(value: number, t: ChatViewSlotProps['t']): string {
+  const digits = String(value)
+  const groups: string[] = []
+  for (let end = digits.length; end > 0; end -= 3) {
+    groups.unshift(digits.slice(Math.max(0, end - 3), end))
+  }
+  return groups.join(t('number.groupSeparator'))
+}
+
+/** Round a cache-read ratio to exact percentage units, with positive ties rounded up. */
+function roundedPercentUnits(cacheReadTokens: number, denominator: number, decimalPlaces: 0 | 1): number {
+  const unitsPerPercent = decimalPlaces === 0 ? 1 : 10
+  const scale = unitsPerPercent * 100
+  const doubledScale = scale * 2
+  const denominatorQuotient = Math.floor(denominator / doubledScale)
+  const denominatorRemainder = denominator % doubledScale
+  let lower = 0
+  let upper = scale
+  while (lower < upper) {
+    const candidate = Math.floor((lower + upper + 1) / 2)
+    const factor = candidate * 2 - 1
+    const threshold = factor * denominatorQuotient
+      + Math.ceil(factor * denominatorRemainder / doubledScale)
+    if (cacheReadTokens >= threshold) lower = candidate
+    else upper = candidate - 1
+  }
+  return lower
+}
+
+function displayPercentUnits(units: number, decimalPlaces: 0 | 1): string {
+  if (decimalPlaces === 0) return String(units)
+  const whole = Math.floor(units / 10)
+  const tenths = units % 10
+  return tenths === 0 ? String(whole) : `${whole}.${tenths}`
+}
+
+/**
+ * Display-ready cache-hit share without rounding a partial hit to 100%.
+ * @param cacheReadTokens - exact prompt tokens served from cache.
+ * @param promptTokens - exact aggregate prompt tokens.
+ * @param decimalPlaces - ordinary-ratio precision; partial hits that would
+ * round to 100 automatically use enough additional precision to stay honest.
+ * @returns percentage text, or null when there was no prompt input.
+ */
+export function formatCacheHitPercent(
+  cacheReadTokens: number,
+  promptTokens: number,
+  decimalPlaces: 0 | 1 = 0,
+): string | null {
+  if (promptTokens === 0) return null
+  const missedInputTokens = promptTokens - cacheReadTokens
+  if (missedInputTokens === 0) return '100'
+
+  const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces)
+  const fullHitUnits = decimalPlaces === 0 ? 100 : 1_000
+  if (roundedUnits < fullHitUnits) return displayPercentUnits(roundedUnits, decimalPlaces)
+
+  let distinguishingPlaces = 1
+  let scaledDoubleGap = missedInputTokens * 200
+  const denominatorTens = Math.floor(promptTokens / 10)
+  while (scaledDoubleGap <= denominatorTens) {
+    scaledDoubleGap *= 10
+    distinguishingPlaces += 1
+  }
+  const denominatorOnes = promptTokens % 10
+  let roundedLoss = 5
+  for (let loss = 1; loss < 5; loss += 1) {
+    const factor = loss * 2 + 1
+    const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
+    if (scaledDoubleGap <= threshold) {
+      roundedLoss = loss
+      break
+    }
+  }
+  return `99.${'9'.repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`
+}

+ 25 - 0
packages/client/ui-chat/src/client/contract/chat-nodes.ts

@@ -59,6 +59,29 @@ export interface RetryChatData {
   readonly current: ModelRetryNode
 }
 
+/** One provider/model route that contributed a billed request attempt. */
+export interface TurnTokenUsageRoute {
+  readonly provider: string
+  readonly model: string
+}
+
+/** Exact provider-reported token accounting for every attempt in one completed Turn. */
+export interface TurnTokenUsage {
+  /** Sum of uncached prompt input across all attempts. */
+  readonly uncachedInputTokens: number
+  readonly outputTokens: number
+  /** Exact aggregate prompt plus output total across all attempts. */
+  readonly totalTokens: number
+  /** Present only when every attempt reported the bucket. */
+  readonly cacheReadTokens?: number
+  /** Present only when every attempt reported the bucket. */
+  readonly cacheWriteTokens?: number
+  /** Output subset, present only when every attempt reported it. */
+  readonly reasoningTokens?: number
+  /** Present only when every billed attempt has provider/model attribution. */
+  readonly routes?: readonly TurnTokenUsageRoute[]
+}
+
 /** Turn-local footer row that owns actions and optional feature contributions. */
 export interface TurnTailChatData {
   readonly turn: number
@@ -70,6 +93,8 @@ export interface TurnTailChatData {
   readonly branchUnavailable: boolean
   readonly ttftMs?: number
   readonly tokensPerSecond?: number
+  /** Exact per-Turn accounting; absent when the loaded evidence is incomplete. */
+  readonly tokenUsage?: TurnTokenUsage
 }
 
 /**

+ 9 - 1
packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts

@@ -4,6 +4,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
 import type {
   AssistantChatData, FinalAssistantChatData, TurnTailChatData,
 } from '../contract/chat-nodes.ts'
@@ -57,10 +58,13 @@ function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[
 } | undefined {
   if (event.type === 'assistant/message'
     || event.type === 'assistant/chunk'
+    || event.type === 'step/start'
     || event.type === 'step/end') {
     return { turn: event.data.turn, step: event.data.step }
   }
-  if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
+  if (event.type === 'llm/retry' || event.type === 'llm/retry-started') {
+    return { turn: event.data.turn, step: event.data.step }
+  }
   return undefined
 }
 
@@ -138,6 +142,9 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
     }
   }
   const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn)
+  const tokenUsage = context.start?.event.type === 'turn/start'
+    ? deriveTurnTokenUsage(context.matches.map(match => match.event))
+    : undefined
   return {
     turn: end.event.data.turn,
     seq: end.event.seq,
@@ -146,6 +153,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
     branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq,
     ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
     ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
+    ...tokenUsage === undefined ? {} : { tokenUsage },
   }
 }
 

+ 22 - 0
packages/client/ui-chat/src/client/locale.ts

@@ -6,6 +6,7 @@ export const NS = 'chat'
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
   'view.chat': '对话',
+  'number.groupSeparator': ',',
   'duration.compactSeconds': '{seconds}秒',
   'duration.compactMinutes': '{minutes}分{seconds}秒',
   'duration.milliseconds': '{milliseconds}毫秒',
@@ -74,6 +75,16 @@ export const zh = {
   'message.ranFor': '用时 {duration}',
   'message.ttft': '首 token {seconds}秒',
   'message.tokensPerSecond': '{tps} tok/s',
+  'message.turnUsage.title': '本轮用量',
+  'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%',
+  'message.turnUsage.model': '提供方 / 模型',
+  'message.turnUsage.input': '未缓存输入',
+  'message.turnUsage.cacheRead': '缓存读取',
+  'message.turnUsage.cacheWrite': '缓存写入',
+  'message.turnUsage.output': '输出',
+  'message.turnUsage.reasoning': '(其中推理 {tokens})',
+  'message.turnUsage.total': '总计',
+  'message.turnUsage.count': '{count} tok',
   'duration.seconds': '{seconds}秒',
   'duration.minutes': '{minutes}分{seconds}秒',
   'command.running': '执行中…',
@@ -93,6 +104,7 @@ export type ChatKey = keyof typeof zh
 /** English dictionary, checked against the Chinese key set. */
 export const en = {
   'view.chat': 'Chat',
+  'number.groupSeparator': ',',
   'duration.compactSeconds': '{seconds}s',
   'duration.compactMinutes': '{minutes}m{seconds}s',
   'duration.milliseconds': '{milliseconds}ms',
@@ -161,6 +173,16 @@ export const en = {
   'message.ranFor': 'Ran for {duration}',
   'message.ttft': 'TTFT {seconds}s',
   'message.tokensPerSecond': '{tps} tok/s',
+  'message.turnUsage.title': 'Turn usage',
+  'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%',
+  'message.turnUsage.model': 'Provider / model',
+  'message.turnUsage.input': 'Uncached input',
+  'message.turnUsage.cacheRead': 'Cached input',
+  'message.turnUsage.cacheWrite': 'Cache write',
+  'message.turnUsage.output': 'Output',
+  'message.turnUsage.reasoning': ' ({tokens} reasoning)',
+  'message.turnUsage.total': 'Total',
+  'message.turnUsage.count': '{count} tok',
   'duration.seconds': '{seconds}s',
   'duration.minutes': '{minutes}m {seconds}s',
   'command.running': 'Running…',

+ 2 - 1
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -9,7 +9,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
+import { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
+import { formatTokens } from '../src/client/chat/token-format.ts'
 import { en, zh } from '../src/client/locale.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 

+ 55 - 1
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -474,7 +474,7 @@ describe('ChatView', () => {
 
       readerScroll(scroller, 100)
 
-      expect(hitTest).toHaveBeenCalledTimes(64)
+      expect(hitTest).toHaveBeenCalledTimes(1)
       expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1')
     } finally {
       if (originalHitTest !== undefined) {
@@ -485,6 +485,60 @@ describe('ChatView', () => {
     }
   })
 
+  it('falls back to the first visible row when the viewport top hit-test misses', () => {
+    const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
+    const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`))
+    const h = makeHarness(
+      { nodes },
+      { hasMore: true },
+    )
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
+    let prepended = false
+    let rowRectCalls = 0
+    vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
+      () => ({ top: 0, bottom: 200 } as DOMRect),
+    )
+    rows.forEach((row, index) => {
+      vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => {
+        rowRectCalls += 1
+        const shift = prepended ? (index === 8 ? 400 : 500) : 0
+        const top = 20 + (index - 8) * 60 + shift
+        return { top, bottom: top + 40 } as DOMRect
+      })
+    })
+    Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
+    readerScroll(scroller, 50)
+
+    const hitTest = vi.fn((_x: number, _y: number): Element[] => [])
+    Object.defineProperty(document, 'elementsFromPoint', {
+      configurable: true,
+      value: hitTest,
+    })
+    try {
+      rowRectCalls = 0
+      fireEvent.click(view.getByText('加载更早'))
+      expect(hitTest).toHaveBeenCalledTimes(1)
+      expect(hitTest.mock.calls[0]?.[1]).toBe(1)
+      expect(rowRectCalls).toBeLessThanOrEqual(6)
+
+      Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
+      prepended = true
+      act(() => {
+        h.setChat({ nodes: [assistant(2, 'older'), ...nodes] })
+      })
+      expect(scroller.scrollTop).toBe(450) // reader offset 50 + first visible row's 400px shift
+    } finally {
+      if (originalHitTest !== undefined) {
+        Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
+      } else {
+        Reflect.deleteProperty(document, 'elementsFromPoint')
+      }
+    }
+  })
+
   it('renders the fixture main line as independently keyed business nodes', () => {
     const h = makeHarness({
       nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],

+ 38 - 0
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -500,6 +500,44 @@ describe('built-in conversation node Definitions', () => {
     expect(tail.branchUnavailable).toBe(true)
   })
 
+  it('publishes exact Turn usage only after pagination supplies the full lifecycle window', () => {
+    const value = assembler([
+      at(3, 'assistant/message', {
+        turn: 1,
+        step: 1,
+        message: assistantMessage('usage-assistant', 'done'),
+        usage: {
+          inputTokens: 10,
+          outputTokens: 4,
+          totalTokens: 17,
+          cacheReadTokens: 2,
+          cacheWriteTokens: 1,
+          reasoningTokens: 1,
+        },
+      }, { surfaceOp: 'append' }),
+      at(4, 'step/end', { turn: 1, step: 1 }),
+      at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ], true)
+
+    expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toBeUndefined()
+
+    value.prepend([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+    ], false)
+    value.flush()
+
+    expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toEqual({
+      uncachedInputTokens: 10,
+      outputTokens: 4,
+      totalTokens: 17,
+      cacheReadTokens: 2,
+      cacheWriteTokens: 1,
+      reasoningTokens: 1,
+      routes: [{ provider: 'fake', model: 'fake' }],
+    })
+  })
+
   it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
     const value = assembler([
       at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),

+ 5 - 0
packages/client/ui-chat/tests/turn-metrics.client.spec.ts

@@ -6,6 +6,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
 import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
+import { formatCacheHitPercent } from '../src/client/chat/token-format.ts'
 
 interface StepSpec {
   seq: number
@@ -139,6 +140,10 @@ describe('deriveTurnMetrics', () => {
 })
 
 describe('footer figure formatters', () => {
+  it('omits a redundant decimal zero in cache-hit percentages', () => {
+    expect(formatCacheHitPercent(1, 2, 1)).toBe('50')
+  })
+
   it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
     expect(formatLatencySeconds(840)).toBe('0.8')
     expect(formatLatencySeconds(1_000)).toBe('1')

+ 76 - 0
packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx

@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
+import { TurnUsageDisclosure } from '../src/client/chat/TurnUsageDisclosure.tsx'
+import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
+import { en } from '../src/client/locale.ts'
+
+const t = makeTranslate(en, commonEn)
+
+afterEach(cleanup)
+
+describe('TurnUsageDisclosure', () => {
+  it('shows the exact compact summary and expands into provider facts', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 5_060,
+      cacheReadTokens: 4_940,
+      cacheWriteTokens: 0,
+      outputTokens: 5_800,
+      reasoningTokens: 42,
+      totalTokens: 15_800,
+      routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
+    }
+    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
+
+    expect(view.getByText('15.8K tok · Cache hit 49.4%')).toBeTruthy()
+    expect(view.queryByRole('definition')).toBeNull()
+
+    fireEvent.click(view.getByRole('button'))
+    const details = view.container.querySelector('[data-turn-usage-details]') as HTMLElement
+    expect(details).toBeTruthy()
+    expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
+    expect(details.textContent).toContain('Uncached input5,060 tok')
+    expect(details.textContent).toContain('Cached input4,940 tok')
+    expect(details.textContent).toContain('Cache write0 tok')
+    expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)')
+    expect(details.textContent).toContain('Total15,800 tok')
+  })
+
+  it('omits unavailable optional facts instead of inventing values', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 120,
+      outputTokens: 30,
+      totalTokens: 150,
+    }
+    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
+
+    expect(view.getByText('150 tok')).toBeTruthy()
+    expect(view.queryByText(/Cache hit/)).toBeNull()
+    fireEvent.click(view.getByRole('button'))
+    expect(view.queryByText('Provider / model')).toBeNull()
+    expect(view.queryByText('Cached input')).toBeNull()
+    expect(view.queryByText('Cache write')).toBeNull()
+    expect(view.queryByText(/reasoning/)).toBeNull()
+  })
+
+  it('keeps a partial cache hit below 100 and supports keyboard toggling', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 1,
+      cacheReadTokens: 999,
+      outputTokens: 100,
+      totalTokens: 1_100,
+    }
+    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
+    expect(view.getByText('1.1K tok · Cache hit 99.9%')).toBeTruthy()
+
+    const disclosure = view.getByRole('button')
+    expect(disclosure.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(disclosure, { key: ' ' })
+    expect(disclosure.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.keyDown(disclosure, { key: 'Enter' })
+    expect(disclosure.getAttribute('aria-expanded')).toBe('false')
+  })
+})

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -5243,7 +5243,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'TokenUsage',
-    declaration: 'export interface TokenUsage {\n    inputTokens: number;\n    outputTokens: number;\n    cacheReadTokens?: number;\n    cacheWriteTokens?: number;\n    reasoningTokens?: number;\n}',
+    declaration: 'export interface TokenUsage {\n    inputTokens: number;\n    outputTokens: number;\n    totalTokens?: number;\n    cacheReadTokens?: number;\n    cacheWriteTokens?: number;\n    reasoningTokens?: number;\n}',
   },
   {
     name: 'ToolCallKind',

+ 2 - 2
packages/llm/llm-deepseek/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/llm-deepseek/README.md
-README.md: 7433bb75104506ec2409c659f3d30058abc6f9a4
-README.zh.md: 7dcdfeac17b0bfca70a293760061182292edb531
+README.md: 11ee4c775c6565e0842707928683587a1e2f1eb8
+README.zh.md: 86da6c75891d7e458b870b630db877c799c33127

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -103,7 +103,7 @@ DeepSeek request identity is separate from app attribution. After credential res
 - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
 - **Reasoning passback rule**: every assistant turn that carried reasoning serializes `reasoning_content` back in history. Thinking mode requires it on tool-call turns; DeepSeek ignores it elsewhere, while a gateway re-encoding the conversation for another vendor recovers that turn's upstream thinking signature by hashing the replayed text.
 - Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`.
-- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
+- Token accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
 
 ## Errors
 

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -103,7 +103,7 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提
 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。
 - **推理回传规则**:每个携带推理内容的 assistant 轮次都会将 `reasoning_content` 序列化回历史。思考模式在工具调用轮次上必需它;DeepSeek 在其他轮次上会忽略它,而将该对话重新编码转发给其他厂商的网关,要靠对回传原文取哈希来恢复该轮次上游的思考签名。
 - 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。
-- Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。
+- Token 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 聚合值;提供的 `total_tokens` 若不一致,则省略该字段。
 
 ## 错误
 

+ 10 - 1
packages/llm/llm-deepseek/src/translate.ts

@@ -48,14 +48,23 @@ export function mapFinishReason(reason: string): FinishReason {
  * api/create-chat-completion); the harness TokenUsage convention is
  * DISJOINT counts, so cache reads are subtracted out of `inputTokens`.
  * @param usage - wire usage from the finish chunk or the trailing usage-only chunk.
- * @returns disjoint harness counts; cache/reasoning fields present only when the wire reported them.
+ * @returns disjoint harness counts; an exact total is present only when the
+ *   aggregate prompt/completion counters are valid and agree with any wire total.
  */
 export function mapUsage(usage: WireUsage): TokenUsage {
   const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens
   const reasoning = usage.completion_tokens_details?.reasoning_tokens
+  const combined = usage.prompt_tokens + usage.completion_tokens
+  const hasExactTotal = Number.isSafeInteger(usage.prompt_tokens)
+    && usage.prompt_tokens >= 0
+    && Number.isSafeInteger(usage.completion_tokens)
+    && usage.completion_tokens >= 0
+    && Number.isSafeInteger(combined)
+    && (usage.total_tokens === undefined || usage.total_tokens === combined)
   return {
     inputTokens: usage.prompt_tokens - (cacheRead ?? 0),
     outputTokens: usage.completion_tokens,
+    ...hasExactTotal ? { totalTokens: combined } : {},
     ...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {},
     ...reasoning !== undefined ? { reasoningTokens: reasoning } : {},
   }

+ 2 - 0
packages/llm/llm-deepseek/src/types.ts

@@ -166,6 +166,8 @@ export interface WireToolCallDelta {
 export interface WireUsage {
   prompt_tokens: number
   completion_tokens: number
+  /** Provider-reported aggregate across prompt and completion tokens. */
+  total_tokens?: number
   prompt_cache_hit_tokens?: number
   prompt_cache_miss_tokens?: number
   prompt_tokens_details?: { cached_tokens?: number }

+ 1 - 1
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -318,7 +318,7 @@ describe('DeepSeekAdapter against a mock server', () => {
     })
     expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
     expect(result.finish).toEqual({ kind: 'stop' })
-    expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
+    expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 })
 
     // The wire request carried the auth header contents we configured.
     expect(server.requests[0]).toMatchObject({

+ 24 - 8
packages/llm/llm-deepseek/tests/translate.spec.ts

@@ -33,7 +33,7 @@ describe('translate: text', () => {
       { type: 'text-delta', index: 0, text: 'Hel' },
       { type: 'text-delta', index: 0, text: 'lo' },
       { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } },
-      { type: 'usage', usage: { inputTokens: 5, outputTokens: 2 } },
+      { type: 'usage', usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } },
       { type: 'finish', reason: { kind: 'stop' } },
     ])
   })
@@ -118,7 +118,7 @@ describe('translate: tool calls', () => {
         index: 0,
         block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' },
       },
-      { type: 'usage', usage: { inputTokens: 28, outputTokens: 6 } },
+      { type: 'usage', usage: { inputTokens: 28, outputTokens: 6, totalTokens: 34 } },
       { type: 'finish', reason: { kind: 'tool-calls' } },
     ])
   })
@@ -172,7 +172,7 @@ describe('translate: finish and usage handling', () => {
       { choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } },
       DONE,
     )))
-    expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1 } })
+    expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1, totalTokens: 10 } })
     expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
   })
 
@@ -184,7 +184,7 @@ describe('translate: finish and usage handling', () => {
       DONE,
     )))
     const usage = chunks.find(chunk => chunk.type === 'usage')
-    expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2 } })
+    expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 } })
   })
 
   it('defaults to finish stop when no finish_reason ever arrives', async () => {
@@ -219,7 +219,7 @@ describe('translate: finish and usage handling', () => {
       DONE,
     )))
     expect(chunks).toEqual([
-      { type: 'usage', usage: { inputTokens: 7, outputTokens: 0 } },
+      { type: 'usage', usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 } },
       {
         type: 'finish',
         reason: {
@@ -286,6 +286,7 @@ describe('mapUsage', () => {
     expect(mapUsage({
       prompt_tokens: 283,
       completion_tokens: 69,
+      total_tokens: 352,
       prompt_cache_hit_tokens: 256,
       prompt_cache_miss_tokens: 27,
       prompt_tokens_details: { cached_tokens: 256 },
@@ -295,6 +296,7 @@ describe('mapUsage', () => {
       // (TokenUsage counts are disjoint).
       inputTokens: 27,
       outputTokens: 69,
+      totalTokens: 352,
       cacheReadTokens: 256,
       reasoningTokens: 24,
     })
@@ -302,12 +304,26 @@ describe('mapUsage', () => {
 
   it('falls back to prompt_cache_hit_tokens when details are absent', () => {
     expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 }))
-      .toEqual({ inputTokens: 2, outputTokens: 2, cacheReadTokens: 8 })
+      .toEqual({ inputTokens: 2, outputTokens: 2, totalTokens: 12, cacheReadTokens: 8 })
   })
 
-  it('omits optional fields when the wire omits them', () => {
+  it('reconstructs an exact total when the wire omits it', () => {
     expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 }))
-      .toEqual({ inputTokens: 10, outputTokens: 2 })
+      .toEqual({ inputTokens: 10, outputTokens: 2, totalTokens: 12 })
+  })
+
+  it.each([
+    ['contradictory total', { prompt_tokens: 10, completion_tokens: 2, total_tokens: 99 }],
+    ['negative prompt', { prompt_tokens: -1, completion_tokens: 2 }],
+    ['fractional prompt', { prompt_tokens: 1.5, completion_tokens: 2 }],
+    ['negative completion', { prompt_tokens: 2, completion_tokens: -1 }],
+    ['fractional completion', { prompt_tokens: 2, completion_tokens: 1.5 }],
+    ['unsafe aggregate', { prompt_tokens: Number.MAX_SAFE_INTEGER, completion_tokens: 1 }],
+  ])('omits the exact total for %s without changing existing buckets', (_name, wire) => {
+    expect(mapUsage(wire)).toEqual({
+      inputTokens: wire.prompt_tokens,
+      outputTokens: wire.completion_tokens,
+    })
   })
 })
 

+ 2 - 2
packages/llm/llm-pi-ai/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/llm-pi-ai/README.md
-README.md: 31e40e5f0fa3c1e7e0ae0df05aa0a76d54d120b0
-README.zh.md: cd40804ce5908aebd0c35011ad1d56879834164d
+README.md: dc17ec8be163d4c4d2b991afe53fdb15e455b61d
+README.zh.md: 007c9606cbf921c0f4d490ca7a5ef23713af87b2

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -155,7 +155,7 @@ Durable content is the authoritative record; replay state only restores native f
 
 - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output.
 - pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message.
-- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map.
+- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. Its exact `totalTokens` value is preserved unchanged.
 - pi-ai's `off` thinking level crosses the Harness capability seam unchanged and becomes an omitted pi-ai common `reasoning` option at dispatch.
 - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers.
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -156,7 +156,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示
 
 - pi-ai 工具调用参数是已解析对象;harness 存储原始 JSON 字符串。适配器会解析输入,并将输出重新字符串化。
 - pi-ai 将失败报告为流内错误事件;它们会映射到 `finish {kind:'error'|'aborted', failure}` 分片。提供方特定错误文本会区分终止型 `QUOTA` 与暂时型 `RATE_LIMIT`,针对已解析模型上下文窗口评估的文本与 usage 信号则将溢出规范化为 `CONTEXT_WINDOW_EXCEEDED`。终止时的 `stop` 若消息不含内容块,则会映射为 `finish {kind:'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试),而非成功空消息。
-- pi-ai 将推理 token 折叠到输出 usage 中;没有可映射的独立推理计数。
+- pi-ai 将推理 token 折叠到输出 usage 中;没有可映射的独立推理计数。它的精确 `totalTokens` 值会原样保留。
 - pi-ai 的 `off` 思考级别会原样穿过 Harness 能力 seam,并在分派时变为被省略的 pi-ai 通用 `reasoning` 选项。
 - `GenerateOptions.stop` 会以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式输出接口无法保证所有提供方都支持它。
 

+ 3 - 1
packages/llm/llm-pi-ai/src/stream.ts

@@ -17,12 +17,14 @@ import { toPiReplayState } from './replay.ts'
 /**
  * Map pi-ai usage (reasoning folded into output by pi-ai).
  * @param usage - cumulative usage from the terminal pi-ai event.
- * @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
+ * @returns harness counts with pi-ai's exact total; cache fields appear only
+ *   when non-zero (pi-ai reports zeros, not absence).
  */
 export function mapUsage(usage: PiUsage): TokenUsage {
   return {
     inputTokens: usage.input,
     outputTokens: usage.output,
+    totalTokens: usage.totalTokens,
     ...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
     ...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
   }

+ 1 - 1
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -85,7 +85,7 @@ describe('PiAiAdapter provider routing', () => {
     })
     expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
     expect(result.finish).toEqual({ kind: 'stop' })
-    expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
+    expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 })
     expect(server.paths).toEqual(['/chat/completions'])
   })
 

+ 5 - 4
packages/llm/llm-pi-ai/tests/convert.spec.ts

@@ -643,7 +643,7 @@ describe('toStreamChunks', () => {
       { type: 'block-start', index: 0, blockType: 'text' },
       { type: 'text-delta', index: 0, text: 'hi' },
       { type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } },
-      { type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } },
+      { type: 'usage', usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 } },
       {
         type: 'finish',
         reason: { kind: 'stop' },
@@ -694,7 +694,7 @@ describe('toStreamChunks', () => {
       { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: '{"a"' },
       { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'f', argumentsDelta: ':1}' },
       { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'call-1', name: 'f', arguments: '{"a":1}' } },
-      { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
+      { type: 'usage', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 } },
       {
         type: 'finish',
         reason: { kind: 'tool-calls' },
@@ -728,7 +728,7 @@ describe('toStreamChunks', () => {
       { type: 'error', reason: 'error', error },
     )))
     expect(chunks).toEqual([
-      { type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } },
+      { type: 'usage', usage: { inputTokens: 1, outputTokens: 0, totalTokens: 1 } },
       { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'PI_AI_ERROR' } } },
     ])
   })
@@ -890,10 +890,11 @@ describe('mapStopReason / mapUsage', () => {
     expect(mapUsage(usage(10, 5, 8, 2))).toEqual({
       inputTokens: 10,
       outputTokens: 5,
+      totalTokens: 25,
       cacheReadTokens: 8,
       cacheWriteTokens: 2,
     })
-    expect(mapUsage(usage(10, 5))).toEqual({ inputTokens: 10, outputTokens: 5 })
+    expect(mapUsage(usage(10, 5))).toEqual({ inputTokens: 10, outputTokens: 5, totalTokens: 15 })
   })
 })
 

+ 8 - 0
packages/llm/llm/src/types.ts

@@ -135,6 +135,14 @@ export type FinishReason = FinishReasonMap[keyof FinishReasonMap]
 export interface TokenUsage {
   inputTokens: number
   outputTokens: number
+  /**
+   * Exact full-call total including aggregate prompt and output tokens.
+   *
+   * Adapters preserve a provider total or derive it from authoritative
+   * aggregate prompt/output counters; they omit it when unavailable or
+   * inconsistent.
+   */
+  totalTokens?: number
   cacheReadTokens?: number
   cacheWriteTokens?: number
   reasoningTokens?: number

+ 2 - 2
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: 9cc56c0ac5e445f2de63cb71aa0b0e9354ae8492
-README.zh.md: eb2cfa9b1130c1ff227a284e84ad9afc979cee60
+README.md: ee80412476c4730e409e6a854d3a78922912bba7
+README.zh.md: 332cc4df33e3d4da5c786fbaf88af210b02cbc85

+ 3 - 1
packages/llm/token-meter/README.md

@@ -25,7 +25,9 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
 
 When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber.
 
-`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.
+`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 replaces the streaming sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new billed attempt. 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.
+
+Token-meter also owns the browser-safe pure fold from one complete Turn's durable events to exact attempt and Turn usage. `step/start` and `llm/retry-started` open real attempts; final message usage replaces that attempt's streaming sample; terminal failures, retries, and step boundaries close it. Missing lifecycle evidence, unsafe counts, or contradictory exact totals fail closed. Presentation consumers select a complete Turn window and render the result; they do not define a second accounting state machine.
 
 `contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage.
 

+ 3 - 1
packages/llm/token-meter/README.zh.md

@@ -25,7 +25,9 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
 
 当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。
 
-`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
+`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;最终 assistant 消息用量会替换同一次模型 attempt 的流式样本,而不是重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的计费 attempt。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
+
+token-meter 还拥有一份可安全用于浏览器的纯 fold,将一个完整 Turn 的持久事件归并为精确的 attempt 与 Turn 用量。`step/start` 与 `llm/retry-started` 打开真实 attempt;最终消息用量替换该 attempt 的流式样本;终止失败、重试与步骤边界关闭它。缺少生命周期证据、计数不安全或精确总量矛盾时一律 fail-closed。展示消费方只选择完整 Turn 窗口并渲染结果,不再定义第二套记账状态机。
 
 `contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。
 

+ 2 - 0
packages/llm/token-meter/package.json

@@ -40,6 +40,7 @@
     "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
@@ -52,6 +53,7 @@
     "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"

+ 3 - 1
packages/llm/token-meter/src/client.ts

@@ -1,7 +1,9 @@
 /**
- * Client-namespace projection of token-meter's browser-safe types.
+ * Client-namespace projection of token-meter's browser-safe contracts and folds.
  *
  * @module @deepseek-ai/dsh-token-meter/client
  */
 
 export type * from './projection.ts'
+export { deriveTurnTokenUsage } from './turn-usage.ts'
+export type { TurnTokenUsage, TurnTokenUsageRoute } from './turn-usage.ts'

+ 2 - 2
packages/llm/token-meter/src/invariant.ts

@@ -18,8 +18,8 @@ export const inject = ['invariants']
  * No runtime invariant: token estimates are per-call outputs and the private
  * session cache is invalidated at its event mutation boundary. The package's
  * three projections do expose observation streams, but their schemas fix the
- * JSON payloads; the usage folds replace same-step samples, so totals need not
- * be monotone when a final sample corrects an earlier chunk, and the
+ * JSON payloads; the usage folds replace same-attempt samples, so totals need
+ * not be monotone when a final sample corrects an earlier chunk, and the
  * composition fold prices through the same `estimate.ts` heuristic as the
  * measurement service and subtracts producer-logged shadow prices derived
  * from that service's own nodes, which makes its message figure equal

+ 271 - 0
packages/llm/token-meter/src/turn-usage.ts

@@ -0,0 +1,271 @@
+import type { AssistantMessage, TokenUsage } from '@deepseek-ai/dsh-llm/types'
+import type {} from '@deepseek-ai/dsh-llm-retry/types'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+
+/** One provider/model route that contributed a billed request attempt. */
+export interface TurnTokenUsageRoute {
+  readonly provider: string
+  readonly model: string
+}
+
+/** Exact provider-reported token accounting for every attempt in one completed Turn. */
+export interface TurnTokenUsage {
+  /** Sum of uncached prompt input across all attempts. */
+  readonly uncachedInputTokens: number
+  readonly outputTokens: number
+  /** Exact aggregate prompt plus output total across all attempts. */
+  readonly totalTokens: number
+  /** Present only when every attempt reported the bucket. */
+  readonly cacheReadTokens?: number
+  /** Present only when every attempt reported the bucket. */
+  readonly cacheWriteTokens?: number
+  /** Output subset, present only when every attempt reported it. */
+  readonly reasoningTokens?: number
+  /** Present only when every billed attempt has provider/model attribution. */
+  readonly routes?: readonly TurnTokenUsageRoute[]
+}
+
+interface NormalizedAttempt {
+  readonly inputTokens: number
+  readonly outputTokens: number
+  readonly totalTokens: number
+  readonly cacheReadTokens?: number
+  readonly cacheWriteTokens?: number
+  readonly reasoningTokens?: number
+  readonly route?: TurnTokenUsageRoute
+}
+
+type AttemptState =
+  | { readonly kind: 'idle' }
+  | {
+    readonly kind: 'open'
+    readonly turn: number
+    readonly step: number
+    readonly sample?: TokenUsage
+  }
+  | {
+    readonly kind: 'finishClosed'
+    readonly turn: number
+    readonly step: number
+  }
+  | {
+    readonly kind: 'settled'
+    readonly turn: number
+    readonly step: number
+    readonly by: 'message' | 'retry'
+  }
+
+function isCount(value: unknown): value is number {
+  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
+}
+
+function safeSum(values: readonly number[]): number | undefined {
+  let total = 0
+  for (const value of values) {
+    total += value
+    if (!Number.isSafeInteger(total)) return undefined
+  }
+  return total
+}
+
+function messageRoute(message: AssistantMessage): TurnTokenUsageRoute | undefined {
+  const { provider, model } = message.source
+  return provider.length > 0 && model.length > 0 ? { provider, model } : undefined
+}
+
+function normalizeUsage(usage: TokenUsage, route?: TurnTokenUsageRoute): NormalizedAttempt | undefined {
+  const {
+    inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens,
+  } = usage
+  if (!isCount(inputTokens) || !isCount(outputTokens)) return undefined
+  if (cacheReadTokens !== undefined && !isCount(cacheReadTokens)) return undefined
+  if (cacheWriteTokens !== undefined && !isCount(cacheWriteTokens)) return undefined
+  if (reasoningTokens !== undefined && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) {
+    return undefined
+  }
+
+  const knownPrompt = safeSum([
+    inputTokens,
+    ...cacheReadTokens === undefined ? [] : [cacheReadTokens],
+    ...cacheWriteTokens === undefined ? [] : [cacheWriteTokens],
+  ])
+  if (knownPrompt === undefined) return undefined
+
+  let exactTotal: number
+  if (totalTokens !== undefined) {
+    if (!isCount(totalTokens)) return undefined
+    const exactPrompt = totalTokens - outputTokens
+    if (!isCount(exactPrompt) || exactPrompt < knownPrompt) return undefined
+    if (cacheReadTokens !== undefined && cacheWriteTokens !== undefined && exactPrompt !== knownPrompt) {
+      return undefined
+    }
+    exactTotal = totalTokens
+  } else {
+    if (cacheReadTokens === undefined || cacheWriteTokens === undefined) return undefined
+    const derivedTotal = safeSum([knownPrompt, outputTokens])
+    if (derivedTotal === undefined) return undefined
+    exactTotal = derivedTotal
+  }
+
+  return {
+    inputTokens,
+    outputTokens,
+    totalTokens: exactTotal,
+    ...cacheReadTokens === undefined ? {} : { cacheReadTokens },
+    ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
+    ...reasoningTokens === undefined ? {} : { reasoningTokens },
+    ...route === undefined ? {} : { route },
+  }
+}
+
+function aggregateAttempts(attempts: readonly NormalizedAttempt[]): TurnTokenUsage | undefined {
+  if (attempts.length === 0) return undefined
+  const inputTokens = safeSum(attempts.map(attempt => attempt.inputTokens))
+  const outputTokens = safeSum(attempts.map(attempt => attempt.outputTokens))
+  const totalTokens = safeSum(attempts.map(attempt => attempt.totalTokens))
+  if (inputTokens === undefined || outputTokens === undefined || totalTokens === undefined) return undefined
+
+  const cacheRead = attempts.map(attempt => attempt.cacheReadTokens)
+  const cacheWrite = attempts.map(attempt => attempt.cacheWriteTokens)
+  const reasoning = attempts.map(attempt => attempt.reasoningTokens)
+  const cacheReadTokens = cacheRead.every(isCount) ? safeSum(cacheRead) : undefined
+  const cacheWriteTokens = cacheWrite.every(isCount) ? safeSum(cacheWrite) : undefined
+  const reasoningTokens = reasoning.every(isCount) ? safeSum(reasoning) : undefined
+  // A present cache bucket is bounded by exact prompt, and reasoning is bounded
+  // by output. Safe required aggregates therefore imply safe optional sums.
+
+  let routes: readonly TurnTokenUsageRoute[] | undefined
+  const attributed = attempts.map(attempt => attempt.route)
+  if (attributed.every((route): route is TurnTokenUsageRoute => route !== undefined)) {
+    const unique = new Map<string, TurnTokenUsageRoute>()
+    for (const route of attributed) unique.set(`${route.provider}\0${route.model}`, route)
+    routes = [...unique.values()]
+  }
+
+  return {
+    uncachedInputTokens: inputTokens,
+    outputTokens,
+    totalTokens,
+    ...cacheReadTokens === undefined ? {} : { cacheReadTokens },
+    ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
+    ...reasoningTokens === undefined ? {} : { reasoningTokens },
+    ...routes === undefined ? {} : { routes },
+  }
+}
+
+function sameAttempt(
+  state: Exclude<AttemptState, { kind: 'idle' }>,
+  turn: number,
+  step: number,
+): boolean {
+  return state.turn === turn && state.step === step
+}
+
+/**
+ * Fold one complete Turn's durable attempt lifecycle into exact token accounting.
+ *
+ * No attempt is inferred from a usage sample. Any missing lifecycle boundary,
+ * incomplete attempt usage, unsafe count, or contradictory exact total makes
+ * the whole disclosure unavailable.
+ * @param events - Turn-local durable events from `turn/start` through `turn/end`.
+ * @returns exact aggregate usage, or undefined when it cannot be proven.
+ */
+export function deriveTurnTokenUsage(events: readonly SessionEvent[]): TurnTokenUsage | undefined {
+  let state: AttemptState = { kind: 'idle' }
+  const attempts: NormalizedAttempt[] = []
+  let turn: number | undefined
+  let sawEnd = false
+  let invalid = false
+
+  const closeOpen = (route?: TurnTokenUsageRoute): boolean => {
+    if (state.kind !== 'open' || state.sample === undefined) return false
+    const normalized = normalizeUsage(state.sample, route)
+    if (normalized === undefined) return false
+    attempts.push(normalized)
+    return true
+  }
+
+  for (const event of events) {
+    if (invalid) break
+    if (event.type === 'turn/start') {
+      if (turn !== undefined || state.kind !== 'idle') invalid = true
+      else turn = event.data.turn
+      continue
+    }
+    if (turn === undefined) {
+      invalid = true
+      break
+    }
+    if (event.type === 'turn/end') {
+      if (event.data.turn !== turn || state.kind !== 'idle' || sawEnd) invalid = true
+      else sawEnd = true
+      continue
+    }
+    if (sawEnd) {
+      invalid = true
+      break
+    }
+    if (event.type === 'step/start') {
+      if (event.data.turn !== turn || state.kind !== 'idle') invalid = true
+      else state = { kind: 'open', turn, step: event.data.step }
+      continue
+    }
+    if (event.type === 'llm/retry-started') {
+      if (event.data.turn !== turn
+        || state.kind !== 'settled'
+        || state.by !== 'retry'
+        || !sameAttempt(state, event.data.turn, event.data.step)) invalid = true
+      else state = { kind: 'open', turn, step: event.data.step }
+      continue
+    }
+    if (event.type === 'assistant/chunk') {
+      if (event.data.turn !== turn
+        || state.kind !== 'open'
+        || !sameAttempt(state, event.data.turn, event.data.step)) {
+        invalid = true
+        continue
+      }
+      if (event.data.chunk.type === 'usage') {
+        state = { ...state, sample: event.data.chunk.usage }
+      } else if (event.data.chunk.type === 'finish'
+        && (event.data.chunk.reason.kind === 'error' || event.data.chunk.reason.kind === 'aborted')) {
+        if (!closeOpen()) invalid = true
+        else state = { kind: 'finishClosed', turn, step: event.data.step }
+      }
+      continue
+    }
+    if (event.type === 'assistant/message') {
+      if (event.data.turn !== turn
+        || state.kind !== 'open'
+        || !sameAttempt(state, event.data.turn, event.data.step)) {
+        invalid = true
+        continue
+      }
+      if (event.data.usage !== undefined) state = { ...state, sample: event.data.usage }
+      if (!closeOpen(messageRoute(event.data.message))) invalid = true
+      else state = { kind: 'settled', turn, step: event.data.step, by: 'message' }
+      continue
+    }
+    if (event.type === 'llm/retry') {
+      if (event.data.turn !== turn || state.kind === 'idle'
+        || !sameAttempt(state, event.data.turn, event.data.step)) {
+        invalid = true
+        continue
+      }
+      if (state.kind === 'settled' || (state.kind === 'open' && !closeOpen())) invalid = true
+      if (!invalid) state = { kind: 'settled', turn, step: event.data.step, by: 'retry' }
+      continue
+    }
+    if (event.type === 'step/end') {
+      if (event.data.turn !== turn || state.kind === 'idle'
+        || !sameAttempt(state, event.data.turn, event.data.step)) {
+        invalid = true
+        continue
+      }
+      if (state.kind === 'open' && !closeOpen()) invalid = true
+      if (!invalid) state = { kind: 'idle' }
+    }
+  }
+
+  return invalid || !sawEnd || state.kind !== 'idle' ? undefined : aggregateAttempts(attempts)
+}

+ 12 - 6
packages/llm/token-meter/src/usage-projection.ts

@@ -4,6 +4,7 @@
 
 import { z } from 'zod'
 import type { TokenUsage } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
@@ -110,18 +111,23 @@ type ContextPressureState = z.infer<typeof contextPressureStateSchema>
  * Token-meter's session projection unit.
  *
  * Usage chunks provide an early sample that survives a later request failure;
- * an assistant message provides the final sample for the same turn/step. A
- * repeated sample replaces that step's earlier value instead of double
- * counting it. The single `last` slot relies on the session-log invariant
- * that usage reports for one turn/step are adjacent: once a later step begins,
- * a legal log never reports usage for an earlier step again.
+ * an assistant message provides the final sample for the same attempt. A
+ * repeated sample replaces that attempt's earlier value instead of double
+ * counting it, while `llm/retry-started` closes the replacement slot so the
+ * retried attempt adds to the total. The single `last` slot relies on the
+ * session-log invariant that usage reports for one attempt are adjacent.
  */
 export const tokenUsageProjectionDefinition = {
   key: 'tokenUsage',
-  stateVersion: 1,
+  stateVersion: 2,
   stateSchema: tokenUsageStateSchema,
   init: () => ({ totals: zeroBuckets(), last: null }),
   apply: (state, event) => {
+    if (event.type === 'llm/retry-started') {
+      return state.last?.turn === event.data.turn && state.last.step === event.data.step
+        ? { ...state, last: null }
+        : state
+    }
     let turn: number
     let step: number
     let usage: TokenUsage

+ 61 - 1
packages/llm/token-meter/tests/token-usage-projection.spec.ts

@@ -7,6 +7,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import TokenMeter from '@deepseek-ai/dsh-token-meter'
 import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
+import { RetryId } from '@deepseek-ai/dsh-llm-retry'
 import { CompactionId } from '@deepseek-ai/dsh-compaction'
 import type {} from '../src/usage-projection.ts'
 
@@ -94,9 +95,16 @@ function appendSummaryMeter(ctx: Context, session: Session, start: number, end:
 }
 
 describe('tokenUsage session projection', () => {
-  it('serves zero buckets for an empty log', async () => {
+  it('serves zero buckets without usage samples', async () => {
     const { ctx, session } = await harness()
     expect(projected(ctx, session)).toEqual(ZERO)
+    session.append('llm/retry-started', {
+      retryId: RetryId('token-meter-no-usage-retry'),
+      turn: 1,
+      step: 1,
+      retry: 1,
+    })
+    expect(projected(ctx, session)).toEqual(ZERO)
   })
 
   it('does not count a usage chunk and identical final usage twice', async () => {
@@ -148,6 +156,58 @@ describe('tokenUsage session projection', () => {
     })
   })
 
+  it('accumulates retried attempts while replacing samples within each attempt', async () => {
+    const { ctx, session } = await harness()
+    const retryId = RetryId('token-meter-retry')
+    session.append('turn/start', { turn: 1 })
+    startStep(session, 1, 1)
+    usageChunk(session, {
+      inputTokens: 10,
+      outputTokens: 2,
+      cacheReadTokens: 3,
+    }, 1, 1)
+    session.append('assistant/chunk', {
+      turn: 1,
+      step: 1,
+      chunk: {
+        type: 'finish',
+        reason: { kind: 'error', failure: { code: 'RATE_LIMIT', message: 'busy', status: 429 } },
+      },
+    })
+    session.append('llm/retry', {
+      retryId,
+      turn: 1,
+      step: 1,
+      provider: 'mock',
+      mode: 'normal',
+      policyKey: 'test',
+      retry: 1,
+      maxRetries: 1,
+      delayMs: 0,
+      failure: { code: 'RATE_LIMIT', message: 'busy', status: 429 },
+    })
+    session.append('llm/retry-started', { retryId, turn: 1, step: 1, retry: 1 })
+    const second = usageChunk(session, {
+      inputTokens: 12,
+      outputTokens: 4,
+      cacheReadTokens: 6,
+    }, 1, 1)
+    finalUsage(session, {
+      inputTokens: 14,
+      outputTokens: 5,
+      cacheReadTokens: 8,
+      cacheWriteTokens: 1,
+    }, 1, 1, [second])
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+
+    expect(projected(ctx, session)).toEqual({
+      uncachedInputTokens: 24,
+      outputTokens: 7,
+      cacheReadTokens: 11,
+      cacheWriteTokens: 1,
+    })
+  })
+
   it('accumulates disjoint buckets across steps without adding reasoning twice', async () => {
     const { ctx, session } = await harness()
     startStep(session, 1, 1)

+ 397 - 0
packages/llm/token-meter/tests/turn-usage.spec.ts

@@ -0,0 +1,397 @@
+import { describe, expect, it } from 'vitest'
+import type { TokenUsage } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { deriveTurnTokenUsage } from '../src/turn-usage.ts'
+
+function event(seq: number, type: string, data: unknown): SessionEvent {
+  return { seq, time: seq, type, data } as unknown as SessionEvent
+}
+
+type UsageOverrides = { [Key in keyof TokenUsage]?: TokenUsage[Key] | undefined }
+
+function usage(overrides: UsageOverrides = {}): TokenUsage {
+  const value = {
+    inputTokens: 100,
+    outputTokens: 20,
+    totalTokens: 170,
+    cacheReadTokens: 50,
+    ...overrides,
+  }
+  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as unknown as TokenUsage
+}
+
+function message(
+  seq: number,
+  tokenUsage?: TokenUsage,
+  provider = 'deepseek',
+  model = 'deepseek-chat',
+  step = 1,
+) {
+  return event(seq, 'assistant/message', {
+    turn: 1,
+    step,
+    message: {
+      id: `message-${seq}`,
+      role: 'assistant',
+      content: [{ type: 'text', text: 'done' }],
+      source: { kind: 'model', provider, model },
+    },
+    ...tokenUsage === undefined ? {} : { usage: tokenUsage },
+  })
+}
+
+function completeAttempt(...middle: readonly SessionEvent[]): SessionEvent[] {
+  return [
+    event(1, 'turn/start', { turn: 1 }),
+    event(2, 'step/start', { turn: 1, step: 1 }),
+    ...middle,
+    event(90, 'step/end', { turn: 1, step: 1 }),
+    event(91, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+  ]
+}
+
+describe('deriveTurnTokenUsage', () => {
+  it('preserves authoritative totals and explicit optional buckets', () => {
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, usage({
+      cacheWriteTokens: 0,
+      reasoningTokens: 8,
+    }))))).toEqual({
+      uncachedInputTokens: 100,
+      outputTokens: 20,
+      totalTokens: 170,
+      cacheReadTokens: 50,
+      cacheWriteTokens: 0,
+      reasoningTokens: 8,
+      routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
+    })
+  })
+
+  it('derives an exact total only when both cache buckets are present', () => {
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, usage({
+      totalTokens: undefined,
+      inputTokens: 10,
+      outputTokens: 4,
+      cacheReadTokens: 2,
+      cacheWriteTokens: 1,
+    }))))?.totalTokens).toBe(17)
+
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, usage({
+      totalTokens: undefined,
+      cacheWriteTokens: undefined,
+    }))))).toBeUndefined()
+  })
+
+  it('lets final message usage replace the latest streaming sample', () => {
+    const result = deriveTurnTokenUsage(completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      message(4, usage({ inputTokens: 30, outputTokens: 5, totalTokens: 45, cacheReadTokens: 10 })),
+    ))
+    expect(result).toMatchObject({ uncachedInputTokens: 30, outputTokens: 5, totalTokens: 45 })
+  })
+
+  it('keeps the latest streaming sample when the final message omits usage', () => {
+    const result = deriveTurnTokenUsage(completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      message(4),
+    ))
+    expect(result).toMatchObject({ uncachedInputTokens: 100, outputTokens: 20, totalTokens: 170 })
+  })
+
+  it('counts an error-finished attempt once across its retry boundary', () => {
+    const events = completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'assistant/chunk', {
+        turn: 1,
+        step: 1,
+        chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+      }),
+      event(5, 'llm/retry', { turn: 1, step: 1 }),
+      event(6, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
+      message(7, usage({ inputTokens: 40, outputTokens: 10, totalTokens: 70, cacheReadTokens: 20 })),
+    )
+    expect(deriveTurnTokenUsage(events)).toEqual({
+      uncachedInputTokens: 140,
+      outputTokens: 30,
+      totalTokens: 240,
+      cacheReadTokens: 70,
+    })
+  })
+
+  it('does not invent an attempt for a scheduled retry that never started', () => {
+    const result = deriveTurnTokenUsage(completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'llm/retry', { turn: 1, step: 1 }),
+    ))
+    expect(result).toMatchObject({ totalTokens: 170 })
+  })
+
+  it('fails closed for missing lifecycle or missing attempt usage', () => {
+    expect(deriveTurnTokenUsage([
+      event(1, 'turn/start', { turn: 1 }),
+      message(2, usage()),
+      event(3, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ])).toBeUndefined()
+    expect(deriveTurnTokenUsage(completeAttempt(message(3)))).toBeUndefined()
+  })
+
+  it.each([
+    ['negative', usage({ inputTokens: -1 })],
+    ['fractional', usage({ outputTokens: 1.5 })],
+    ['unsafe', usage({ totalTokens: Number.MAX_SAFE_INTEGER + 1 })],
+    ['invalid cache read', usage({ cacheReadTokens: -1 })],
+    ['invalid cache write', usage({ cacheWriteTokens: 1.5 })],
+    ['negative exact prompt', usage({ outputTokens: 20, totalTokens: 10, cacheReadTokens: undefined })],
+    ['total below known prompt', usage({ totalTokens: 160 })],
+    ['contradictory complete buckets', usage({ totalTokens: 171, cacheWriteTokens: 0 })],
+    ['reasoning exceeds output', usage({ reasoningTokens: 21 })],
+    ['prompt bucket overflow', usage({
+      inputTokens: Number.MAX_SAFE_INTEGER,
+      outputTokens: 0,
+      totalTokens: Number.MAX_SAFE_INTEGER,
+      cacheReadTokens: 1,
+    })],
+    ['derived total overflow', usage({
+      inputTokens: Number.MAX_SAFE_INTEGER,
+      outputTokens: 1,
+      totalTokens: undefined,
+      cacheReadTokens: 0,
+      cacheWriteTokens: 0,
+    })],
+  ])('fails closed for %s usage', (_label, invalidUsage) => {
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, invalidUsage)))).toBeUndefined()
+  })
+
+  it('omits optional aggregates and routes unless every attempt reports them', () => {
+    const events = [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, usage({ totalTokens: 175, cacheWriteTokens: 5, reasoningTokens: 2 })),
+      event(4, 'step/end', { turn: 1, step: 1 }),
+      event(5, 'step/start', { turn: 1, step: 2 }),
+      event(6, 'assistant/message', {
+        turn: 1,
+        step: 2,
+        message: {
+          id: 'message-6', role: 'assistant', content: [],
+          source: { kind: 'model', provider: '', model: '' },
+        },
+        usage: usage({ cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }),
+      }),
+      event(7, 'step/end', { turn: 1, step: 2 }),
+      event(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]
+    expect(deriveTurnTokenUsage(events)).toEqual({ uncachedInputTokens: 200, outputTokens: 40, totalTokens: 345 })
+  })
+
+  it('sums multiple steps and preserves distinct attributed routes', () => {
+    const events = [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, usage()),
+      event(4, 'step/end', { turn: 1, step: 1 }),
+      event(5, 'step/start', { turn: 1, step: 2 }),
+      message(6, usage(), 'openai', 'gpt-5', 2),
+      event(7, 'step/end', { turn: 1, step: 2 }),
+      event(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]
+    expect(deriveTurnTokenUsage(events)).toEqual({
+      uncachedInputTokens: 200,
+      outputTokens: 40,
+      totalTokens: 340,
+      cacheReadTokens: 100,
+      routes: [
+        { provider: 'deepseek', model: 'deepseek-chat' },
+        { provider: 'openai', model: 'gpt-5' },
+      ],
+    })
+  })
+
+  it('fails closed when aggregation overflows a safe integer', () => {
+    const half = Math.floor(Number.MAX_SAFE_INTEGER / 2) + 1
+    const attempt = usage({ inputTokens: 0, outputTokens: 0, cacheReadTokens: undefined, totalTokens: half })
+    const events = [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, attempt),
+      event(4, 'step/end', { turn: 1, step: 1 }),
+      event(5, 'step/start', { turn: 1, step: 2 }),
+      event(6, 'assistant/message', {
+        turn: 1,
+        step: 2,
+        message: {
+          id: 'message-6', role: 'assistant', content: [],
+          source: { kind: 'model', provider: 'deepseek', model: 'deepseek-chat' },
+        },
+        usage: attempt,
+      }),
+      event(7, 'step/end', { turn: 1, step: 2 }),
+      event(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]
+    expect(deriveTurnTokenUsage(events)).toBeUndefined()
+  })
+
+  it.each([
+    ['uncached input', usage({
+      inputTokens: Math.floor(Number.MAX_SAFE_INTEGER / 2) + 1,
+      outputTokens: 0,
+      cacheReadTokens: undefined,
+      totalTokens: Math.floor(Number.MAX_SAFE_INTEGER / 2) + 1,
+    })],
+    ['output', usage({
+      inputTokens: 0,
+      outputTokens: Math.floor(Number.MAX_SAFE_INTEGER / 2) + 1,
+      cacheReadTokens: undefined,
+      totalTokens: Math.floor(Number.MAX_SAFE_INTEGER / 2) + 1,
+    })],
+  ])('fails closed when aggregate %s overflows', (_label, attempt) => {
+    expect(deriveTurnTokenUsage([
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, attempt),
+      event(4, 'step/end', { turn: 1, step: 1 }),
+      event(5, 'step/start', { turn: 1, step: 1 }),
+      message(6, attempt),
+      event(7, 'step/end', { turn: 1, step: 1 }),
+      event(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ])).toBeUndefined()
+  })
+
+  it('closes a sampled attempt at step/end', () => {
+    expect(deriveTurnTokenUsage(completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'assistant/chunk', {
+        turn: 1,
+        step: 1,
+        chunk: { type: 'finish', reason: { kind: 'stop' } },
+      }),
+      event(5, 'tool/call', { turn: 1, step: 1 }),
+    ))).toMatchObject({ totalTokens: 170 })
+  })
+
+  it('accepts an aborted finish after observing usage', () => {
+    expect(deriveTurnTokenUsage(completeAttempt(
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'assistant/chunk', {
+        turn: 1,
+        step: 1,
+        chunk: { type: 'finish', reason: { kind: 'aborted' } },
+      }),
+    ))).toMatchObject({ totalTokens: 170 })
+  })
+
+  it.each([
+    ['empty turn', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]],
+    ['duplicate turn start', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'turn/start', { turn: 1 }),
+    ]],
+    ['wrong turn end', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
+    ]],
+    ['turn end during an open attempt', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]],
+    ['duplicate turn end', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+      event(3, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ]],
+    ['event after turn end', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+      event(3, 'step/start', { turn: 1, step: 1 }),
+    ]],
+    ['wrong-turn step start', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 2, step: 1 }),
+    ]],
+    ['nested step start', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'step/start', { turn: 1, step: 2 }),
+    ]],
+    ['retry start without a scheduled retry', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
+    ]],
+    ['retry start after a final message', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, usage()),
+      event(4, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
+    ]],
+    ['retry start for the wrong step', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'llm/retry', { turn: 1, step: 1 }),
+      event(5, 'llm/retry-started', { turn: 1, step: 2, retry: 1 }),
+    ]],
+    ['usage chunk outside an attempt', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+    ]],
+    ['usage chunk for the wrong step', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: usage() } }),
+    ]],
+    ['error finish without usage', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'assistant/chunk', {
+        turn: 1,
+        step: 1,
+        chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+      }),
+    ]],
+    ['retry outside an attempt', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'llm/retry', { turn: 1, step: 1 }),
+    ]],
+    ['retry for the wrong step', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+      event(4, 'llm/retry', { turn: 1, step: 2 }),
+    ]],
+    ['retry after a final message', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      message(3, usage()),
+      event(4, 'llm/retry', { turn: 1, step: 1 }),
+    ]],
+    ['retry before any usage', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'llm/retry', { turn: 1, step: 1 }),
+    ]],
+    ['step end outside an attempt', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/end', { turn: 1, step: 1 }),
+    ]],
+    ['step end for the wrong step', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'step/end', { turn: 1, step: 2 }),
+    ]],
+    ['step end before any usage', [
+      event(1, 'turn/start', { turn: 1 }),
+      event(2, 'step/start', { turn: 1, step: 1 }),
+      event(3, 'step/end', { turn: 1, step: 1 }),
+    ]],
+  ])('fails closed for invalid lifecycle: %s', (_label, events) => {
+    expect(deriveTurnTokenUsage(events)).toBeUndefined()
+  })
+
+  it('requires the complete turn window', () => {
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, usage())).slice(1))).toBeUndefined()
+    expect(deriveTurnTokenUsage(completeAttempt(message(3, usage())).slice(0, -1))).toBeUndefined()
+  })
+})

+ 3 - 0
packages/llm/token-meter/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../llm/llm"
     },
+    {
+      "path": "../../llm/llm-retry"
+    },
     {
       "path": "../../core/session"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -6281,6 +6281,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../llm
+      '@deepseek-ai/dsh-llm-retry':
+        specifier: workspace:^
+        version: link:../llm-retry
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session

+ 3 - 0
scripts/client-bundle-purity.spec.ts

@@ -95,6 +95,9 @@ describe('client bundle purity gate', () => {
     expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
+    expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
+    expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
+    expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
   })
 
   it('lets exact generated Remote contributions inline without admitting their package implementation', () => {

+ 64 - 32
scripts/snapshots/python-sdk-single-exe/advanced/result.json

@@ -233,7 +233,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -279,7 +280,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -428,7 +430,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -474,7 +477,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -661,7 +665,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -707,7 +712,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -887,7 +893,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -933,7 +940,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -1078,7 +1086,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -1124,7 +1133,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -1309,7 +1319,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -1355,7 +1366,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -1532,7 +1544,8 @@
           "type": "usage",
           "usage": {
             "inputTokens": 3,
-            "outputTokens": 3
+            "outputTokens": 3,
+            "totalTokens": 6
           }
         }
       }
@@ -1576,7 +1589,8 @@
         },
         "usage": {
           "inputTokens": 3,
-          "outputTokens": 3
+          "outputTokens": 3,
+          "totalTokens": 6
         }
       },
       "sourceEventSeqs": [
@@ -1930,7 +1944,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -1988,7 +2003,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -2191,7 +2207,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -2249,7 +2266,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -2496,7 +2514,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -2554,7 +2573,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -2800,7 +2820,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -2858,7 +2879,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -3248,7 +3270,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -3304,7 +3327,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -3541,7 +3565,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -3599,7 +3624,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -4021,7 +4047,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -4077,7 +4104,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -4345,7 +4373,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -4403,7 +4432,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [
@@ -4640,7 +4670,8 @@
               "type": "usage",
               "usage": {
                 "inputTokens": 3,
-                "outputTokens": 3
+                "outputTokens": 3,
+                "totalTokens": 6
               }
             }
           }
@@ -4696,7 +4727,8 @@
             },
             "usage": {
               "inputTokens": 3,
-              "outputTokens": 3
+              "outputTokens": 3,
+              "totalTokens": 6
             }
           },
           "sourceEventSeqs": [

+ 2 - 2
scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl

@@ -16,8 +16,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 2 - 2
scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl

@@ -16,8 +16,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 14 - 14
scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl

@@ -15,9 +15,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n  harness.registerTool(ctx, harness.defineTool({\\n    name: 'snapshot_double',\\n    description: 'Double a number for executable snapshot verification.',\\n    parameters: { value: { type: 'number', required: true } },\\n    output: {\\n      schema: { type: 'number' },\\n      render(_args, value) {\\n        return [{ type: 'text', text: String(value) }]\\n      }\\n    },\\n    async execute(args) {\\n      return args.value * 2\\n    }\\n  }))\\n}\\n\"}}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n  harness.registerTool(ctx, harness.defineTool({\\n    name: 'snapshot_double',\\n    description: 'Double a number for executable snapshot verification.',\\n    parameters: { value: { type: 'number', required: true } },\\n    output: {\\n      schema: { type: 'number' },\\n      render(_args, value) {\\n        return [{ type: 'text', text: String(value) }]\\n      }\\n    },\\n    async execute(args) {\\n      return args.value * 2\\n    }\\n  }))\\n}\\n\"}}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n  harness.registerTool(ctx, harness.defineTool({\\n    name: 'snapshot_double',\\n    description: 'Double a number for executable snapshot verification.',\\n    parameters: { value: { type: 'number', required: true } },\\n    output: {\\n      schema: { type: 'number' },\\n      render(_args, value) {\\n        return [{ type: 'text', text: String(value) }]\\n      }\\n    },\\n    async execute(args) {\\n      return args.value * 2\\n    }\\n  }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n  harness.registerTool(ctx, harness.defineTool({\\n    name: 'snapshot_double',\\n    description: 'Double a number for executable snapshot verification.',\\n    parameters: { value: { type: 'number', required: true } },\\n    output: {\\n      schema: { type: 'number' },\\n      render(_args, value) {\\n        return [{ type: 'text', text: String(value) }]\\n      }\\n    },\\n    async execute(args) {\\n      return args.value * 2\\n    }\\n  }))\\n}\\n\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\": {\"kind\": \"new\", \"idPrefix\": \"snap\"}, \"name\": \"Snapshot Double\", \"purpose\": \"Expose a deterministic doubling tool for executable snapshot verification.\", \"code\": {\"host\": \"return (ctx) => {\\n  harness.registerTool(ctx, harness.defineTool({\\n    name: 'snapshot_double',\\n    description: 'Double a number for executable snapshot verification.',\\n    parameters: { value: { type: 'number', required: true } },\\n    output: {\\n      schema: { type: 'number' },\\n      render(_args, value) {\\n        return [{ type: 'text', text: String(value) }]\\n      }\\n    },\\n    async execute(args) {\\n      return args.value * 2\\n    }\\n  }))\\n}\\n\"}}"}}
 {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Double); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
@@ -26,9 +26,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-run","name":"cordis_run","argumentsDelta":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-run","name":"cordis_run","arguments":"{\"pluginId\": \"snap-1\", \"packageId\": \"pkg-1\", \"mode\": \"run\"}"}}
 {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-run"},"content":[{"type":"tool-result","toolCallId":"advanced-run","content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}],"isError":false}],"role":"user","id":"{{messageId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1","pluginRunId":"run-1"}},"sourceEventSeqs":[30],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
@@ -38,9 +38,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
 {"type":"tool/code-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
 {"type":"tool/code-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
@@ -51,9 +51,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
 {"type":"tool/result","data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":4}}
@@ -62,9 +62,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
 {"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}}
 {"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}}
@@ -77,9 +77,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\": \"snap-1\"}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":6,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\": \"snap-1\"}"}}
 {"type":"tool/result","data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[81],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":6}}
@@ -89,8 +89,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[87,88,89,90,91],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[87,88,89,90,91],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":7}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 2 - 2
scripts/snapshots/python-sdk-single-exe/restart/session.1.jsonl

@@ -15,8 +15,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_ONE_OK"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 2 - 2
scripts/snapshots/python-sdk-single-exe/restart/session.2.jsonl

@@ -15,8 +15,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"PROCESS_TWO_OK"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 4
snapshots/web/turn-tail-actions/session.jsonl

@@ -20,9 +20,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Reading the workspace now."}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7788,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":42}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7788,"outputTokens":109,"totalTokens":7897,"cacheReadTokens":0,"reasoningTokens":42}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."},{"type":"text","text":"Reading the workspace now."},{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":7788,"outputTokens":109,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."},{"type":"text","text":"Reading the workspace now."},{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":7788,"outputTokens":109,"totalTokens":7897,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}}
 {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1yZGg4XTqe0N5r1rnDLx5082"},"content":[{"type":"tool-result","toolCallId":"call_00_1yZGg4XTqe0N5r1rnDLx5082","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[90],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
@@ -31,8 +31,8 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"D"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
-{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":103,"outputTokens":3,"cacheReadTokens":7808,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":103,"outputTokens":3,"totalTokens":7914,"cacheReadTokens":7808,"reasoningTokens":0}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":103,"outputTokens":3,"cacheReadTokens":7808,"reasoningTokens":0}},"sourceEventSeqs":[94,95,96,97,98,99],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":103,"outputTokens":3,"totalTokens":7914,"cacheReadTokens":7808,"reasoningTokens":0}},"sourceEventSeqs":[94,95,96,97,98,99],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 64 - 0
snapshots/web/turn-tail-actions/usage-expanded.expected.md

@@ -0,0 +1,64 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Begin your reply with the" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Begin your reply with the plain sentence "Reading the workspace now." as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop. {{clock}}
+- button "Copy":
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop.":
+  - img
+  - img
+  - text: Think The user wants me to begin with "Reading the workspace now." and call bash with "echo alpha" in the same message. Then after the tool result, reply with the single word DONE and stop.
+- paragraph: Reading the workspace now.
+- button "Bash Print alpha to stdout":
+  - img
+  - img
+  - text: Bash Print alpha to stdout
+- paragraph: DONE
+- button "Turn usage 15.8K tok · Cache hit 49.7%" [expanded]:
+  - img
+  - text: Turn usage 15.8K tok · Cache hit 49.7%
+- term: Provider / model
+- definition: deepseek-official/deepseek-v4-flash
+- term: Uncached input
+- definition: 7,891 tok
+- term: Cached input
+- definition: 7,808 tok
+- term: Output
+- definition: 112 tok (42 tok reasoning)
+- term: Total
+- definition: 15,811 tok
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message the agent"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "6% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 50% Input 15.7K tok · Output 112 tok

+ 1 - 0
tsconfig.base.json

@@ -75,6 +75,7 @@
       "@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"],
       "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"],
       "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"],
+      "@deepseek-ai/dsh-token-meter/client": ["./packages/llm/token-meter/src/client.ts"],
       "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
       "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
       "@deepseek-ai/dsh-agent-presets/types": ["./packages/preset/agent-presets/src/types.ts"],