From 62a437c082b5fd1740247e25eab783dc5abf4e10 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 12 Aug 2026 21:43:35 +0800 Subject: [PATCH] review: address ds-review-bot findings on session-stats - stateVersion starts at 1: the package is new, no persisted rows predate it - pendingCalls pairs by own key so a provider callId naming a prototype property cannot fold toolMs to NaN on an unmatched crash-recovery result - StatsLine folds the window fallback only when no sessionStats value is served, and gates the token group on actual token activity instead of steps, so failed-only sessions drop the zero-token group - correct the crash-step counting semantics in the README and Agent Note: recovery closes interrupted steps with a synthetic step/end on reload - reword the window-scoped alternative as a plain rejected option and name isTokenDelta's home beside the StreamChunk type --- ...12-full-session-turn-step-counts.i18n.yaml | 4 ++-- ...026-08-12-full-session-turn-step-counts.md | 6 +++--- ...-08-12-full-session-turn-step-counts.zh.md | 6 +++--- apps/web/tests/math-rendering.e2e.ts | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../markdown-cjk-strong/ui.expected.md | 2 +- .../snapshots/markdown-images/ui.expected.md | 2 +- .../markdown-inline-code-links/ui.expected.md | 2 +- .../snapshots/math-rendering/ui.expected.md | 2 +- .../queue-actions/preserved.expected.md | 2 +- .../stats-paged-history/ui.expected.md | 2 +- .../src/client/sessions/assistant-timing.ts | 2 +- .../src/client/chat/StatsLine.tsx | 13 +++++++----- .../tests/chat-stats.client.spec.tsx | 11 ++++++++++ .../session/session-stats/README.i18n.yaml | 4 ++-- packages/session/session-stats/README.md | 2 +- packages/session/session-stats/README.zh.md | 2 +- .../session/session-stats/src/projection.ts | 8 +++++-- .../session-stats/tests/projection.spec.ts | 21 +++++++++++++++++++ 20 files changed, 68 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml index f50df929a7..18ed791f68 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md -2026-08-12-full-session-turn-step-counts.md: 7cea57e429d3ffc2da49c4a1359ee3489663ccc5 -2026-08-12-full-session-turn-step-counts.zh.md: 93e46cfb7ffa4f397b85ee80b26cca9f664d246a +2026-08-12-full-session-turn-step-counts.md: ecfa00dc3e24101953a9d5a724dba17682d839bd +2026-08-12-full-session-turn-step-counts.zh.md: 85e2a26b5af51296e20f29af49e909c6e182ea05 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md index 7cea57e429..ecfa00dc3e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md @@ -10,7 +10,7 @@ The web chat stats strip folded `StatsLine`'s loaded conversation window (`deriv ## Decision -A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (the `StreamChunk` vocabulary owner) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline. +A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (beside the `StreamChunk` type it discriminates) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline. `step/end` — not `assistant/message` — is the counted event, for two correctness reasons found while reviewing the obvious message-counting design: @@ -31,8 +31,8 @@ A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats **Fold the full log client-side.** The client holds only the paged window by design; the projection RFC's no-client-folding rule exists exactly so figures survive paging, compaction, and cold reads. -**Keep wall times, TTFT, and throughput window-scoped.** The first shipped cut did, reading them as "what is on screen"; the same paging complaint immediately applied to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection now carries the whole set, with the window fold demoted to the no-unit fallback. +**Keep wall times, TTFT, and throughput window-scoped, reading them as "what is on screen".** Rejected: the same paging complaint applies to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection carries the whole set, with the window fold demoted to the no-unit fallback. ## Consequences -The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step truncated by a crash between `step/start` and `step/end` does not, a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier. +The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step interrupted by a crash counts once recovery closes it with a synthetic `step/end` on reload (`interruptedTurnClosers`), a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md index 93e46cfb7f..85e2a26b5a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md @@ -10,7 +10,7 @@ Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载 ## 决定 -新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(`StreamChunk` 词汇的属主),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。 +新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(与其判别的 `StreamChunk` 类型同处),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。 计数事件选 `step/end` 而非 `assistant/message`,源于评审直觉方案(按消息计数)时发现的两个正确性问题: @@ -31,8 +31,8 @@ Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载 **在客户端折叠全量日志。** 客户端按设计只持有分页窗口;投影 RFC 的「不在客户端折叠」规则正是为了让数字在分页、压缩与冷读之间存活。 -**墙钟时间、TTFT 与吞吐保持窗口口径。** 首个交付版本如此,将其解读为「屏幕上有什么」;同样的分页问题立刻落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影现在携带完整集合,窗口折叠降级为无单元时的回退。 +**墙钟时间、TTFT 与吞吐保持窗口口径,解读为「屏幕上有什么」。** 否决:同样的分页问题一样落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影携带完整集合,窗口折叠降级为无单元时的回退。 ## 后果 -统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计;被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。 +统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;被崩溃打断的步在重新加载、恢复为其补写合成 `step/end` 后计入(`interruptedTurnClosers`);被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。 diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index de24c1ca76..67af32373c 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -120,7 +120,7 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) await expect.poll( - () => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(), + () => page.getByText('1 turns · 1 steps', { exact: false }).count(), { timeout: 10_000 }, ).toBe(1) diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4ea903679c..bafa739a58 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -31,4 +31,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 975ebe3a0d..341ddf22db 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -27,4 +27,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index dbaca89b8a..adae2f723e 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -53,4 +53,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index f90d10f616..85c537bb18 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -32,4 +32,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index f345357f1b..d940beabc9 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -44,4 +44,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 0bb9a9b19b..5561c3574e 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -48,4 +48,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 7951af37e4..43c9665ac1 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -50,4 +50,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps diff --git a/apps/web/tests/snapshots/stats-paged-history/ui.expected.md b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md index 8179078b8b..78d175af5d 100644 --- a/apps/web/tests/snapshots/stats-paged-history/ui.expected.md +++ b/apps/web/tests/snapshots/stats-paged-history/ui.expected.md @@ -354,4 +354,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 28 turns · 28 steps LLM {{duration}} Input 0 tok · Output 0 tok +- text: 28 turns · 28 steps LLM {{duration}} diff --git a/packages/client/runtime/src/client/sessions/assistant-timing.ts b/packages/client/runtime/src/client/sessions/assistant-timing.ts index 8f58f8daab..179f76281d 100644 --- a/packages/client/runtime/src/client/sessions/assistant-timing.ts +++ b/packages/client/runtime/src/client/sessions/assistant-timing.ts @@ -6,7 +6,7 @@ import { isTokenDelta } from '@deepseek-ai/dsh-llm/message' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantTiming } from './conversation.ts' -// The first-token predicate lives with the StreamChunk vocabulary in dsh-llm; +// The first-token predicate lives beside the StreamChunk type in dsh-llm; // re-exported here so Chat Definitions keep their client-runtime import. export { isTokenDelta } from '@deepseek-ai/dsh-llm/message' diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 177afdb05a..147d2b7c6c 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -162,12 +162,13 @@ export interface StatsLineProps { export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) { const settledNodes = useSession(s => s.chat.legacy.nodes) - const windowStats = useMemo(() => deriveStats(settledNodes), [settledNodes]) const usage = useProjection('tokenUsage') // Every figure rides the durable sessionStats projection, so paging and // compaction cannot change any of them; an assembly without the unit falls - // back to the window-scoped fold wholesale (same field names). - const stats = useProjection('sessionStats') ?? windowStats + // back to the window-scoped fold wholesale (same field names), paid only + // while no projection value is served. + const projected = useProjection('sessionStats') + const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes]) // Pipe-separated groups (figma stats strip); a group with no data drops out whole. const groups: string[] = [] if (stats.steps > 0) { @@ -190,9 +191,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t // Context occupancy deliberately lives on the composer's ContextMeter ring, // not here — one home per fact. // Billing rides the durable projection, so these survive paging and - // compaction. Suppress the empty projection on a brand-new session. + // compaction. Gated on actual token activity: a session whose steps all + // settled without billing (e.g. every request failed) shows its counts + // without a zero-token group. if (usage !== undefined - && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { + && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) { const cacheHit = cacheHitPercent(usage) if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit })) groups.push(t('stats.tokens', { diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 6abcf611d2..4ace851a66 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -314,6 +314,17 @@ describe('StatsLine', () => { expect(view.container.textContent).toBe('') }) + it('hides the zero-token group when steps closed without any billed activity', () => { + // A session whose only turn failed before billing (e.g. an auth error): + // the counts group renders alone, not an uninformative zero-token group. + const { source } = makeSource() + const view = render() + expect(view.container.textContent).toBe('1 turns · 1 steps') + }) + it('keeps the counts group over an empty visible window when the projection carries totals', () => { // Extends the durable-groups guarantee: full-session counts survive a // window that compaction (or paging) left without assistant nodes. diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml index 8896173827..d12db01c69 100644 --- a/packages/session/session-stats/README.i18n.yaml +++ b/packages/session/session-stats/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/session/session-stats/README.md -README.md: a42d5b1b6efe8ff522a43431f1ccb68b71607876 -README.zh.md: 5929280a8a619fb37a6f6468fb839962702a68c3 +README.md: 81b0de17e335b67936afd6fb11f15411beee3b76 +README.zh.md: 606628ea09bc34203a5374e49db62c1646293846 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md index a42d5b1b6e..81b0de17e3 100644 --- a/packages/session/session-stats/README.md +++ b/packages/session/session-stats/README.md @@ -33,7 +33,7 @@ None; the plugin never assembles or sends provider requests. ## Known Limitations and Deferred Work -- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step truncated by a crash between `step/start` and `step/end` does not. +- **Steps count work attempted, not visible output** — a step that failed before producing any visible content still closed with `step/end` and counts; a step interrupted by a crash counts after the session reloads, when crash recovery appends its synthetic `step/end` (`interruptedTurnClosers` in dsh-session). - **A cancelled step is counted but untimed** — no assistant message assembles, so its partial stream time enters no wall-time figure, matching the window fold's untimed interrupted node; a max-tokens usage-host message conversely contributes model time the surface does not show. - **Counts are log-scoped, not surface-scoped** — steps whose messages were later compacted away stay counted; the figures describe the whole session, not the current model-visible surface. - **Mounted only in the web-app bundle** — other assemblies serve no `sessionStats` key, and their consumers fall back to window-scoped counting (the web stats strip's fallback path). diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md index 5929280a8a..606628ea09 100644 --- a/packages/session/session-stats/README.zh.md +++ b/packages/session/session-stats/README.zh.md @@ -33,7 +33,7 @@ ## 已知局限与延后工作 -- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;进程崩溃恰好截断在 `step/start` 与 `step/end` 之间的步不计。 +- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;被崩溃打断的步在会话重新加载后计入,届时崩溃恢复为其补写合成的 `step/end`(dsh-session 的 `interruptedTurnClosers`)。 - **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。 - **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。 - **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats` 键,其消费者回退到窗口口径计数(Web 统计条的回退路径)。 diff --git a/packages/session/session-stats/src/projection.ts b/packages/session/session-stats/src/projection.ts index e155622920..a000300873 100644 --- a/packages/session/session-stats/src/projection.ts +++ b/packages/session/session-stats/src/projection.ts @@ -140,8 +140,12 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat case 'tool/call': return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } } case 'tool/result': { + // Own-key check: callId is provider-minted (model/tool JSON boundary), + // so a prototype property name ('constructor', 'toString') on a result + // with no recorded call must read as unmatched, not as an inherited + // function that would poison toolMs with NaN. const callId = event.data.message.source.callId - const dispatched = state.pendingCalls[callId] + const dispatched = Object.hasOwn(state.pendingCalls, callId) ? state.pendingCalls[callId] : undefined if (dispatched === undefined) return state const pendingCalls = Object.fromEntries( Object.entries(state.pendingCalls).filter(([id]) => id !== callId), @@ -175,5 +179,5 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat decodeMs: state.decodeMs, decodeTokens: state.decodeTokens, }), - stateVersion: 2, + stateVersion: 1, } diff --git a/packages/session/session-stats/tests/projection.spec.ts b/packages/session/session-stats/tests/projection.spec.ts index fce09b4e9e..ebe728181b 100644 --- a/packages/session/session-stats/tests/projection.spec.ts +++ b/packages/session/session-stats/tests/projection.spec.ts @@ -237,6 +237,27 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => { expect(pruned).toEqual(totals({ turns: 1, steps: 1 })) }) + it('pairs only own pendingCalls keys: a prototype-name callId without a recorded call stays unmatched', () => { + const result = (callId: string): unknown => + ({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } }) + // Crash recovery (TOOL_NOT_STARTED) emits results with no preceding + // tool/call; a provider-minted callId colliding with an Object prototype + // property must read as absent, not as an inherited function that would + // fold toolMs to NaN and fail the value schema. + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_500, 'tool/result', result('toString')), + at(2_000, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1 })) + // The same name pairs normally once its call is recorded. + expect(fold([ + at(1_000, 'step/start', { turn: 1, step: 1 }), + at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'constructor', name: 'read', arguments: '{}' }), + at(1_600, 'tool/result', result('constructor')), + at(2_000, 'step/end', { turn: 1, step: 1 }), + ])).toEqual(totals({ turns: 1, steps: 1, toolMs: 500 })) + }) + it('skips decode for an invalid usage report and ignores a duplicate assembled message', () => { const events = [ at(1_000, 'step/start', { turn: 1, step: 1 }),