fix: 分页问题

This commit is contained in:
07akioni
2026-08-12 20:26:47 +08:00
parent 3784607f3d
commit dabe6207f5
46 changed files with 1594 additions and 66 deletions

View File

@@ -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/README.md
README.md: 586d1be0286a0de935b0b08313e6965452b85376
README.zh.md: 60e58e6d471a48d3ced03a518e96aeeae79110e8
README.md: 64aa8e4fdf1e85d74dfa0a77803a04e881b547c4
README.zh.md: 520e969cbf623ac4a0a3dc82b6fb54f12ba8ffa3

View File

@@ -25,6 +25,7 @@ Serves current, log-derived per-session state to client carriers.
|---|---|---|
| [`session-projection/`](session-projection/README.md) | Defines and drives session projection units | `ctx.sessionProjections` |
| [`session-projection-cache/`](session-projection-cache/README.md) | Persists and restores projection checkpoints | `ctx.sessionProjectionCache` |
| [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times (`sessionStats` unit) | registers on `ctx.sessionProjections` |
## Titles

View File

@@ -25,6 +25,7 @@
|---|---|---|
| [`session-projection/`](session-projection/README.md) | 定义并驱动会话投影单元 | `ctx.sessionProjections` |
| [`session-projection-cache/`](session-projection-cache/README.md) | 持久化并恢复投影检查点 | `ctx.sessionProjectionCache` |
| [`session-stats/`](session-stats/README.md) | 提供全日志会话计数与墙钟时间(`sessionStats` 单元) | 注册到 `ctx.sessionProjections` |
## 标题

View File

@@ -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 packages/session/session-stats/README.md
README.md: a42d5b1b6efe8ff522a43431f1ccb68b71607876
README.zh.md: 5929280a8a619fb37a6f6468fb839962702a68c3

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-session-stats
English | [中文](README.zh.md)
Function plugin registering the `sessionStats` projection unit: whole-log conversation figures — turn/step counts and the LLM, tool, first-token, and decode wall times — folded from step boundaries, stream chunks, tool pairs, and assembled assistant messages, and served through the session-projection seam (registry snapshot, change feed, and every projection carrier: history tail page, `session/projection` push frames, session list rows). Clients render full-session figures that paging and compaction cannot change; the reference consumer is the web chat stats strip, whose window fold mirrors these field names as its no-unit fallback.
## Fold semantics
- `steps` counts `step/end` events. The agent loop appends exactly one per entered step, in a `finally`, so completed, failed, cancelled, and max-tokens steps all count. Counting assembled assistant messages instead would overcount max-tokens usage-host messages (empty content, excluded from the surface) and undercount cancelled steps (aborted before the message assembles).
- `turns` counts distinct turns carrying at least one closed step; rejected or empty turns (closed with no step) are uncounted. Turn numbers are host-assigned and monotonic per session, so the fold keeps only the last counted turn.
- `llmMs` sums `step/start``assistant/message` per step that assembled a message (retry waits inside the step are model time, as in the window fold).
- `ttftMs`/`ttftSteps` sum and count `step/start` → first non-empty delta chunk; the first attempt's boundary survives an in-step `llm/retry` (window `resetForRetry` parity).
- `decodeMs`/`decodeTokens` sum first token → assembled message and the provider-reported output tokens, only over steps carrying both.
- `toolMs` sums `tool/call``tool/result` pairs matched by callId; unresolved calls are dropped at `turn/end` (results land within their turn).
- Every field is 0 until its first contributing event. A composed registry always serves the key, so clients read the value, never key presence.
## Composition
```yaml
- id: session-stats
name: '@deepseek-ai/dsh-session-stats'
```
Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers.
## Model Experience
None, as the plugin only computes a client-facing read model of already-logged session events and touches no prompt, message, schema, stream, or tool result.
#### KV Cache effect
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.
- **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).

View File

