fix: 分页问题
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
## 标题
|
||||
|
||||
|
||||
6
packages/session/session-stats/README.i18n.yaml
Normal file
6
packages/session/session-stats/README.i18n.yaml
Normal 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
|
||||
39
packages/session/session-stats/README.md
Normal file
39
packages/session/session-stats/README.md
Normal 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).
|
||||
39
packages/session/session-stats/README.zh.md
Normal file
39
packages/session/session-stats/README.zh.md
Normal 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 统计条的回退路径)。
|
||||
62
packages/session/session-stats/package.json
Normal file
62
packages/session/session-stats/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
10
packages/session/session-stats/src/client.ts
Normal file
10
packages/session/session-stats/src/client.ts
Normal 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'
|
||||
29
packages/session/session-stats/src/index.ts
Normal file
29
packages/session/session-stats/src/index.ts
Normal 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)
|
||||
}
|
||||
35
packages/session/session-stats/src/invariant.ts
Normal file
35
packages/session/session-stats/src/invariant.ts
Normal 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 */
|
||||
179
packages/session/session-stats/src/projection.ts
Normal file
179
packages/session/session-stats/src/projection.ts
Normal 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,
|
||||
}
|
||||
46
packages/session/session-stats/src/types.ts
Normal file
46
packages/session/session-stats/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
271
packages/session/session-stats/tests/projection.spec.ts
Normal file
271
packages/session/session-stats/tests/projection.spec.ts
Normal 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 }))
|
||||
})
|
||||
})
|
||||
30
packages/session/session-stats/tsconfig.json
Normal file
30
packages/session/session-stats/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user