@@ -0,0 +1,39 @@
# @deepseek-ai/dsh-session-stats
[English](README.md) | 中文
注册 `sessionStats` projection 单元的函数插件:从步边界、流式 chunk、工具配对与已组装的 assistant 消息折叠出全日志会话数字——轮/步计数以及 LLM、工具、首 token、解码墙钟时间——经 session-projection 缝对外提供registry 快照、变更流,以及每一个 projection 载体history 尾页、`session/projection` 推送帧、会话列表行)。客户端由此渲染分页与压缩都无法改变的全会话数字;参考消费者是 Web 聊天统计条,其窗口折叠以相同字段名充当无单元时的回退。
## 折叠语义
- `steps` 统计 `step/end` 事件。agent loop 对每个进入的步在 `finally` 中恰好追加一条因此完成、失败、取消、max-tokens 的步全部计入。若改按已组装的 assistant 消息计数,则会多算 max-tokens 的 usage 宿主消息(空内容、被排除在 surface 之外),并少算被取消的步(在消息组装前已中止)。
- `turns` 统计含至少一个已关闭步的不同 turn被拒绝或空轮未进入任何步即关闭不计。turn 号由宿主分配、按会话单调递增,因此折叠只需保留最近计入的 turn。
- `llmMs` 按步累加 `step/start``assistant/message`(组装出消息的步;步内重试的等待与窗口折叠一样计入模型时间)。
- `ttftMs`/`ttftSteps` 累加并统计 `step/start` → 首个非空 delta chunk首次尝试的边界在步内 `llm/retry` 后保留(与窗口 `resetForRetry` 对齐)。
- `decodeMs`/`decodeTokens` 累加首 token → 已组装消息的时长与提供方上报的输出 token仅统计两者兼备的步。
- `toolMs` 按 callId 配对累加 `tool/call``tool/result`;未解决的调用在 `turn/end` 时丢弃(结果总在其轮内落地)。
- 每个字段在首个贡献事件之前均为 0。已装配的 registry 恒提供该键,客户端读取值本身,而非键的存在性。
## 组合
```yaml
- id: session-stats
name: '@deepseek-ai/dsh-session-stats'
```
注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。
## 模型体验
因为插件只计算面向客户端的、由已写入日志的会话事件派生的读模型不触碰任何提示词、消息、schema、流或工具结果。
#### KV Cache 影响
无;插件从不组装或发送提供方请求。
## 已知局限与延后工作
- **步数统计的是已发生的工作,而非可见输出**——在产生任何可见内容前就失败的步仍以 `step/end` 关闭并计入;进程崩溃恰好截断在 `step/start``step/end` 之间的步不计。
- **被取消的步计数但不计时**——没有组装出 assistant 消息,其部分流式时间不进入任何墙钟数字,与窗口折叠的无计时 interrupted 节点一致;反之 max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。
- **计数是日志口径,不是 surface 口径**——消息后来被压缩掉的步仍然计入;数字描述整个会话,而非当前模型可见 surface。
- **仅挂载于 web-app bundle**——其他装配不提供 `sessionStats`其消费者回退到窗口口径计数Web 统计条的回退路径)。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-session-stats",
"description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/session/session-stats"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,10 @@
/**
* Client-namespace projection of the session-stats domain: a pure re-export
* of the package's types outlet. Client code imports ONLY the client
* namespace (repo discipline), so `./client` projects the same single-source
* content `./types` serves to host consumers — zero duplication.
*
* @module @deepseek-ai/dsh-session-stats/client
*/
export type * from './types.ts'

View File

@@ -0,0 +1,29 @@
/**
* Function plugin registering the `sessionStats` projection unit: whole-log
* turn/step counts and LLM/tool/first-token/decode wall times served through
* the session-projection seam (registry snapshot, change feed, and every
* projection carrier), so clients render full-session figures that paging and
* compaction cannot change. The plugin owns only the fold; delivery is the
* seam's.
*
* @module @deepseek-ai/dsh-session-stats
*/
import type { Context } from '@deepseek-ai/cordis'
import { sessionStatsProjectionDefinition } from './projection.ts'
export type * from './types.ts'
/** Cordis plugin name. */
export const name = 'session-stats'
/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
export const inject = ['sessionProjections']
/**
* Register the `sessionStats` unit; the registration is an effect on this
* plugin's fiber, so unloading removes the key.
* @param ctx - registrant context carrying the projection registry.
*/
export function apply(ctx: Context): void {
ctx.sessionProjections.register(sessionStatsProjectionDefinition)
}

View File

@@ -0,0 +1,35 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-stats`.
* @module @deepseek-ai/dsh-session-stats/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-stats'
/** Cordis companion plugin name. */
export const name = 'session-stats-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the package owns a single pure projection fold whose
* wire payload is schema-validated by the projection registry at every
* snapshot and change-feed emission, and the event relations the fold relies
* on (`step/end` exactly once per entered step, monotonic host-assigned turn
* numbers, chunk and tool events carrying their step coordinates and call
* ids) are owned and runtime-checked by dsh-agent-loop and the session
* surface, not here.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,179 @@
/**
* The `sessionStats` projection unit: a pure fold of step boundaries, stream
* chunks, tool pairs, and assembled assistant messages into whole-log counts
* and wall times.
*
* `step/end` — not `assistant/message` — is the counted step event because it
* is the step lifecycle authority: the loop appends exactly one per entered
* step, in a `finally`, so completed, failed, cancelled, and max-tokens steps
* all land one. Counting assembled assistant messages instead would overcount
* max-tokens usage-host messages (empty content, excluded from the surface)
* and undercount cancelled steps (aborted before the message assembles).
*
* The wall-time folds mirror the client window fold field by field
* (`deriveStats` in dsh-client-ui-conversation, that fold's whole-window
* fallback role): model time is `step/start` → `assistant/message`, first
* token is the first non-empty delta chunk and survives an in-step
* `llm/retry`, decode spans first token → assembled message on steps that
* also report output tokens, and tool time pairs `tool/call` → `tool/result`
* by callId. A cancelled step assembles no message, so its partial stream
* time stays uncounted in every time figure — matching the window, which
* renders it as an untimed interrupted node.
*
* @module @deepseek-ai/dsh-session-stats/projection
*/
import { z } from 'zod'
import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
/** Accumulated whole-log figures (the view is exactly these totals). */
interface SessionStatsTotals {
/** Distinct turns with at least one closed step so far. */
turns: number
/** Closed steps so far. */
steps: number
/** Summed model wall time over message-assembling steps, ms. */
llmMs: number
/** Summed matched tool call→result wall time, ms. */
toolMs: number
/** Summed first-token latency over `ttftSteps`, ms. */
ttftMs: number
/** Steps carrying a recorded first token. */
ttftSteps: number
/** Summed decode wall time over usage-reporting steps, ms. */
decodeMs: number
/** Summed provider output tokens over the same steps. */
decodeTokens: number
}
/**
* Fold state: the totals plus the in-flight boundaries they accrue from.
* Turn numbers are host-assigned and monotonic per session, so a single
* `lastTurn` slot decides "first closed step of a new turn"; the state is
* plain JSON per the unit contract (persisted-cache precondition).
*/
interface SessionStatsState extends SessionStatsTotals {
/** Turn of the last counted `step/end`; null before the first. */
lastTurn: number | null
/** The open step's boundary facts; null outside a step or after its message assembled. */
openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null
/** Dispatch times of tool calls whose result has not landed, by callId. */
pendingCalls: Record<string, number>
}
const sessionStatsSchema = z.object({
turns: z.number().int().nonnegative(),
steps: z.number().int().nonnegative(),
llmMs: z.number().nonnegative(),
toolMs: z.number().nonnegative(),
ttftMs: z.number().nonnegative(),
ttftSteps: z.number().int().nonnegative(),
decodeMs: z.number().nonnegative(),
decodeTokens: z.number().nonnegative(),
}).strict()
/**
* Provider-reported completion tokens, guarded the way the window fold guards
* node usage.
* @param usage - the assistant/message event's optional usage record.
* @returns the output-token count, or null when unreported or invalid.
*/
function usageOutputTokens(usage: unknown): number | null {
if (typeof usage !== 'object' || usage === null) return null
const value = (usage as { outputTokens?: unknown }).outputTokens
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
key: 'sessionStats',
schema: sessionStatsSchema,
init: () => ({
turns: 0,
steps: 0,
llmMs: 0,
toolMs: 0,
ttftMs: 0,
ttftSteps: 0,
decodeMs: 0,
decodeTokens: 0,
lastTurn: null,
openStep: null,
pendingCalls: {},
}),
apply: (state, event) => {
// Every uninteresting event returns the same reference (Object.is gates the change feed).
switch (event.type) {
case 'step/start':
return {
...state,
openStep: { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null },
}
case 'assistant/chunk': {
const open = state.openStep
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
if (open.firstTokenTime !== null || !isTokenDelta(event.data.chunk)) return state
return { ...state, openStep: { ...open, firstTokenTime: event.time } }
}
case 'assistant/message': {
const open = state.openStep
if (open === null || open.turn !== event.data.turn || open.step !== event.data.step) return state
// One assembled message per step: closing the boundary means a
// defensive duplicate cannot accrue twice.
const next: SessionStatsState = {
...state,
llmMs: state.llmMs + Math.max(0, event.time - open.startTime),
openStep: null,
}
if (open.firstTokenTime !== null) {
next.ttftMs += Math.max(0, open.firstTokenTime - open.startTime)
next.ttftSteps += 1
const outputTokens = usageOutputTokens(event.data.usage)
if (outputTokens !== null) {
next.decodeMs += Math.max(0, event.time - open.firstTokenTime)
next.decodeTokens += outputTokens
}
}
return next
}
case 'tool/call':
return { ...state, pendingCalls: { ...state.pendingCalls, [event.data.callId]: event.time } }
case 'tool/result': {
const callId = event.data.message.source.callId
const dispatched = state.pendingCalls[callId]
if (dispatched === undefined) return state
const pendingCalls = Object.fromEntries(
Object.entries(state.pendingCalls).filter(([id]) => id !== callId),
)
return { ...state, toolMs: state.toolMs + Math.max(0, event.time - dispatched), pendingCalls }
}
case 'step/end':
return {
...state,
turns: state.lastTurn === event.data.turn ? state.turns : state.turns + 1,
steps: state.steps + 1,
lastTurn: event.data.turn,
openStep: null,
}
case 'turn/end':
// A call whose result never landed belongs to a cancelled or failed
// turn; results always land within their turn, so drop the leftovers
// instead of growing persisted state forever.
return Object.keys(state.pendingCalls).length === 0 ? state : { ...state, pendingCalls: {} }
default:
return state
}
},
view: state => ({
turns: state.turns,
steps: state.steps,
llmMs: state.llmMs,
toolMs: state.toolMs,
ttftMs: state.ttftMs,
ttftSteps: state.ttftSteps,
decodeMs: state.decodeMs,
decodeTokens: state.decodeTokens,
}),
stateVersion: 2,
}

View File

@@ -0,0 +1,46 @@
/**
* Pure types of the session-stats domain: the ONE home of the `sessionStats`
* projection-key declaration, free of this package's host-side value imports
* (cordis context, zod, the llm chunk predicate). Two namespace projections
* serve it — `./types` for host consumers, `./client` for client aggregates —
* with zero content duplication.
*
* @module @deepseek-ai/dsh-session-stats/types
*/
// Marks this file a module so the declaration below AUGMENTS the projection
// table instead of declaring an ambient module.
export {}
/**
* Whole-log conversation figures, independent of how much history a client
* has paged in. Counts and wall times all fold from the complete durable log;
* every field is 0 until its first contributing event lands. Field names
* mirror the client window fold so an assembly without this unit can fall
* back to it wholesale.
*/
export interface SessionStatsProjection {
/** Distinct turns carrying at least one closed step (`step/end`); rejected or empty turns are uncounted. */
turns: number
/** Closed steps (`step/end` events) — completed, failed, and cancelled steps alike. */
steps: number
/** Summed model wall time (`step/start` → `assistant/message`) over steps that assembled a message. */
llmMs: number
/** Summed tool wall time over `tool/call` → `tool/result` pairs matched by callId. */
toolMs: number
/** Summed first-token latency (`step/start` → first non-empty delta chunk) over `ttftSteps`. */
ttftMs: number
/** Steps carrying a recorded first token. */
ttftSteps: number
/** Summed decode wall time (first token → `assistant/message`) over steps that also report output tokens. */
decodeMs: number
/** Summed provider output tokens over the same decode-timed steps. */
decodeTokens: number
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Whole-log turn/step counts and wall times; see {@link SessionStatsProjection}. */
sessionStats: SessionStatsProjection
}
}

View File

@@ -0,0 +1,86 @@
/**
* REAL-composition proof: the shipped YAML shape (session + projection
* registry + session-stats) boots through the vendored Loader, the function
* plugin's namespace survives (no default export), and a full logged turn
* serves `{turns: 1, steps: 1}` through the composed registry.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
async function loadYaml(lines: readonly string[]): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-session-stats-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [...lines, ''].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-session-stats', SessionStatsPlugin],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
describe('real Loader composition', () => {
it('loads the shipped session-stats YAML shape and serves whole-log counts', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-session-stats'",
])
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
const session = loaded.sessions.create(SessionId('composed'))
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(loaded.sessionProjections.snapshot(session).values.sessionStats)
.toMatchObject({ turns: 1, steps: 1 })
})
it('keeps the function-plugin namespace free of a default export', () => {
// A default export beside the named form makes the Loader discard the
// namespace (postmortem 0001) — pin its absence.
expect('default' in SessionStatsPlugin).toBe(false)
})
})

View File

@@ -0,0 +1,271 @@
/**
* The `sessionStats` projection unit: mounting the plugin beside the
* projection registry serves whole-log counts and wall times folded from step
* boundaries, chunks, tool pairs, and assembled messages; compositions
* without the registry are unaffected; unmounting the plugin removes the key
* (HMR safety). The two counting regressions pinned here are the reasons the
* fold counts step boundaries instead of assistant messages: a cancelled step
* never assembles a message but still counts, and a max-tokens usage-host
* message (empty content) adds no extra step. Wall-time math runs against the
* exported definition directly, where event times are controlled.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts'
import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types'
async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
if (withStatsPlugin) await ctx.plugin(SessionStatsPlugin)
return { ctx, session: ctx.sessions.create(SessionId('counted')) }
}
/** Close one step; returns the counted `step/end` seq. */
function closeStep(session: Session, turn: number, step: number): number {
session.append('step/start', { turn, step })
return session.append('step/end', { turn, step }).seq
}
/** Append the max-tokens usage-host shape: an assistant/message with empty content. */
function appendEmptyAssistantMessage(session: Session, turn: number, step: number): void {
session.append('assistant/message', {
turn,
step,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
}, { surfaceOp: 'append', sourceEventSeqs: [] })
}
/** The all-zero projection value plus overrides, for exact fold expectations. */
function totals(overrides: Partial<SessionStatsProjection> = {}): SessionStatsProjection {
return {
turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
...overrides,
}
}
describe('sessionStats projection unit (registry drive)', () => {
it('serves zero figures on the empty log', async () => {
const { ctx, session } = await harness(true)
expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals())
})
it('counts distinct turns and closed steps and notifies the change feed with the causing seq', async () => {
const { ctx, session } = await harness(true)
const changes: { key: string; value: unknown; seq: number }[] = []
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
changes.push({ key, value, seq })
})
session.append('turn/start', { turn: 1 })
const firstSeq = closeStep(session, 1, 1)
const secondSeq = closeStep(session, 1, 2)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
const thirdSeq = closeStep(session, 2, 1)
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// Boundary events that carry no figure change (turn/start, empty-prune
// turn/end, user input) fold to the same reference and stay silent;
// step/start opens a boundary (internal state) and step/end commits the
// counts, so each closed step notifies twice with the step/end value last.
const counted = changes.filter(change => (change.value as SessionStatsProjection).steps > 0
|| change.seq === firstSeq)
expect(changes.every(change => change.key === 'sessionStats')).toBe(true)
expect(counted.map(change => ({ seq: change.seq, value: change.value }))).toContainEqual(
{ seq: firstSeq, value: totals({ turns: 1, steps: 1 }) },
)
expect(changes.at(-1)).toEqual({ key: 'sessionStats', value: totals({ turns: 2, steps: 3 }), seq: thirdSeq })
const snapshot = ctx.sessionProjections.snapshot(session)
expect(snapshot.values.sessionStats).toEqual(totals({ turns: 2, steps: 3 }))
expect(snapshot.asOfSeq).toBe(session.seq - 1)
expect(changes.map(change => change.seq)).toContain(secondSeq)
})
it('does not count a rejected or empty turn that closes with no step', async () => {
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
expect(ctx.sessionProjections.snapshot(session).values.sessionStats).toEqual(totals())
})
it('counts a cancelled step that closed without an assistant message', async () => {
// Regression: an aborted stream never assembles assistant/message, but the
// loop's finally still appends step/end — the step happened and counts.
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
closeStep(session, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } })
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
.toMatchObject({ turns: 1, steps: 1 })
})
it('adds no extra step for a max-tokens usage-host assistant message', async () => {
// Regression: the empty-content assistant/message exists only to host
// usage and is excluded from the surface; the step counts once, from its
// step/end, while the message contributes only its model wall time.
const { ctx, session } = await harness(true)
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
appendEmptyAssistantMessage(session, 1, 1)
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
.toMatchObject({ turns: 1, steps: 1, ttftSteps: 0, decodeTokens: 0 })
})
it('folds steps already in the log when the plugin mounts late (lazy cell build)', async () => {
const { ctx, session } = await harness(false)
session.append('turn/start', { turn: 1 })
closeStep(session, 1, 1)
closeStep(session, 1, 2)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(SessionStatsPlugin)
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
.toMatchObject({ turns: 1, steps: 2 })
})
it('has no sessionStats key without the plugin, and drops it when the plugin unloads (HMR safety)', async () => {
const { ctx, session } = await harness(false)
expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = await ctx.plugin(SessionStatsPlugin)
session.append('turn/start', { turn: 1 })
closeStep(session, 1, 1)
expect(ctx.sessionProjections.snapshot(session).values.sessionStats)
.toMatchObject({ turns: 1, steps: 1 })
await fiber.dispose()
expect('sessionStats' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
/** Build one synthetic committed event with a controlled timestamp. */
function at(time: number, type: string, data: unknown): SessionEvent {
return { type, seq: time, time, data } as unknown as SessionEvent
}
/** Fold a synthetic event list through the definition and view the result. */
function fold(events: readonly SessionEvent[]): SessionStatsProjection {
const state = events.reduce(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
sessionStatsProjectionDefinition.init(),
)
return sessionStatsProjectionDefinition.view(state)
}
describe('sessionStats wall-time fold (controlled timestamps)', () => {
const message = createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'answer' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
})
it('accrues model, first-token, and decode time from one fully recorded step', () => {
expect(fold([
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_800, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
at(4_800, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 10, outputTokens: 60 } }),
at(4_900, 'step/end', { turn: 1, step: 1 }),
])).toEqual(totals({
turns: 1, steps: 1, llmMs: 3_800, ttftMs: 800, ttftSteps: 1, decodeMs: 3_000, decodeTokens: 60,
}))
})
it('keeps the first attempt token boundary across an in-step retry (window resetForRetry parity)', () => {
expect(fold([
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'x' } }),
at(2_000, 'llm/retry', { turn: 1, step: 1 }),
at(3_000, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'y' } }),
at(5_000, 'assistant/message', { turn: 1, step: 1, message }),
at(5_100, 'step/end', { turn: 1, step: 1 }),
])).toEqual(totals({ turns: 1, steps: 1, llmMs: 4_000, ttftMs: 200, ttftSteps: 1 }))
})
it('ignores empty deltas, non-token chunks, and chunks outside the open step', () => {
expect(fold([
// Chunk before any step/start: no open boundary.
at(500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'stray' } }),
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_100, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } }),
at(1_200, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } }),
at(1_300, 'assistant/chunk', { turn: 2, step: 9, chunk: { type: 'text-delta', index: 0, text: 'other' } }),
at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }),
at(2_000, 'assistant/message', { turn: 1, step: 1, message }),
at(2_100, 'step/end', { turn: 1, step: 1 }),
])).toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 }))
})
it('leaves a cancelled step untimed: counted by step/end, no assembled message to accrue from', () => {
expect(fold([
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_500, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial' } }),
at(2_000, 'step/end', { turn: 1, step: 1 }),
])).toEqual(totals({ turns: 1, steps: 1 }))
})
it('pairs tool wall time by callId, ignores orphan results, and prunes leftovers at turn/end', () => {
const result = (callId: string): unknown =>
({ turn: 1, step: 1, message: { source: { kind: 'tool', callId } } })
const paired = fold([
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'read', arguments: '{}' }),
at(1_200, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'read', arguments: '{}' }),
// Out-of-order settlement pairs by id, not adjacency.
at(4_200, 'tool/result', result('b')),
at(1_600, 'tool/result', result('a')),
at(5_000, 'tool/result', result('ghost')),
at(5_100, 'step/end', { turn: 1, step: 1 }),
])
expect(paired).toEqual(totals({ turns: 1, steps: 1, toolMs: 3_500 }))
// An unresolved call is dropped at turn/end; a later result cannot pair.
const pruned = fold([
at(1_000, 'step/start', { turn: 1, step: 1 }),
at(1_100, 'tool/call', { turn: 1, step: 1, callId: 'orphan', name: 'read', arguments: '{}' }),
at(2_000, 'step/end', { turn: 1, step: 1 }),
at(2_100, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'legacy' } } }),
at(9_000, 'tool/result', result('orphan')),
])
expect(pruned).toEqual(totals({ turns: 1, steps: 1 }))
})
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 }),
at(1_400, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
// A malformed provider report: guarded like the window fold guards node usage.
at(2_000, 'assistant/message', { turn: 1, step: 1, message, usage: { inputTokens: 1, outputTokens: -5 } }),
]
expect(fold([...events, at(2_100, 'step/end', { turn: 1, step: 1 })]))
.toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 }))
// The first message closed the step boundary; a defensive duplicate finds
// no open step and folds to the same reference.
const state = events.reduce(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
sessionStatsProjectionDefinition.init(),
)
expect(sessionStatsProjectionDefinition.apply(
state,
at(2_050, 'assistant/message', { turn: 1, step: 1, message }),
)).toBe(state)
})
it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => {
const state = sessionStatsProjectionDefinition.init()
const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] }))
expect(untouched).toBe(state)
expect(fold([
at(2_000, 'step/start', { turn: 1, step: 1 }),
at(1_000, 'assistant/message', { turn: 1, step: 1, message }),
at(2_100, 'step/end', { turn: 1, step: 1 }),
])).toEqual(totals({ turns: 1, steps: 1 }))
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../session-projection"
}
]
}