diff --git a/README.md b/README.md index a571bf3..c8b2c95 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,15 @@ The **tool-part TTFT fallback** (capturing `tool.pending` as first-response time ## Features -- **Cache hit rate**: session total + **per-turn** rate with trend (↑ / ↓ / `-`) on the main block +- **Cache hit rate**: active provider/model lineage total + **per-turn** rate with trend (↑ / ↓ / `-`) on the main block - **Token breakdown**: cache read / write / miss / output (aligned rows with visual-cache) -- **Cost**: session cost with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`); per-million rates and cache savings from provider config; **dynamic pricing** for time-of-day tiers (DeepSeek peak/off-peak) and context tiers (`context_over_200k`, e.g. GPT-5.6) +- **Cost**: per-message model rates with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`); read savings, write premium, and net cache value; **dynamic pricing** for time-of-day tiers (DeepSeek peak/off-peak) and context tiers (`context_over_200k`, e.g. GPT-5.6) - **Sub-agents**: **Agents** section rolls up **child sessions only** (scope labeled in UI); each row shows model name + session ID suffix with **vendor-tinted** label (cost in muted gray) - **Main + Agents**: main block always shown; **Agents** section when sub-agents exist (foldable) -- **Collapsible sections**: Detail / Model (and Agents); theme-adaptive hit bar colors +- **Model lineages**: separate provider/model buckets; model switches show `switch` or `warming` instead of a cross-model trend +- **Cache TTL**: active and recent model lineages have independent timers +- **Compaction filtering**: `summary: true` and `agent: "compaction"` messages do not change interactive metrics +- **Collapsible sections**: Detail / Model / Models (and Agents); theme-adaptive hit bar colors - **i18n**: `display.lang` — `en` / `zh` / `auto` via config (no slash commands yet) - **Timeline** (optional): daily JSONL per assistant turn for `jq` / scripts @@ -76,7 +79,7 @@ Create or edit `~/.config/opencode/tui.json` / `tui.jsonc`: } ``` -Local development: use `"./plugins/opencode-cache-hit"` instead of the npm name. +Local development: use an absolute path to the checkout, such as `"/path/to/opencode-cache-hit"`, instead of the npm name. OpenCode loads the package root and its `index.tsx` entry. Copy `cache-hit.config.example.json` → `~/.config/opencode/cache-hit.json` (recommended) or next to the plugin root. **Restart OpenCode** after changing plugin code or config. @@ -132,9 +135,17 @@ Supported display currencies in config: `USD`, `CNY`, `EUR`, `GBP`, `JPY` (see ` **Agents** totals sum **child sessions only**, not the main session (see `agentsScopeHint`). Main session metrics stay in the block above; collapse **Agents** to save space. Per-child rows use the same model slug as the main **Model** line (truncated when the sidebar is narrow); see [docs/en/design.md](docs/en/design.md) § Sub-agent row display. +### Model lineages and metric history + +The main Hit, Total Hit, and cache TTL values use the active provider/model lineage. Cost, read savings, write premium, and net cache value sum eligible messages with each message's provider/model rates. The foldable **Models** section shows recent lineages separately. + +The plugin excludes assistant messages with `summary: true` or `agent: "compaction"` from interactive metrics. Timeline JSONL keeps these rows when `timeline.logSummaryMessages` is enabled and marks them with `skippedForMetrics: true`. + +The plugin requests main-session history directly with a limit of 10,000 messages. It reports a capped or unavailable source internally and falls back to the live TUI mirror when needed. The live mirror can contain only the 100 most recent messages. Streaming speed and TTFT use this live mirror so they can update during a call. If a message has no matching rate, the cost row keeps OpenCode's reported blended cost instead of showing a partial recomputation. + ### Timeline logs (`timeline`, default off) -Per assistant turn → JSONL (tokens, cache, cost, TTFT, per-tool `toolDurations`). [docs/en/timeline.md](docs/en/timeline.md) · [中文](docs/zh-CN/timeline.md). +Per assistant turn → JSONL (tokens, cache, cost, TTFT, per-tool `toolDurations`, and `skippedForMetrics`). [docs/en/timeline.md](docs/en/timeline.md) · [中文](docs/zh-CN/timeline.md). ```json "timeline": { @@ -168,7 +179,7 @@ Retention details: [Rotation and retention](docs/en/timeline.md#rotation-and-ret ### Cache TTL (`cacheTTL`, default on) -Shows how long the prompt cache has been alive. Color changes when exceeding TTL: +Shows how long the active provider/model prompt cache has been alive. Each model lineage has an independent timer. Color changes when exceeding TTL: - Green: elapsed < TTL - Yellow: TTL ≤ elapsed < 2×TTL @@ -254,7 +265,7 @@ Per-model rules support two forms (explicit config wins over the built-in DeepSe - `levels`: absolute rates per level, e.g. `{"peak": {"input": 0.44, "output": 0.88, "cacheRead": 0.01}, "offpeak": {"input": 0.22, ...}}`. Cache rates may be written as flat `cacheRead`/`cacheWrite` (or `cache_read`/`cache_write`) or nested `cache: {"read": …, "write": …}` (both are accepted; flat wins if both present). Default unit is **USD per 1M** (same as `state.provider`). To write prices in another currency, set `"currency": "CNY"` and either make it match the display `cost.currency` (converted via `cost.rate`) or provide the per-rule `"rate"` (USD → that currency, e.g. `"rate": 1.08` for EUR). If the currency cannot be converted (no `rate`, currency ≠ display currency), a warning is logged to stderr and the values are treated as USD. `multipliers` are ratios and have no currency. - `contextThreshold`: per-model override of the global threshold (wins over the runtime tier size from `state.provider`). -Rates shown in the sidebar switch automatically at schedule boundaries (no polling). The `peak`/`off-peak` badge on the rate row appears **only when the model actually prices that level** (the level exists in its explicit `levels`/`multipliers`, or the built-in DeepSeek default applies); plain static models and unpriced levels (e.g. a peak-only rule at an off-peak moment) show no badge. Session cost shown is recomputed per message from its request time + context tier when dynamic rules apply (marked `≈`); otherwise OpenCode's own `msg.cost` is used. Sub-agent rows use their **session creation time** (`session.list`) for time-of-day pricing (marked `≈` on the Agents total when any child was recomputed). +Rates shown in the sidebar switch automatically at schedule boundaries (no polling). The `peak`/`off-peak` badge on the rate row appears **only when the model actually prices that level** (the level exists in its explicit `levels`/`multipliers`, or the built-in DeepSeek default applies); plain static models and unpriced levels (e.g. a peak-only rule at an off-peak moment) show no badge. Session cost shown is recomputed per message from its provider/model, request time, and context tier when dynamic rules apply (marked `≈`); otherwise OpenCode's own `msg.cost` is used. If a message has no matching rate, the cost row keeps OpenCode's reported blended cost instead of showing a partial recomputation. Read savings, write premium, and net cache value use the same per-message rates. Sub-agent rows use their **session creation time** (`session.list`) for time-of-day pricing (marked `≈` on the Agents total when any child was recomputed). > [!NOTE] > **Migration (weekday-aware schedules).** Schedules now support an optional `days` field (ISO weekday, 1=Monday … 7=Sunday; omitted = every day) and a catch-all **fallback level** (a level with empty `windows`). DeepSeek's official peak is **Monday–Friday** 09:00-12:00 / 14:00-18:00 Beijing time; weekends are off-peak. The built-in default schedule is weekday-aware, and new configs written from the examples above are too. **Legacy configs without `days` keep the old behavior — weekends are still billed as peak.** To pick up the fix, add `"days": [1,2,3,4,5]` to your `peak` windows (or use the new default schedule with an `offpeak` fallback). If you resolve a DeepSeek model while your configured schedule has windowed levels but no `days`, the plugin logs a one-time hint to stderr. diff --git a/README.zh-CN.md b/README.zh-CN.md index 5c186c0..122f426 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,12 +31,15 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量 ## 功能一览 -- **命中率**:会话累计 + **单轮**命中率与趋势(↑ / ↓ / `-`) +- **命中率**:当前 provider/model lineage 的累计命中率 + **单轮**命中率与趋势(↑ / ↓ / `-`) - **Token 明细**:缓存读 / 写 / 未命中 / 输出(对齐 visual-cache 的行布局) -- **费用**:多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`);从 provider 配置读取百万 token 单价及缓存节省;**动态计价**:支持按时段(DeepSeek 高峰/空闲)与按上下文分档(`context_over_200k`,如 GPT-5.6) +- **费用**:按消息使用对应模型单价;展示读取节省、写入溢价和缓存净值;支持多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`)与**动态计价**(DeepSeek 高峰/空闲、`context_over_200k` 上下文分档) - **子 agent**:**Agents** 段仅汇总**子 session**(UI 有范围标注);每行显示模型名 + session ID 后缀,**label 按厂商近似品牌色**,金额为灰色 - **主 + Agents**:主块始终显示;有子 agent 时出现可折叠的 **Agents** 段 -- **可折叠段落**:Detail / Model(以及 Agents);主题自适应的命中率条颜色 +- **模型 lineage**:按 provider/model 分桶;模型切换显示 `switch` 或 `warming`,不跨模型比较趋势 +- **缓存 TTL**:当前及最近模型 lineage 使用独立计时器 +- **压缩过滤**:`summary: true` 和 `agent: "compaction"` 不会改变交互指标 +- **可折叠段落**:Detail / Model / Models(以及 Agents);主题自适应的命中率条颜色 - **国际化**:`display.lang` — `en` / `zh` / `auto`(配置文件,暂无斜杠命令) - **时间轴**(可选):按天 JSONL,每 assistant 轮次一条,可用于 `jq` / 脚本分析 @@ -76,7 +79,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量 } ``` -本地开发:将 npm 名称替换为 `"./plugins/opencode-cache-hit"`。 +本地开发:将 npm 名称替换为 checkout 的绝对路径,例如 `"/path/to/opencode-cache-hit"`。OpenCode 会加载包根目录及其 `index.tsx` 入口。 复制 `cache-hit.config.example.json` → `~/.config/opencode/cache-hit.json`(推荐)或放在插件根目录旁。更改插件代码或配置后**重启 OpenCode**。 @@ -132,9 +135,17 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量 **Agents** 段仅汇总**子 session**,不含主 session(详见 `agentsScopeHint`)。主 session 指标始终在上方块中;折叠 **Agents** 可节省空间。各子 session 行与主块 **Model** 行同源模型名(侧栏窄时会截断);详见 [docs/zh-CN/design.md](docs/zh-CN/design.md)「子 session 行展示」。 +### 模型 lineage 与指标历史 + +主 Hit、Total Hit 和缓存 TTL 使用当前 provider/model lineage。费用、读取节省、写入溢价和缓存净值按每条消息的 provider/model 单价汇总。可折叠的 **Models** 段展示最近的 lineage。 + +插件会从交互指标中排除 `summary: true` 或 `agent: "compaction"` 的 assistant 消息。启用 `timeline.logSummaryMessages` 时,JSONL 仍保留这些行,并标记 `skippedForMetrics: true`。 + +插件直接请求主 session 历史,最多 10,000 条消息。数据源达到上限或不可用时会回退到 TUI 实时镜像;该镜像最多包含最近 100 条消息。流式速度和 TTFT 使用实时镜像,因此可在调用过程中更新。如果某条消息没有匹配单价,费用行保留 OpenCode 的混合会话费用,不显示不完整的重算结果。 + ### 时间轴日志(`timeline`,默认关闭) -每 assistant 轮次 → JSONL(tokens、cache、cost、TTFT,各工具 `toolDurations`)。详见 [docs/zh-CN/timeline.md](docs/zh-CN/timeline.md)。 +每 assistant 轮次 → JSONL(tokens、cache、cost、TTFT、各工具 `toolDurations`、`skippedForMetrics`)。详见 [docs/zh-CN/timeline.md](docs/zh-CN/timeline.md)。 ```json "timeline": { @@ -168,7 +179,7 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] ### 缓存 TTL(`cacheTTL`,默认开启) -显示 prompt cache 已存活时间。超过 TTL 时颜色变化: +显示当前 provider/model prompt cache 已存活时间。每个模型 lineage 使用独立计时器。超过 TTL 时颜色变化: - 绿色:已存活 < TTL - 黄色:TTL ≤ 已存活 < 2×TTL @@ -254,7 +265,7 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] - `levels`:各时段档的绝对单价,如 `{"peak": {"input": 0.44, "output": 0.88, "cacheRead": 0.01}, "offpeak": {"input": 0.22, ...}}`。缓存单价可写为扁平 `cacheRead`/`cacheWrite`(或 `cache_read`/`cache_write`),也可写为嵌套 `cache: {"read": …, "write": …}`(两种都接受;同时存在时扁平优先)。默认单位为 **USD/百万 token**(与 `state.provider` 一致)。想用其他币种写价时设 `"currency": "CNY"`:要么与展示币种 `cost.currency` 一致(按 `cost.rate` 换算),要么提供模型级 `"rate"`(USD → 该币种,如 EUR 填 1.08)。无法换算时(无 `rate` 且币种 ≠ 展示币种)向 stderr 告警并按 USD 处理。`multipliers` 是倍率,无币种概念。 - `contextThreshold`:覆盖全局阈值的模型级配置(优先于 `state.provider` 的运行时档位阈值)。 -侧边栏单价会在时段边界自动切换(无需轮询)。单价行的 `peak`/`offpeak` 徽标**仅在该模型对当前时段档有定价时**显示(当前档存在于其显式 `levels`/`multipliers` 中,或内置 DeepSeek 默认生效);纯静态价模型、以及未定价的档(如只配 peak 的模型在 offpeak 时刻)不标注。会话成本在动态规则生效时按每条消息的请求时刻 + 上下文档位重算(标注 `≈`);否则使用 OpenCode 自身的 `msg.cost`。子 agent 行按其**会话创建时刻**(`session.list`)做时段计价(任一子会话被重算时 Agents 合计标注 `≈`)。 +侧边栏单价会在时段边界自动切换(无需轮询)。单价行的 `peak`/`offpeak` 徽标**仅在该模型对当前时段档有定价时**显示(当前档存在于其显式 `levels`/`multipliers` 中,或内置 DeepSeek 默认生效);纯静态价模型、以及未定价的档(如只配 peak 的模型在 offpeak 时刻)不标注。会话成本在动态规则生效时按每条消息的 provider/model、请求时刻和上下文档位重算(标注 `≈`);否则使用 OpenCode 自身的 `msg.cost`。如果有消息缺少单价,则费用行保留 OpenCode 自身的混合 `msg.cost`,不显示不完整的重算结果。读取节省、写入溢价和缓存净值使用相同的逐消息单价。子 agent 行按其**会话创建时刻**(`session.list`)做时段计价(任一子会话被重算时 Agents 合计标注 `≈`)。 > [!NOTE] > **迁移提示(星期感知 schedule)**。schedule 现支持可选 `days` 字段(ISO 星期,1=周一 … 7=周日;省略 = 每天)与「**回退档**」(`windows` 为空的 level)。DeepSeek 官方高峰为**周一至周五**北京时间 09:00-12:00 / 14:00-18:00,周末为空闲。内置默认 schedule 已是星期感知写法,上方示例亦然;**未写 `days` 的旧配置保持原语义——周末仍按高峰计费**。要修复,请为 `peak` 窗口补 `"days": [1,2,3,4,5]`(或改用含 `offpeak` 回退档的新默认 schedule)。若你解析 DeepSeek 模型时配置的 schedule 存在窗口级档但均未写 `days`,插件会向 stderr 输出一次性提示。 diff --git a/docs/en/design.md b/docs/en/design.md index aaba56f..6f13f09 100644 --- a/docs/en/design.md +++ b/docs/en/design.md @@ -20,7 +20,7 @@ See the [documentation index](../README.md). flowchart TB subgraph ours [opencode-cache-hit] DISC[Child session discovery] - AGG[Per-message token/cost aggregation] + AGG[Filtered per-message and lineage aggregation] UI[Cache Hit sidebar] end subgraph ref [opencode-visual-cache reference] @@ -45,6 +45,9 @@ flowchart TB - OpenCode: `msg.cost` accumulates assistant messages using **USD** list prices from `opencode.json`. - Plugin: `createCostFormatter(loadPluginConfig().cost)`; default `costUnit: USD` → `currency: CNY`, `rate: 6.77`. - Config file: `~/.config/opencode/cache-hit.json` (preferred) or `cache-hit.config.json` at plugin root (legacy). Defaults in `plugin-config.ts`. +- Interactive metrics exclude messages with `summary: true` or `agent: "compaction"`. +- Main-session lineage metrics group direct-history messages by `providerID:modelID`. Missing metadata uses the `unknown` bucket. +- Mixed-model cost, read savings, write premium, and net cache value use each message's provider/model rates. A dynamic result is marked `≈`. If any message has no matching rate, the cost row keeps OpenCode's reported blended cost. ## Dynamic pricing (`dynamicPricing`) @@ -73,8 +76,9 @@ sequenceDiagram Host->>API: session.list → childIds API-->>Host: message.updated { info: Message } Host->>Host: refreshTick++, timeline.handleMessage(info) - Host->>API: session.get(sid / cid) → aggregates - Host->>API: session.messages(sid / cid) → fallback / trend + Host->>API: session.messages(sid, limit=10000) → main metric history + Host->>API: session.get(cid) / session.messages(cid) → child aggregates + Host->>Host: TUI mirror → streaming speed and TTFT Host->>W: main, messages, subAgents W->>W: aggregate / format / TuiPanel ``` @@ -84,12 +88,16 @@ sequenceDiagram | File | Role | |------|------| | `plugin.tsx` | `api.slots.register` (`order: 56`, next to visual-cache); load config | -| `sidebar-host.tsx` | Bind `sessionId`; `mainSnap` / `mainMessages` / `subAgentList`; `refreshTick` + `message.updated` | +| `sidebar-host.tsx` | Bind `sessionId`; direct main history; `mainSnap` / `mainMessages` / `subAgentList`; `refreshTick` + `message.updated` | | `widget.tsx` | Render panel when `sessionId` set; `noData` when empty | | `use-cache-hit-metrics.ts` | Hit row, trend, main block visibility | | `main-session-view.tsx` / `agents-view.tsx` | Business sections | | `cache-hit-rows.tsx` | Shared token rows in Detail | | `stats.ts` | Pure aggregation (no UI) | +| `session-messages.ts` | Direct main-session history loader and source status | +| `lineage-stats.ts` | Provider/model lineage buckets and active-lineage selection | +| `pricing.ts` | Per-message mixed-model cache value calculations | +| `cache-ttl.ts` / `cache-ttl-view.tsx` | Independent TTL activity per lineage | | `session-list.ts` | Parse `session.list`, `childSessionIdsForParent` | | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | Display formatting (`computeHitBarWidth` lives in `tui-panel/layout.ts`) | | `format-model.ts` | Sub-agent row label (`formatSubAgentLabel`) and vendor brand colors (`modelRowColor`) | @@ -170,23 +178,24 @@ Implementation: `agents-view.tsx` calls `formatSubAgentLabel` + `modelRowColor`; | Data | Trigger | |------|---------| -| Main snapshot | `createMemo` reads `refreshTick` + `session.get?.(sid)` (aggregate); fallback `messages(sid)` | -| Main messages (Hit trend) | `createMemo` reads `refreshTick` + `messages(sid)` | +| Main metric history | Direct `session.messages(sid, limit=10000)`; fallback to the TUI mirror with `capped` or `unavailable` status | +| Main fast messages | `createMemo` reads `refreshTick` + the TUI mirror for streaming speed and TTFT | +| Main snapshot fallback | `session.get?.(sid)` aggregate, then the TUI mirror | | Sub-agent content | `refreshTick` + `childIds` → `session.get?.(cid)` per child; fallback `messages(cid)` | | Sub-agent ids | `session.list` callback / debounced refresh on foreign activity | -`sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so dependent memos recompute; session totals follow `session.get()` aggregates, while per-turn trend follows recent messages. +`sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so dependent memos recompute. Main lineage totals use direct history. Streaming speed and TTFT use the TUI mirror. Child totals use `session.get()` when available. ### session.get() availability -`session.get()` provides DB-level aggregates (not capped at 100 messages), but was added in [opencode#26644](https://github.com/anomalyco/opencode/pull/26644) (2026-05-12). Forks that split before this commit — including MiMo-Code — lack the method. The code uses optional-chaining (`get?.()`) and falls back to `session.messages()`, which returns at most the 100 most recent assistant messages per call. +`session.get()` provides DB-level aggregates (not capped at 100 messages), but was added in [opencode#26644](https://github.com/anomalyco/opencode/pull/26644) (2026-05-12). Forks that split before this commit — including MiMo-Code — lack the method. The main metric path requests direct session messages with a 10,000-message limit. If that request is unavailable or reaches the limit, it falls back to the TUI mirror, which contains at most the 100 most recent assistant messages per call. ### Accumulation rules -- Every `role === assistant` message in the session is included—not only the last turn. +- Every eligible `role === assistant` message in the session is included—not only the last turn. - Streaming updates the same message’s `tokens` repeatedly. - `reasoning` tokens are **excluded** from hit-rate denominator. -- `summary: true`: skipped in `computePerCallHitTrend`; **not** yet excluded in `aggregateSessionFromMessages`. +- `summary: true` and `agent: "compaction"`: excluded from interactive totals, speed, pricing, trend, and TTL selection. ### Sidebar visibility @@ -197,36 +206,39 @@ flowchart TD S -->|yes| P[TuiPanel] P --> D{hasData?} D -->|no| ND[noData] - D -->|yes| MAIN[Main / Detail / Model] + D -->|yes| MAIN[Main / Detail / Model / Models] D -->|has subs| AG[Agents (foldable)] ``` | Concept | Implementation | |---------|----------------| | Whole panel | `widget.tsx`: `Show when={sessionId().length > 0}` | -| Has data | `mainSessionHasStats(main) \|\| subs.length > 0` | +| Has data | `lineages.length > 0 \|\| subs.length > 0` | | Main **block** | Always rendered | | **Agents** | Shown when `subs.length > 0`; foldable | ## Hit rate (current) -**Session total (Total Hit, aligned with visual-cache)** +**Active lineage total (Total Hit)** ``` -Sum input and cache.read over assistant messages +Sum input and cache.read over eligible assistant messages in the active provider/model lineage → cacheRead / (cacheRead + input) ``` +The foldable **Models** section shows one cache ratio and call count per recent lineage. The previous mixed-session aggregate remains available internally for cost fallback, but it is not labeled as the active model's total. + **Header Hit (per-turn + trend)** -- `computePerCallHitTrend`: one rate per assistant turn; skip `summary: true`. -- Display the **last** non-summary turn; compare to previous for ↑ / ↓ / `-`. +- `computePerCallHitTrend`: one rate per eligible assistant turn; skip summary and compaction messages. +- Display `switch` after a lineage change, `warming` for the first eligible call, then compare only same-lineage calls for ↑ / ↓ / `-`. **Pricing & Saved** - Provider pricing is read from `api.state.provider` (SDK runtime data, not hardcoded). -- `computePricing` looks up per-million rates by `providerID` + `modelID`; computes `saved = (inputRate - cacheReadRate) * cacheRead / 1M`. -- Main session: **Saved** row in Detail section; per-million rates (`/M in`, `/M cache`, `/M out`) in Model section. +- `computePricing` looks up active-lineage per-million rates by `providerID` + `modelID`. +- `computeSessionPricing` sums each message's input, cache-read, cache-write, and output rates. Detail shows read savings, write premium, and net cache value. +- Main session cost uses the dynamic per-message result when dynamic rules apply and all eligible messages have rates; otherwise it uses the blended OpenCode message cost. - Agents section: **Saved** row sums savings across all child sessions (`computeSubsSaved`). - All pricing rows hidden when rates are unavailable or saved is zero. diff --git a/docs/en/frontend-migration-plan.md b/docs/en/frontend-migration-plan.md index 10e716e..6ff756a 100644 --- a/docs/en/frontend-migration-plan.md +++ b/docs/en/frontend-migration-plan.md @@ -1,5 +1,7 @@ # Frontend Migration: session.get() Aggregates +> **Historical status:** The `session.get()` migration described here is no longer the primary source for main-session interactive metrics. The cache-lineage implementation now requests direct main-session messages with `limit: 10000`, filters summary and compaction calls, and uses `session.get()` as a fallback for snapshot and child-session data. See [design.md](./design.md). + ## Overview Cache-hit previously derived all cost/token statistics by iterating `api.state.session.messages()` and summing per-message fields. This path is capped at **100 messages** by the OpenCode TUI sync layer ([issue #31513]), silently truncating sessions with >100 messages. diff --git a/docs/en/timeline.md b/docs/en/timeline.md index 97e0153..658e6d6 100644 --- a/docs/en/timeline.md +++ b/docs/en/timeline.md @@ -11,11 +11,11 @@ For developers. Sidebar aggregation: [design.md](./design.md). User guide: [READ | Inspect each assistant call’s tokens / cache / cost / hit % over time | Replace OpenCode platform logs (`~/.local/share/opencode/log`) | | Distinguish main vs child sessions | Spam the TUI with `console.log` | | Local JSONL for `jq` / scripts | Cloud upload or team sharing | -| Same rules as `stats.ts` (including `summary` skip) | SQLite, charts, or recursive sub-agents in v1 | +| Same rules as `stats.ts` (including summary and compaction skips) | SQLite, charts, or recursive sub-agents in v1 | ## Core concept -**One timeline event = one billable assistant turn**, same source as the sidebar **Hit** row—not tool parts or user messages. +**One timeline event = one assistant turn**, with `skippedForMetrics` marking summary or compaction rows. Interactive rows use the same source as the sidebar **Hit** row. The timeline does not record tool parts or user messages. ```mermaid flowchart LR @@ -28,8 +28,8 @@ flowchart LR | Field | Source | |-------|--------| | Sort key | `time.completed ?? time.created` (`timingFromAssistantMessage`) | -| Hit trend eligibility | `summary !== true` and `input + cache.read > 0` | -| Session totals | `aggregateSessionFromMessages` (may later skip `summary` too) | +| Hit trend eligibility | `summary !== true`, `agent !== "compaction"`, and `input + cache.read > 0` | +| Session totals | `aggregateSessionFromMessages` with the interactive-message predicate | ## Data model @@ -55,6 +55,7 @@ export type LlmCallRecord = { cost: number hitPercent: number | null skippedForHit: boolean // compaction / summary + skippedForMetrics: boolean // compaction / summary; raw row remains available ttftMs?: number // Time To First Token (firstPartTime - created) ttftSource?: "sdk" | "tui" // TTFT data source tps?: number // Tokens Per Second ((output + reasoning) / genTime * 1000) @@ -145,10 +146,13 @@ Event-driven path in `sidebar-host.tsx` → `timeline/collector.ts`: Record rules (`assistantMessageToRecord`): - Only `role === assistant`. -- `skippedForHit = msg.summary === true`. +- `skippedForHit = !isInteractiveAssistantMessage(msg)`. +- `skippedForMetrics = msg.summary === true || msg.agent === "compaction"`. - `hitPercent` uses the same per-message logic as `computePerCallHitTrend`. - Child sessions: same pipeline when `handleMessage` is called for a child `sessionID` in `childIds` (no batch merge in v1). +Timeline collection remains separate from sidebar filtering. With `logSummaryMessages: true`, summary and compaction rows stay in JSONL for diagnostics and carry `skippedForMetrics: true`. They do not change hit, token, speed, cost, savings, or TTL metrics. + ## Storage **Default layout** @@ -357,5 +361,5 @@ Default log dir matches `timeline.dir` in plugin config (`~/.local/share/opencod ## Example line ```json -{"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:m1","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false,"ttftMs":944,"ttftSource":"sdk","tps":8.83,"tpot":114.63,"itlP50":12,"itlP90":15,"itlCount":5,"finish":"stop"} +{"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:m1","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false,"skippedForMetrics":false,"ttftMs":944,"ttftSource":"sdk","tps":8.83,"tpot":114.63,"itlP50":12,"itlP90":15,"itlCount":5,"finish":"stop"} ``` diff --git a/docs/zh-CN/design.md b/docs/zh-CN/design.md index 01f6260..ef15bd0 100644 --- a/docs/zh-CN/design.md +++ b/docs/zh-CN/design.md @@ -20,7 +20,7 @@ flowchart TB subgraph ours [opencode-cache-hit] DISC[子 session 发现] - AGG[消息级 token/cost 聚合] + AGG[筛选后的消息级与 lineage 聚合] UI[Cache Hit 侧边栏] end subgraph ref [opencode-visual-cache 参考] @@ -45,6 +45,9 @@ flowchart TB - OpenCode:`msg.cost` = 按 `opencode.json` 中**美元**单价对 assistant 消息累加。 - 插件:`createCostFormatter(loadPluginConfig().cost)`;默认 `costUnit: USD` → `currency: CNY`,`rate: 6.77`。 - 配置路径:优先 `~/.config/opencode/cache-hit.json`,兜底插件根目录 `cache-hit.config.json`。缺省见 `plugin-config.ts` 的 `DEFAULT_PLUGIN_CONFIG`。 +- 交互指标排除 `summary: true` 或 `agent: "compaction"` 的消息。 +- 主 session lineage 指标按 `providerID:modelID` 对直接历史消息分桶;缺少元数据的消息进入 `unknown` 分桶。 +- 混合模型的费用、读取节省、写入溢价和缓存净值使用每条消息自己的 provider/model 单价。动态重算结果标记 `≈`;如果有消息缺少单价,费用行保留 OpenCode 的混合费用。 ## 动态计价(`dynamicPricing`) @@ -73,8 +76,9 @@ sequenceDiagram Host->>API: session.list → childIds API-->>Host: message.updated { info: Message } Host->>Host: refreshTick++, timeline.handleMessage(info) - Host->>API: session.get(sid / cid) → 聚合值 - Host->>API: session.messages(sid / cid) → fallback / 趋势 + Host->>API: session.messages(sid, limit=10000) → 主 session 指标历史 + Host->>API: session.get(cid) / session.messages(cid) → 子 session 聚合 + Host->>Host: TUI 镜像 → 流式速度与 TTFT Host->>W: main, messages, subAgents W->>W: aggregate / format / TuiPanel ``` @@ -84,12 +88,16 @@ sequenceDiagram | 文件 | 职责 | |------|------| | `plugin.tsx` | `api.slots.register`(`order: 56`,紧邻 visual-cache);加载配置与 `formatCost` | -| `sidebar-host.tsx` | 绑定 `sessionId`;`mainSnap` / `mainMessages` / `subAgentList`;`refreshTick` + `message.updated` | +| `sidebar-host.tsx` | 绑定 `sessionId`;主 session 直接历史;`mainSnap` / `mainMessages` / `subAgentList`;`refreshTick` + `message.updated` | | `widget.tsx` | `sessionId` 非空则渲染面板;`hasData` 否则 noData | | `use-cache-hit-metrics.ts` | Hit 条、趋势、Combined Hit、hasData | | `main-session-view.tsx` / `agents-view.tsx` | 业务区块 | | `cache-hit-rows.tsx` | Detail 区共用 token 行 | | `stats.ts` | 纯函数聚合(无 UI) | +| `session-messages.ts` | 主 session 直接历史加载器与数据源状态 | +| `lineage-stats.ts` | provider/model lineage 分桶与当前 lineage 选择 | +| `pricing.ts` | 按消息计算混合模型缓存价值 | +| `cache-ttl.ts` / `cache-ttl-view.tsx` | 按 lineage 独立计算缓存 TTL | | `session-list.ts` | `session.list` 响应解析、`childSessionIdsForParent` | | `format-cost.ts` / `format-tokens.ts` / `format-cache-ui.ts` | 展示格式化(**不含** `computeHitBarWidth`,其在 `tui-panel/layout.ts`) | | `format-model.ts` | 子 agent 行 label(`formatSubAgentLabel`)与厂商品牌色(`modelRowColor`) | @@ -170,23 +178,24 @@ flowchart TD | 数据 | 触发方式 | |------|----------| -| 主 session snapshot | `createMemo` 内读 `refreshTick` + `session.get?.(sid)`(聚合);fallback `messages(sid)` | -| 主 session 消息列表(Hit 趋势) | `createMemo` 内读 `refreshTick` + `messages(sid)` | +| 主 session 指标历史 | 直接请求 `session.messages(sid, limit=10000)`;失败或达到上限时回退到 TUI 镜像,并标记 `capped` 或 `unavailable` | +| 主 session 实时消息 | `createMemo` 读取 `refreshTick` + TUI 镜像,用于流式速度和 TTFT | +| 主 session snapshot 兜底 | `session.get?.(sid)` 聚合值,再回退到 TUI 镜像 | | 子 agent 列表内容 | `refreshTick` + `childIds` → 每个 child 的 `session.get?.(cid)`;fallback `messages(cid)` | | 子 agent id 集合 | `session.list` 完成回调 / `message.updated` 发现新 child | -主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,触发相关 memo 重算;会话总量以 `session.get()` 聚合为准,逐轮趋势使用最近 messages。 +主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,触发相关 memo 重算。主 lineage 总量使用直接历史;流式速度和 TTFT 使用 TUI 镜像;子 session 总量优先使用 `session.get()`。 ### session.get() 可用性 -`session.get()` 提供数据库级聚合(不受 100 条消息限制),但在 [opencode#26644](https://github.com/anomalyco/opencode/pull/26644)(2026-05-12)才加入。在此之前 fork 的分支(含 MiMo-Code)缺少该方法。代码使用可选链调用(`get?.()`),fallback 到 `session.messages()`,后者每次调用最多返回最近 100 条 assistant 消息。 +`session.get()` 提供数据库级聚合(不受 100 条消息限制),但在 [opencode#26644](https://github.com/anomalyco/opencode/pull/26644)(2026-05-12)才加入。在此之前 fork 的分支(含 MiMo-Code)缺少该方法。主指标路径直接请求最多 10,000 条 session 消息;请求不可用或达到上限时回退到 TUI 镜像,后者每次最多包含最近 100 条 assistant 消息。 ### 累加规则 -- **不是**只在「最后一轮」算一次;session 内**每条** `role === assistant` 的消息都进入累加。 +- **不是**只在「最后一轮」算一次;session 内每条符合条件的 `role === assistant` 消息都进入累加。 - **流式中**:同一条 message 的 `tokens` 可能多次变化;每次 `message.updated` 后重算。 - `reasoning` token **不参与**命中率分母。 -- `summary: true` 的 assistant:在 `computePerCallHitTrend` 中**跳过**;会话累计器 `aggregateSessionFromMessages` **暂未**排除。 +- `summary: true` 和 `agent: "compaction"` 的 assistant:从交互总量、速度、费用、趋势和 TTL 选择中排除。 ### 侧边栏可见性(避免与 README 混淆) @@ -197,37 +206,40 @@ flowchart TD S -->|是| P[渲染 TuiPanel] P --> D{hasData?
主或子有统计} D -->|否| ND[noData] - D -->|是| MAIN[Main / Detail / Model] + D -->|是| MAIN[Main / Detail / Model / Models] D -->|有子 agent| AG[Agents 段(可折叠)] ``` | 概念 | 实现 | |------|------| | 整个面板 | `widget.tsx`:`Show when={sessionId().length > 0}` | -| 有无可显示数据 | `hasData` = `mainSessionHasStats(main) \|\| subs.length > 0` | +| 有无可显示数据 | `hasData` = `lineages.length > 0 \|\| subs.length > 0` | | 主 session **区块** | 始终渲染(Hit / Detail / Model) | | **Agents 段** | `subs.length > 0` 时显示,各段可独立折叠 | | `sidebarShouldShow` | `mainSessionHasStats(main) \|\| subs.length > 0`(测试用) | ## 命中率(当前实现) -**会话累计(Total Hit 口径,对齐 visual-cache)** +**当前 lineage 累计(Total Hit)** ``` -对所有 assistant 消息累加 input、cache.read +对当前 provider/model lineage 中符合条件的 assistant 消息累加 input、cache.read → cacheRead / (cacheRead + input) ``` +可折叠的 **Models** 段按最近 lineage 展示独立命中率与调用次数。混合 session 的旧聚合仍可作为费用回退使用,但不会标记为当前模型的 Total Hit。 + **顶栏 Hit(单轮 + 趋势)** -- `computePerCallHitTrend(messages)`:每条 assistant 一轮命中率;`summary: true` 跳过。 -- 展示**最后一条**非 summary 轮的命中率;与前一条比较得趋势(↑ / ↓ / `-`)。 +- `computePerCallHitTrend(messages)`:每条符合条件的 assistant 一轮命中率;跳过 summary 与 compaction。 +- lineage 切换后显示 `switch`,新 lineage 的第一条调用显示 `warming`;只有同 lineage 前序调用存在时才显示 ↑ / ↓ / `-`。 **单价与节省(Pricing & Saved)** - Provider 单价从 `api.state.provider`(SDK 运行时数据)读取,非硬编码。 -- `computePricing` 根据 `providerID` + `modelID` 查找百万 token 单价;计算 `saved = (inputRate - cacheReadRate) * cacheRead / 1M`。 -- 主 session:Detail 段展示 **Saved** 行;Model 段展示百万 token 单价(`/M 输入`、`/M 缓存`、`/M 输出`)。 +- `computePricing` 根据当前 lineage 的 `providerID` + `modelID` 查找百万 token 单价。 +- `computeSessionPricing` 按消息累加输入、缓存读、缓存写和输出单价;Detail 段展示读取节省、写入溢价和缓存净值。 +- 动态规则生效且所有消息都有单价时,主 session 成本使用逐消息重算结果;否则使用 OpenCode 的混合消息成本。 - Agents 段:**Saved** 行汇总所有子 session 的节省金额(`computeSubsSaved`)。 - 单价不可用或节省为零时,所有 pricing 行隐藏。 diff --git a/docs/zh-CN/frontend-migration-plan.md b/docs/zh-CN/frontend-migration-plan.md index cedb9a5..47ecabf 100644 --- a/docs/zh-CN/frontend-migration-plan.md +++ b/docs/zh-CN/frontend-migration-plan.md @@ -1,5 +1,7 @@ # 前端迁移方案:session.get() 聚合数据 +> **历史状态:** 本文描述的 `session.get()` 迁移不再是主 session 交互指标的主要数据源。当前 lineage 实现直接请求主 session 消息(`limit: 10000`),过滤 summary 与 compaction 调用;`session.get()` 仅作为 snapshot 和子 session 数据的兜底。详见 [design.md](./design.md)。 + ## 概述 cache-hit 插件此前通过遍历 `api.state.session.messages()` 逐条累加 per-message 字段来统计 cost/token。这个路径受 OpenCode TUI sync 的 **100 条消息上限** 限制([issue #31513]),超过 100 条消息的会话数据被静默截断。 diff --git a/docs/zh-CN/timeline.md b/docs/zh-CN/timeline.md index 622bbd7..6e06616 100644 --- a/docs/zh-CN/timeline.md +++ b/docs/zh-CN/timeline.md @@ -11,11 +11,11 @@ | 按时间查看每次 assistant 调用的 token / cache / cost / 命中率 | 替代 OpenCode 平台日志(`~/.local/share/opencode/log`) | | 区分主 session 与子 session 的调用 | 在 TUI 里实时 `console.log` 刷屏 | | 本地落盘,便于事后用 jq / 脚本分析 | 上传云端、团队共享 | -| 与现有 `stats.ts` 口径一致(含 `summary` 跳过规则) | 第一期就做 SQLite、图表、递归子 agent | +| 与现有 `stats.ts` 口径一致(含 summary 与 compaction 跳过规则) | 第一期就做 SQLite、图表、递归子 agent | ## 核心概念 -**一条时间轴事件 = 一次「可计费的 assistant 轮次」**,与侧栏顶栏 **Hit** 行同源,不是 tool part、不是 user 消息。 +**一条时间轴事件 = 一次 assistant 轮次**。summary 或 compaction 行通过 `skippedForMetrics` 标记。交互指标行与侧栏顶栏 **Hit** 行同源,不是 tool part、不是 user 消息。 ```mermaid flowchart LR @@ -28,8 +28,8 @@ flowchart LR | 字段 | 来源 | |------|------| | 时间排序键 | `time.completed ?? time.created`(已有 `timingFromAssistantMessage`) | -| 是否计入 Hit 趋势 | `summary !== true` 且 `input + cache.read > 0`(对齐 `computePerCallHitTrend`) | -| 会话累计 | 仍用 `aggregateSessionFromMessages`(可后续让累计也跳过 `summary`) | +| 是否计入 Hit 趋势 | `summary !== true`、`agent !== "compaction"` 且 `input + cache.read > 0` | +| 会话累计 | 使用带 interactive-message predicate 的 `aggregateSessionFromMessages` | ## 数据模型 @@ -62,6 +62,8 @@ export type LlmCallRecord = { hitPercent: number | null /** compaction / summary 消息 */ skippedForHit: boolean + /** compaction / summary 消息;原始行仍可保留 */ + skippedForMetrics: boolean ttftMs?: number // 首 Token 延迟(firstPartTime - created) ttftSource?: "sdk" | "tui" // TTFT 数据来源 tps?: number // 每秒 Token 数((output + reasoning) / genTime * 1000) @@ -152,10 +154,13 @@ export type LlmCallRecord = { 记录规则(`assistantMessageToRecord`): - 只处理 `role === assistant`。 -- `skippedForHit = msg.summary === true`。 +- `skippedForHit = !isInteractiveAssistantMessage(msg)`。 +- `skippedForMetrics = msg.summary === true || msg.agent === "compaction"`。 - `hitPercent` 与 `computePerCallHitTrend` 单条算法一致。 - 子 session:对 `childIds` 中的 `sessionID` 同样走 `handleMessage`(v1 不做批量合并排序)。 +时间轴收集与侧栏过滤相互独立。`logSummaryMessages: true` 时,summary 和 compaction 行仍写入 JSONL,并标记 `skippedForMetrics: true`。这些行不会改变命中率、token、速度、费用、节省或 TTL 指标。 + ## 存储 **默认路径(可配置)** @@ -386,7 +391,7 @@ bun scripts/timeline-dashboard.ts --open ## 示例 JSONL 行 ```json -{"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:m1","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false,"ttftMs":944,"ttftSource":"sdk","tps":8.83,"tpot":114.63,"itlP50":12,"itlP90":15,"itlCount":5,"finish":"stop"} +{"schema":1,"recordedAt":"2024-05-30T08:00:00.000+08:00","sessionId":"sess_main","rootSessionId":"sess_main","scope":"main","messageKey":"sess_main:m1","modelId":"deepseek/v4","created":"2024-05-30T07:59:50.000+08:00","completedAt":"2024-05-30T08:00:00.000+08:00","durationMs":10000,"isComplete":true,"input":1200,"output":80,"reasoning":0,"cacheRead":38000,"cacheWrite":0,"cost":0.012,"hitPercent":96.9,"skippedForHit":false,"skippedForMetrics":false,"ttftMs":944,"ttftSource":"sdk","tps":8.83,"tpot":114.63,"itlP50":12,"itlP90":15,"itlCount":5,"finish":"stop"} ``` --- diff --git a/src/cache-ttl-view.tsx b/src/cache-ttl-view.tsx index 7fd581e..c8b184a 100644 --- a/src/cache-ttl-view.tsx +++ b/src/cache-ttl-view.tsx @@ -7,37 +7,35 @@ import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js" import type { AssistantMessage } from "./types.ts" import { type CacheTTLConfig, DEFAULT_CACHE_TTL } from "./plugin-config.ts" -import { getTTL, formatElapsed, DEFAULT_TTL_MS } from "./cache-ttl.ts" +import { findLastCacheActivityByLineage, getTTL, formatElapsed, DEFAULT_TTL_MS } from "./cache-ttl.ts" import type { PanelPalette, PanelLayout } from "./tui-panel/index.ts" -function findLastCacheActivity(messages: Accessor | undefined): AssistantMessage | null { - const msgs = messages?.() - if (!msgs) return null - for (let i = msgs.length - 1; i >= 0; i--) { - const m = msgs[i] - if ( - m.role === "assistant" && - m.time?.completed !== undefined && - ((m.tokens?.cache?.read ?? 0) > 0 || (m.tokens?.cache?.write ?? 0) > 0) - ) { - return m - } - } - return null -} - export function CacheTTLView(props: { messages?: Accessor + lineageKey?: Accessor + now?: Accessor config?: CacheTTLConfig pal: PanelPalette layout: PanelLayout label: string }) { - const [now, setNow] = createSignal(Date.now()) - const tick = setInterval(() => setNow(Date.now()), 1000) - onCleanup(() => clearInterval(tick)) + const [localNow, setLocalNow] = createSignal(Date.now()) + const tick = props.now ? undefined : setInterval(() => setLocalNow(Date.now()), 1000) + onCleanup(() => { + if (tick !== undefined) clearInterval(tick) + }) - const lastCache = createMemo(() => findLastCacheActivity(props.messages)) + const now = () => props.now?.() ?? localNow() + const activities = createMemo(() => findLastCacheActivityByLineage(props.messages?.() ?? [])) + const lastCache = createMemo(() => { + const key = props.lineageKey?.() + if (key) return activities().get(key) ?? null + let latest: AssistantMessage | null = null + for (const message of activities().values()) { + if (!latest || (message.time?.completed ?? 0) > (latest.time?.completed ?? 0)) latest = message + } + return latest + }) // Self-heal against partial/undefined config reaching this component (see #1, #3): // a stale-cached plugin build may pass { enabled: true } without `providers`. diff --git a/src/cache-ttl.ts b/src/cache-ttl.ts index cf733eb..8bbbb19 100644 --- a/src/cache-ttl.ts +++ b/src/cache-ttl.ts @@ -5,6 +5,8 @@ */ import type { CacheTTLConfig } from "./plugin-config.ts" import { parseDuration } from "./plugin-config.ts" +import { compareAssistantMessages, isInteractiveAssistantMessage, messageLineageKey } from "./stats.ts" +import type { AssistantMessage } from "./types.ts" const SECOND = 1000 const MINUTE = 60 * SECOND @@ -24,6 +26,26 @@ export const BUILT_IN_TTL: Record = { moonshot: 5 * MINUTE, } +export function findLastCacheActivityByLineage( + messages: readonly AssistantMessage[], +): Map { + const result = new Map() + for (const message of messages) { + if ( + message.role !== "assistant" || + !isInteractiveAssistantMessage(message) || + message.time?.completed === undefined || + ((message.tokens?.cache?.read ?? 0) === 0 && (message.tokens?.cache?.write ?? 0) === 0) + ) { + continue + } + const key = messageLineageKey(message) + const previous = result.get(key) + if (!previous || compareAssistantMessages(previous, message) < 0) result.set(key, message) + } + return result +} + // Tolerates undefined/partial config (guards against pre-normalize input; see #3). export function getTTL( providerID: string, diff --git a/src/dynamic-pricing/recompute.ts b/src/dynamic-pricing/recompute.ts index f2727e2..31d8c8e 100644 --- a/src/dynamic-pricing/recompute.ts +++ b/src/dynamic-pricing/recompute.ts @@ -1,4 +1,5 @@ import type { AssistantMessage, ProviderInfo, SubAgentSummary } from "../types.ts" +import { isInteractiveAssistantMessage } from "../stats.ts" import { billingCost } from "./context.ts" import { resolveModelCost } from "./lookup.ts" import type { DynamicPricingConfig } from "./types.ts" @@ -31,6 +32,7 @@ export function recomputeSessionCost( let counted = 0 let dynamic = false for (const msg of messages) { + if (!isInteractiveAssistantMessage(msg)) continue const tokens = msg.tokens if (!tokens) continue const input = tokens.input ?? 0 diff --git a/src/i18n.ts b/src/i18n.ts index 9638f7c..3b7e233 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -4,6 +4,7 @@ export type UiStrings = { title: string hit: string totalHit: string + historyIncomplete: string read: string write: string miss: string @@ -11,6 +12,9 @@ export type UiStrings = { reasoning: string cost: string saved: string + readSavings: string + writePremium: string + netCacheValue: string rate: string rateIn: string rateOut: string @@ -19,7 +23,9 @@ export type UiStrings = { noData: string secDetail: string secModel: string + secLineages: string model: string + unknown: string secAgents: string /** Shown in Agents section header: totals are child sessions only, not the parent session. */ agentsScopeHint: string @@ -30,6 +36,8 @@ export type UiStrings = { avg: string now: string trend: string + switchState: string + warmingState: string ttft: string /** Shown in Speed → Now when no stream is active (not a missing-data dash). */ streamingIdle: string @@ -46,6 +54,7 @@ const EN: UiStrings = { title: "Cache Hit", hit: "Hit", totalHit: "Total Hit:", + historyIncomplete: "* history truncated", read: "Read:", write: "Write:", miss: "Miss:", @@ -53,6 +62,9 @@ const EN: UiStrings = { reasoning: "Reason:", cost: "Cost:", saved: "Saved:", + readSavings: "Read save:", + writePremium: "Write cost:", + netCacheValue: "Net cache:", rate: "Rate:", rateIn: "/M in", rateOut: "/M out", @@ -61,7 +73,9 @@ const EN: UiStrings = { noData: "Waiting for cache data...", secDetail: "Detail", secModel: "Model", + secLineages: "Models", model: "Model:", + unknown: "unknown", secAgents: "Agents", agentsScopeHint: " · sub-sessions", secTTL: "TTL:", @@ -71,6 +85,8 @@ const EN: UiStrings = { avg: "Avg:", now: "Now:", trend: "Trend:", + switchState: "switch", + warmingState: "warming", ttft: "TTFT:", streamingIdle: "·", approx: "≈", @@ -83,6 +99,7 @@ const ZH: UiStrings = { title: "缓存命中", hit: "命中率", totalHit: "总命中:", + historyIncomplete: "* 历史记录可能已截断", read: "缓存读:", write: "缓存写:", miss: "未命中:", @@ -90,6 +107,9 @@ const ZH: UiStrings = { reasoning: "推理:", cost: "费用:", saved: "节省:", + readSavings: "读取节省:", + writePremium: "写入成本:", + netCacheValue: "缓存净值:", rate: "单价:", rateIn: "/M 输入", rateOut: "/M 输出", @@ -98,7 +118,9 @@ const ZH: UiStrings = { noData: "等待缓存数据...", secDetail: "明细", secModel: "模型", + secLineages: "模型", model: "模型:", + unknown: "未知", secAgents: "子 Agent", agentsScopeHint: " · 仅子会话", secTTL: "存活:", @@ -108,6 +130,8 @@ const ZH: UiStrings = { avg: "平均:", now: "实时:", trend: "趋势:", + switchState: "切换", + warmingState: "预热", ttft: "首Token:", streamingIdle: "·", approx: "≈", diff --git a/src/lineage-stats.ts b/src/lineage-stats.ts new file mode 100644 index 0000000..6805c18 --- /dev/null +++ b/src/lineage-stats.ts @@ -0,0 +1,80 @@ +import { + cacheHitRatio, + compareAssistantMessages, + isInteractiveAssistantMessage, + messageLineageKey, + perMessageHitPercent, +} from "./stats.ts" +import type { AssistantMessage, LineageBucket } from "./types.ts" + +function emptyBucket(key: string, providerID: string, modelID: string): LineageBucket { + return { + key, + providerID, + modelID, + callCount: 0, + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + cacheRatio: 0, + agentCounts: {}, + } +} + +export function aggregateLineages(messages: readonly AssistantMessage[]): LineageBucket[] { + const buckets = new Map() + const eligible = messages + .filter((message) => message.role === "assistant" && isInteractiveAssistantMessage(message)) + .slice() + .sort(compareAssistantMessages) + + for (const message of eligible) { + const providerID = message.providerID ?? "" + const modelID = message.modelID ?? "" + const key = messageLineageKey(message) + const bucket = buckets.get(key) ?? emptyBucket(key, providerID, modelID) + const tokens = message.tokens ?? {} + const agent = message.agent ?? "unknown" + bucket.callCount += 1 + bucket.input += tokens.input ?? 0 + bucket.output += tokens.output ?? 0 + bucket.reasoning += tokens.reasoning ?? 0 + bucket.cacheRead += tokens.cache?.read ?? 0 + bucket.cacheWrite += tokens.cache?.write ?? 0 + bucket.cost += message.cost ?? 0 + bucket.agentCounts[agent] = (bucket.agentCounts[agent] ?? 0) + 1 + bucket.cacheRatio = cacheHitRatio(bucket.cacheRead, bucket.input) + bucket.lastCall = { + id: message.id ?? message.messageID, + created: message.time?.created, + completed: message.time?.completed, + agent: message.agent, + hitPercent: perMessageHitPercent(message), + } + buckets.set(key, bucket) + } + + return [...buckets.values()] +} + +export function activeLineageKey(messages: readonly AssistantMessage[]): string | undefined { + const eligible = messages + .filter((message) => message.role === "assistant" && isInteractiveAssistantMessage(message)) + .slice() + .sort(compareAssistantMessages) + const last = eligible[eligible.length - 1] + return last ? messageLineageKey(last) : undefined +} + +export function recentLineages(buckets: readonly LineageBucket[]): LineageBucket[] { + return buckets + .slice() + .sort((a, b) => { + const aTime = a.lastCall?.completed ?? a.lastCall?.created ?? -Infinity + const bTime = b.lastCall?.completed ?? b.lastCall?.created ?? -Infinity + return bTime - aTime || a.key.localeCompare(b.key) + }) +} diff --git a/src/main-session-view.tsx b/src/main-session-view.tsx index 3f88328..6345715 100644 --- a/src/main-session-view.tsx +++ b/src/main-session-view.tsx @@ -1,15 +1,17 @@ /** @jsxImportSource @opentui/solid */ -import { Show, createMemo } from "solid-js" +import { Show, createMemo, createSignal, onCleanup } from "solid-js" import { TokenDetailRows } from "./cache-hit-rows.tsx" import { CacheTTLView } from "./cache-ttl-view.tsx" import { formatStreamingNowDisplay, type StreamingPhase } from "./streaming-state.ts" import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts" import type { CacheTTLConfig } from "./plugin-config.ts" import type { AssistantMessage } from "./types.ts" +import { formatRatioAsPercent } from "./format-cache-ui.ts" import { TuiHitRow, TuiMetricRow, TuiSection, + truncateVisual, type PanelLayout, type SectionFold, } from "./tui-panel/index.ts" @@ -21,6 +23,7 @@ export function MainSessionView(props: { detail: SectionFold speed: SectionFold model: SectionFold + lineages: SectionFold showSpeed: boolean streamingNow: Accessor<{ phase: StreamingPhase; speed: number }> formatCost: (n: number) => string @@ -29,6 +32,11 @@ export function MainSessionView(props: { messages?: Accessor }) { const { m, layout } = props + const [ttlNow, setTtlNow] = createSignal(Date.now()) + const ttlTimer = props.cacheTTL?.enabled ? setInterval(() => setTtlNow(Date.now()), 1000) : undefined + onCleanup(() => { + if (ttlTimer !== undefined) clearInterval(ttlTimer) + }) const streamingNowRow = createMemo(() => { const now = props.streamingNow() return formatStreamingNowDisplay(now.phase, now.speed, m.t().streamingIdle, m.useTps()) @@ -36,9 +44,11 @@ export function MainSessionView(props: { /** Show the recomputed cost (≈ prefix) when dynamic rules apply, else OpenCode's msg.cost. */ const shownCost = createMemo(() => { - const rec = m.recomputedCost() - if (rec && rec.dynamic) return { value: rec.cost, approx: true } - return { value: m.main().cost, approx: false } + const pricing = m.sessionPricing() + if (pricing.counted > 0 && pricing.dynamic && pricing.unpriced === 0) { + return { value: pricing.cost, approx: true } + } + return { value: m.blendedMain().cost, approx: false } }) const rateLabel = createMemo(() => { @@ -58,13 +68,18 @@ export function MainSessionView(props: { barColor={m.hitColor()} textColor={m.pal().text} trend={ - m.perCall().hasTrend ? { text: m.trendLabel(), color: m.trendFg() } : undefined + m.trendLabel() ? { text: m.trendLabel(), color: m.trendFg() } : undefined } /> + + {m.t().historyIncomplete} + - + - 0}> + + + + + + 0 ? m.pal().success : m.pal().error} + /> + @@ -184,6 +217,50 @@ export function MainSessionView(props: { /> + + 1}> + + {m.recentLineages().map((lineage) => { + const model = lineage.modelID ? `${lineage.providerID}/${lineage.modelID.split("/").pop()}` : m.t().unknown + const agents = Object.entries(lineage.agentCounts) + .map(([agent, count]) => `${agent}:${count}`) + .join(",") + const label = truncateVisual( + agents ? `${model} ${agents}` : model, + Math.max(8, layout.gauge() - 10), + ) + return ( + <> + + + lineage.key} + now={ttlNow} + config={props.cacheTTL} + pal={m.pal()} + layout={layout} + label={`${m.t().secTTL} ${truncateVisual(model, Math.max(8, layout.gauge() - 8))}`} + /> + + + ) + })} + + ) } diff --git a/src/pricing.ts b/src/pricing.ts index 6cf971c..474801c 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -1,7 +1,8 @@ -import type { ModelCost, ProviderInfo, SubAgentSummary } from "./types.ts" +import type { AssistantMessage, ModelCost, ProviderInfo, SubAgentSummary } from "./types.ts" import type { DynamicPricingConfig } from "./dynamic-pricing/types.ts" import { lookupModelCost, resolveModelCost } from "./dynamic-pricing/lookup.ts" import { billingCost } from "./dynamic-pricing/context.ts" +import { isInteractiveAssistantMessage } from "./stats.ts" export type { ModelCost } from "./types.ts" export { lookupModelCost } from "./dynamic-pricing/lookup.ts" @@ -29,6 +30,26 @@ export const EMPTY_PRICING: PricingInfo = { dynamic: false, } +export type SessionPricing = { + cost: number + readSavings: number + writePremium: number + netCacheValue: number + counted: number + unpriced: number + dynamic: boolean +} + +export const EMPTY_SESSION_PRICING: SessionPricing = { + cost: 0, + readSavings: 0, + writePremium: 0, + netCacheValue: 0, + counted: 0, + unpriced: 0, + dynamic: false, +} + export type PricingContext = { /** Current time (ms) for time-of-day matching. Default Date.now(). */ now?: number @@ -38,6 +59,47 @@ export type PricingContext = { rules?: DynamicPricingConfig } +export function computeSessionPricing( + messages: readonly AssistantMessage[], + providers: ReadonlyArray, + rules?: DynamicPricingConfig, +): SessionPricing { + const result = { ...EMPTY_SESSION_PRICING } + for (const message of messages) { + if (message.role !== "assistant" || !isInteractiveAssistantMessage(message)) continue + const tokens = message.tokens + if (!tokens) { + if ((message.cost ?? 0) !== 0) result.unpriced += 1 + continue + } + const input = tokens.input ?? 0 + const output = tokens.output ?? 0 + const cacheRead = tokens.cache?.read ?? 0 + const cacheWrite = tokens.cache?.write ?? 0 + if (input + output + cacheRead + cacheWrite === 0) { + if ((message.cost ?? 0) !== 0) result.unpriced += 1 + continue + } + const resolved = resolveModelCost(providers, message.providerID ?? "", message.modelID ?? "", { + now: message.time?.created, + contextTokens: input + cacheRead, + rules, + }) + if (!resolved) { + result.unpriced += 1 + continue + } + const rates = resolved.rates + result.cost += billingCost(rates, input, output, cacheRead, cacheWrite) + result.readSavings += (cacheRead * (rates.input - rates.cache.read)) / 1_000_000 + result.writePremium += (cacheWrite * (rates.cache.write - rates.input)) / 1_000_000 + result.counted += 1 + result.dynamic ||= resolved.explicit + } + result.netCacheValue = result.readSavings - result.writePremium + return result +} + export function computePricing( providers: ReadonlyArray, providerID: string | undefined, diff --git a/src/session-messages.ts b/src/session-messages.ts new file mode 100644 index 0000000..3c83b71 --- /dev/null +++ b/src/session-messages.ts @@ -0,0 +1,76 @@ +import type { AssistantMessage } from "./types.ts" + +/** The API has no total-count field, so keep the request well above the TUI mirror cap. */ +export const SESSION_MESSAGE_LIMIT = 10_000 + +export type SessionMessageLoadStatus = "complete" | "capped" | "unavailable" + +export type SessionMessageLoadResult = { + messages: AssistantMessage[] + status: SessionMessageLoadStatus + source: "direct" | "mirror" + reason?: "missing-client" | "request-failed" | "malformed-response" | "limit-reached" +} + +type SessionMessagesClient = { + messages?: (opts: { + path: { id: string } + query: { directory: string; limit: number } + }) => Promise +} + +function responseEntries(raw: unknown): unknown[] | null { + if (Array.isArray(raw)) return raw + if (!raw || typeof raw !== "object") return null + const data = (raw as { data?: unknown }).data + return Array.isArray(data) ? data : null +} + +function normalizeEntries(entries: readonly unknown[]): AssistantMessage[] | null { + const messages: AssistantMessage[] = [] + for (const entry of entries) { + if (!entry || typeof entry !== "object") return null + const info = (entry as { info?: unknown }).info + if (!info || typeof info !== "object") return null + const message = info as AssistantMessage + if (typeof message.role !== "string") return null + if (message.role === "assistant") messages.push(message) + } + return messages +} + +export async function loadSessionMessages(opts: { + client: SessionMessagesClient + sessionId: string + directory: string + fallback: readonly AssistantMessage[] + limit?: number +}): Promise { + const fallback = [...opts.fallback] + const request = opts.client.messages + if (!request) { + return { messages: fallback, status: "unavailable", source: "mirror", reason: "missing-client" } + } + + let raw: unknown + try { + raw = await request({ + path: { id: opts.sessionId }, + query: { directory: opts.directory, limit: opts.limit ?? SESSION_MESSAGE_LIMIT }, + }) + } catch { + return { messages: fallback, status: "unavailable", source: "mirror", reason: "request-failed" } + } + + const entries = responseEntries(raw) + const messages = entries ? normalizeEntries(entries) : null + if (!entries || !messages) { + return { messages: fallback, status: "unavailable", source: "mirror", reason: "malformed-response" } + } + + const limit = opts.limit ?? SESSION_MESSAGE_LIMIT + if (entries.length >= limit) { + return { messages, status: "capped", source: "direct", reason: "limit-reached" } + } + return { messages, status: "complete", source: "direct" } +} diff --git a/src/sidebar-host.tsx b/src/sidebar-host.tsx index 8fa5872..9c5c330 100644 --- a/src/sidebar-host.tsx +++ b/src/sidebar-host.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, createMemo, createEffect, onCleanup } from "solid-js" +import { createSignal, createMemo, createEffect, onCleanup, untrack } from "solid-js" import { CacheHitSidebar } from "./widget.tsx" import type { DisplayConfig, TimelineConfig, CacheTTLConfig, DynamicPricingConfig } from "./plugin-config.ts" import { isToolSummaryEnabled } from "./plugin-config.ts" @@ -17,6 +17,7 @@ import type { OpenCodeTuiApi, SubAgentSummary, } from "./types.ts" +import { loadSessionMessages, type SessionMessageLoadStatus } from "./session-messages.ts" import { emptySessionSnapshot, aggregateFromSessionObject, @@ -62,6 +63,10 @@ export function CacheHitSidebarHost(props: { const [refreshTick, setRefreshTick] = createSignal(0) const [childIds, setChildIds] = createSignal([]) const [childEntries, setChildEntries] = createSignal([]) + const [historyMessages, setHistoryMessages] = createSignal([]) + const [historyStatus, setHistoryStatus] = createSignal("unavailable") + const pendingHistoryUpdates = new Map() + let historyLoadGeneration = 0 /** Re-read cache-hit.config.json when parent session changes (picks up edits without full plugin reload). */ const runtimeConfig = createMemo(() => { @@ -133,6 +138,39 @@ export function CacheHitSidebarHost(props: { return (props.api.state.session.messages(sid) ?? []) as AssistantMessage[] }) + const mergeHistoryMessage = (messages: readonly AssistantMessage[], update: AssistantMessage) => { + const id = update.id ?? update.messageID + const next = [...messages] + const index = id ? next.findIndex((message) => (message.id ?? message.messageID) === id) : -1 + if (index >= 0) next[index] = update + else next.push(update) + next.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0)) + return next + } + + const loadHistory = (sid: string) => { + const generation = ++historyLoadGeneration + pendingHistoryUpdates.clear() + const mirror = untrack(mainMessages) + setHistoryMessages(mirror) + setHistoryStatus("unavailable") + if (!sid) return + void loadSessionMessages({ + client: props.api.client.session, + sessionId: sid, + directory: props.api.state.path.directory, + fallback: mirror, + }).then((result) => { + if (generation !== historyLoadGeneration) return + let merged = result.messages + for (const update of pendingHistoryUpdates.values()) { + merged = mergeHistoryMessage(merged, update) + } + setHistoryMessages(merged) + setHistoryStatus(result.status) + }) + } + const subAgentList = createMemo(() => { void refreshTick() const useTps = display().speedUnit === "tps" @@ -234,6 +272,7 @@ export function CacheHitSidebarHost(props: { itlTracker.reset() streamingTickState = initialStreamingTickState() setStreamingNow({ phase: "idle", speed: 0 }) + loadHistory(sid) if (sid) { childSync.loadChildren() } @@ -249,6 +288,10 @@ export function CacheHitSidebarHost(props: { seedTtftFromParts(msg) } if (sid && msg) { + if (sid === props.sessionId && msg.role === "assistant") { + pendingHistoryUpdates.set(msg.id ?? msg.messageID ?? String(msg.time?.created ?? Date.now()), msg) + setHistoryMessages((messages) => mergeHistoryMessage(messages, msg)) + } timeline.handleMessage(sid, msg) } }) @@ -304,6 +347,8 @@ export function CacheHitSidebarHost(props: { cacheTTL={cacheTTL()} dynamicPricing={dynamicPricing()} messages={mainMessages} + metricMessages={() => historyMessages()} + metricMessageStatus={() => historyStatus()} main={mainSnap} subAgents={subAgentList} providers={() => props.api.state.provider ?? []} diff --git a/src/stats.ts b/src/stats.ts index 86dbc14..af34104 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -39,7 +39,7 @@ export function aggregateSessionFromMessages(messages: readonly AssistantMessage cacheWrite = 0, cost = 0 for (const msg of messages) { - if (msg.role !== "assistant") continue + if (msg.role !== "assistant" || !isInteractiveAssistantMessage(msg)) continue const t = msg.tokens ?? {} input += t.input ?? 0 output += t.output ?? 0 @@ -139,11 +139,33 @@ export type PerCallHitTrend = { hitPercent: number trendPercent: number hasTrend: boolean + state: "steady" | "switch" | "warming" +} + +export const UNKNOWN_LINEAGE_KEY = "unknown" + +/** Assistant turns that represent an interactive, billable model call. */ +export function isInteractiveAssistantMessage(msg: AssistantMessage): boolean { + return msg.summary !== true && msg.agent !== "compaction" +} + +export function messageLineageKey(msg: AssistantMessage): string { + return msg.providerID && msg.modelID ? `${msg.providerID}:${msg.modelID}` : UNKNOWN_LINEAGE_KEY +} + +export function compareAssistantMessages(a: AssistantMessage, b: AssistantMessage): number { + const aCompleted = a.time?.completed ?? -Infinity + const bCompleted = b.time?.completed ?? -Infinity + if (aCompleted !== bCompleted) return aCompleted - bCompleted + const aCreated = a.time?.created ?? -Infinity + const bCreated = b.time?.created ?? -Infinity + if (aCreated !== bCreated) return aCreated - bCreated + return (a.id ?? a.messageID ?? "").localeCompare(b.id ?? b.messageID ?? "") } /** Single assistant turn hit % (0–100), or null if skipped / no denominator. */ export function perMessageHitPercent(msg: AssistantMessage): number | null { - if (msg.role !== "assistant" || msg.summary === true) return null + if (msg.role !== "assistant" || !isInteractiveAssistantMessage(msg)) return null const t = msg.tokens if (!t) return null const input = t.input ?? 0 @@ -155,21 +177,31 @@ export function perMessageHitPercent(msg: AssistantMessage): number | null { /** * Per-turn hit rates for the top Hit row (visual-cache). - * Skips `summary: true` assistant messages — not full LLM pricing turns. + * Skips summary and compaction assistant messages — not full LLM pricing turns. */ export function computePerCallHitTrend(messages: readonly AssistantMessage[]): PerCallHitTrend { - let prevHit = -1 - let lastHit = -1 - for (const msg of messages) { - const hit = perMessageHitPercent(msg) - if (hit === null) continue - prevHit = lastHit - lastHit = hit + const calls = messages + .filter((msg) => msg.role === "assistant" && isInteractiveAssistantMessage(msg)) + .slice() + .sort(compareAssistantMessages) + .map((msg) => ({ + msg, + hit: perMessageHitPercent(msg), + })) + .filter((call) => call.hit !== null) + const last = calls[calls.length - 1] + if (!last) { + return { hitPercent: 0, trendPercent: 0, hasTrend: false, state: "warming" } } + const previous = calls[calls.length - 2] + const hit = last.hit ?? 0 + const switched = Boolean(previous && messageLineageKey(last.msg) !== messageLineageKey(previous.msg)) + const state = !previous ? "warming" : switched ? "switch" : "steady" return { - hitPercent: lastHit >= 0 ? lastHit : 0, - trendPercent: prevHit >= 0 && lastHit >= 0 ? lastHit - prevHit : 0, - hasTrend: prevHit >= 0 && lastHit >= 0, + hitPercent: hit, + trendPercent: previous && !switched ? hit - (previous.hit ?? 0) : 0, + hasTrend: Boolean(previous && !switched), + state, } } diff --git a/src/timeline/collector.ts b/src/timeline/collector.ts index c3ed996..b3aebb0 100644 --- a/src/timeline/collector.ts +++ b/src/timeline/collector.ts @@ -4,6 +4,7 @@ import type { ToolTimingTracker } from "../tool-timing.ts" import type { TimelineConfig } from "../plugin-config.ts" import type { AssistantMessage } from "../types.ts" import { assistantMessageToRecord } from "./records.ts" +import { isInteractiveAssistantMessage } from "../stats.ts" import { appendTimelineRecord, localDateKey, @@ -82,7 +83,7 @@ export function createTimelineCollector(opts: { } if (msg.role !== "assistant") return - if (!config.logSummaryMessages && msg.summary === true) return + if (!config.logSummaryMessages && !isInteractiveAssistantMessage(msg)) return maybePurge(config) diff --git a/src/timeline/records.ts b/src/timeline/records.ts index ca1cc88..96e95d6 100644 --- a/src/timeline/records.ts +++ b/src/timeline/records.ts @@ -1,5 +1,5 @@ import { generationDurationMs, timingFromAssistantMessage } from "../message-timing.ts" -import { perMessageHitPercent } from "../stats.ts" +import { isInteractiveAssistantMessage, perMessageHitPercent } from "../stats.ts" import type { AssistantMessage } from "../types.ts" import type { LlmCallRecord } from "./types.ts" import type { ToolDurationRecord } from "../tool-timing.ts" @@ -43,7 +43,7 @@ export function assistantMessageToRecord( const timing = timingFromAssistantMessage(msg) if (!timing) return null const t = msg.tokens ?? {} - const skippedForHit = msg.summary === true + const skippedForMetrics = !isInteractiveAssistantMessage(msg) const output = t.output ?? 0 const reasoning = t.reasoning ?? 0 const tokens = output + reasoning @@ -77,7 +77,8 @@ export function assistantMessageToRecord( cacheWrite: t.cache?.write ?? 0, cost: msg.cost ?? 0, hitPercent: perMessageHitPercent(msg), - skippedForHit, + skippedForHit: skippedForMetrics, + skippedForMetrics, ttftMs, ttftSource, tps, @@ -89,4 +90,3 @@ export function assistantMessageToRecord( toolDurations, } } - diff --git a/src/timeline/types.ts b/src/timeline/types.ts index e86423c..240a83f 100644 --- a/src/timeline/types.ts +++ b/src/timeline/types.ts @@ -25,6 +25,8 @@ export type LlmCallRecord = { dynCost?: number hitPercent: number | null skippedForHit: boolean + /** True for summary/compaction rows kept for diagnostics but excluded from metrics. */ + skippedForMetrics: boolean ttftMs?: number ttftSource?: "sdk" | "tui" tps?: number diff --git a/src/token-speed.ts b/src/token-speed.ts index aff721b..90aa0ce 100644 --- a/src/token-speed.ts +++ b/src/token-speed.ts @@ -1,5 +1,6 @@ import { generationDurationMs, timingFromAssistantMessage } from "./message-timing.ts" import type { AssistantMessage } from "./types.ts" +import { isInteractiveAssistantMessage } from "./stats.ts" export function computeTokenSpeed(output: number, reasoning: number, durationMs: number): number { if (durationMs < 500) return 0 @@ -24,7 +25,7 @@ export function computeAvgTokenSpeed( let totalTokens = 0 let totalMs = 0 for (const msg of messages) { - if (msg.summary) continue + if (!isInteractiveAssistantMessage(msg)) continue const timing = timingFromAssistantMessage(msg) if (!timing?.isComplete) continue const output = msg.tokens?.output ?? 0 @@ -47,7 +48,7 @@ export function computeAvgTokenTpotMs( let totalGenerationMs = 0 let totalTokenIntervals = 0 for (const msg of messages) { - if (msg.summary) continue + if (!isInteractiveAssistantMessage(msg)) continue const timing = timingFromAssistantMessage(msg) if (!timing?.isComplete) continue const output = msg.tokens?.output ?? 0 diff --git a/src/types.ts b/src/types.ts index d880a7e..aa8dbb0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ export type SessionSnapshot = { + /** Provider/model lineage when this snapshot represents one lineage. */ + lineageKey?: string model: string providerID: string input: number @@ -9,6 +11,30 @@ export type SessionSnapshot = { cost: number } +export type LineageCall = { + id?: string + created?: number + completed?: number + agent?: string + hitPercent: number | null +} + +export type LineageBucket = { + key: string + providerID: string + modelID: string + callCount: number + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + cost: number + cacheRatio: number + lastCall?: LineageCall + agentCounts: Record +} + export type SubAgentSummary = { id: string model: string @@ -30,6 +56,7 @@ export type AssistantMessage = { messageID?: string modelID?: string providerID?: string + agent?: string cost?: number /** OpenCode SDK: true = summary/compaction message, not a full LLM pricing turn */ summary?: boolean @@ -115,6 +142,10 @@ export type OpenCodeTuiApi = { client: { session: { list: (opts: { query: { directory: string } }) => Promise + messages?: (opts: { + path: { id: string } + query: { directory: string; limit: number } + }) => Promise } } event: { diff --git a/src/use-cache-hit-metrics.ts b/src/use-cache-hit-metrics.ts index 97e3299..e79e5ce 100644 --- a/src/use-cache-hit-metrics.ts +++ b/src/use-cache-hit-metrics.ts @@ -11,15 +11,19 @@ import { computeHitBarWidth, visualWidth } from "./tui-panel/layout.ts" import { buildPanelPalette, type PanelPalette } from "./tui-panel/palette.ts" import type { PanelLayout } from "./tui-panel/use-panel-layout.ts" import type { AssistantMessage, ProviderInfo, SessionSnapshot, SubAgentSummary } from "./types.ts" +import type { SessionMessageLoadStatus } from "./session-messages.ts" import { + aggregateSessionFromMessages, cacheHitRatio, computePerCallHitTrend, emptySessionSnapshot, + isInteractiveAssistantMessage, mainSessionHasStats, shortModelName, } from "./stats.ts" -import { computePricing, computeSubsSaved, type PricingInfo } from "./pricing.ts" -import { recomputeSessionCost, recomputeSubAgentCost } from "./dynamic-pricing/recompute.ts" +import { activeLineageKey, aggregateLineages, recentLineages } from "./lineage-stats.ts" +import { computePricing, computeSessionPricing, computeSubsSaved, type PricingInfo } from "./pricing.ts" +import { recomputeSubAgentCost } from "./dynamic-pricing/recompute.ts" import { nextBoundaryMs } from "./dynamic-pricing/schedule.ts" import { computeAvgTokenTpotMs, @@ -48,6 +52,8 @@ export function useCacheHitMetrics(props: { theme: Accessor> display: DisplayConfig messages: Accessor + metricMessages?: Accessor + metricMessageStatus?: Accessor main: Accessor subAgents: Accessor providers: Accessor> @@ -59,8 +65,27 @@ export function useCacheHitMetrics(props: { const t = createMemo(() => getUiStrings(activeLang(props.display))) const hitLabel = createMemo(() => props.display.mainHitLabel ?? t().hit) const subs = createMemo(() => props.subAgents()) - const main = createMemo(() => props.main() ?? emptySessionSnapshot()) - const perCall = createMemo(() => computePerCallHitTrend(props.messages())) + const metricInput = () => props.metricMessages?.() ?? props.messages() + const lineages = createMemo(() => aggregateLineages(metricInput())) + const activeKey = createMemo(() => activeLineageKey(metricInput())) + const activeLineage = createMemo(() => lineages().find((lineage) => lineage.key === activeKey())) + const blendedMain = createMemo(() => aggregateSessionFromMessages(metricInput())) + const main = createMemo(() => { + const active = activeLineage() + if (!active) return aggregateSessionFromMessages(metricInput()) + return { + lineageKey: active.key, + model: active.modelID, + providerID: active.providerID, + input: active.input, + output: active.output, + reasoning: active.reasoning, + cacheRead: active.cacheRead, + cacheWrite: active.cacheWrite, + cost: active.cost, + } + }) + const perCall = createMemo(() => computePerCallHitTrend(metricInput())) const sessionRatio = createMemo(() => cacheHitRatio(main().cacheRead, main().input)) // Dynamic pricing: precise refresh at schedule boundaries (no polling). @@ -91,6 +116,9 @@ export function useCacheHitMetrics(props: { rules: props.dynamicPricing, }), ) + const sessionPricing = createMemo(() => + computeSessionPricing(metricInput(), props.providers(), props.dynamicPricing), + ) // Sub-agent cache savings: level by current time-of-day + each child's total input (input + cacheRead). const subsSaved = createMemo(() => @@ -100,11 +128,6 @@ export function useCacheHitMetrics(props: { }), ) - // Dynamic cost recompute (per-message request time + context tier); unpriced → null. - const recomputedCost = createMemo(() => - recomputeSessionCost(props.messages(), props.providers(), props.dynamicPricing), - ) - // Sub-agent dynamic cost (session creation time + aggregate tokens); no created / unpriced → null. const subAgentDynamicCosts = createMemo(() => { const rules = props.dynamicPricing @@ -115,26 +138,30 @@ export function useCacheHitMetrics(props: { }) const mainHasStats = createMemo(() => mainSessionHasStats(main())) - const hasData = createMemo(() => mainHasStats() || subs().length > 0) + const hasData = createMemo(() => lineages().length > 0 || subs().length > 0) - const trendLabel = createMemo(() => - perCall().hasTrend ? formatTrendLabel(perCall().trendPercent) : "", - ) + const trendLabel = createMemo(() => { + if (perCall().state === "switch") return t().switchState + if (perCall().state === "warming") return t().warmingState + return perCall().hasTrend ? formatTrendLabel(perCall().trendPercent) : "" + }) const bar = createMemo(() => formatHitBar( perCall().hitPercent / 100, - computeHitBarWidth(hitLabel(), props.layout.gauge(), trendLabel(), perCall().hasTrend), + computeHitBarWidth(hitLabel(), props.layout.gauge(), trendLabel(), trendLabel().length > 0), ), ) const hitColor = createMemo(() => hitRateColor(perCall().hitPercent, pal())) const trendFg = createMemo(() => { + if (perCall().state === "switch") return pal().warning + if (perCall().state === "warming") return pal().muted const tr = perCall().trendPercent if (Math.abs(tr) < 0.05) return pal().text return tr > 0 ? pal().success : pal().error }) const collapsedHitSummary = createMemo(() => { - const right = perCall().hasTrend + const right = trendLabel() ? `${formatPercentOneDecimal(perCall().hitPercent)} ${t().hitFolded} ${trendLabel()}` : `${formatPercentOneDecimal(perCall().hitPercent)} ${t().hitFolded}` return { text: right, width: visualWidth(right) } @@ -146,7 +173,7 @@ export function useCacheHitMetrics(props: { const msgs = props.messages() const firstPartTime = props.firstPartTime() for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].summary) continue + if (!isInteractiveAssistantMessage(msgs[i])) continue const timing = timingFromAssistantMessage(msgs[i]) if (!timing?.isComplete) continue const output = msgs[i].tokens?.output ?? 0 @@ -176,7 +203,7 @@ export function useCacheHitMetrics(props: { const msgs = props.messages() const firstPartTime = props.firstPartTime() const records = msgs - .filter((msg) => msg.role === "assistant" && !msg.summary && msg.time?.completed) + .filter((msg) => isInteractiveAssistantMessage(msg) && msg.time?.completed) .map((msg) => { const timing = timingFromAssistantMessage(msg) const msgID = msg.id ?? msg.messageID @@ -203,7 +230,7 @@ export function useCacheHitMetrics(props: { const lastTtft = createMemo(() => { const msgs = props.messages() for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].summary) continue + if (!isInteractiveAssistantMessage(msgs[i])) continue const msgID = msgs[i].id ?? msgs[i].messageID if (!msgID) continue const firstTime = props.firstPartTime().get(msgID) @@ -229,9 +256,17 @@ export function useCacheHitMetrics(props: { hitLabel, subs, main, + blendedMain, + lineages, + activeLineage, + activeLineageKey: activeKey, + metricMessages: metricInput, + recentLineages: createMemo(() => recentLineages(lineages())), + metricMessageStatus: () => props.metricMessageStatus?.() ?? "complete", mainHasStats, perCall, pricing, + sessionPricing, sessionPct: createMemo(() => formatRatioAsPercent(sessionRatio())), hasData, @@ -242,7 +277,6 @@ export function useCacheHitMetrics(props: { pctLabel: createMemo(() => formatPercentOneDecimal(perCall().hitPercent)), modelShort: createMemo(() => shortModelName(main().model)), totalSubCost: createMemo(() => subs().reduce((s, a) => s + a.cost, 0)), - recomputedCost, subAgentDynamicCosts, subsSaved, collapsedHitSummary, diff --git a/src/widget.tsx b/src/widget.tsx index 4216dc7..fb4dc58 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -2,6 +2,7 @@ import { createMemo, createSignal, Show, type Accessor } from "solid-js" import type { DisplayConfig, CacheTTLConfig, DynamicPricingConfig } from "./plugin-config.ts" import type { AssistantMessage, ProviderInfo, SessionSnapshot, SubAgentSummary } from "./types.ts" +import type { SessionMessageLoadStatus } from "./session-messages.ts" import type { StreamingPhase } from "./streaming-state.ts" import { PLUGIN_VERSION } from "./version.ts" import { AgentsView } from "./agents-view.tsx" @@ -28,6 +29,8 @@ export function CacheHitSidebar(props: { cacheTTL: CacheTTLConfig dynamicPricing: DynamicPricingConfig messages: Accessor + metricMessages?: Accessor + metricMessageStatus?: Accessor main: Accessor subAgents: Accessor providers: Accessor> @@ -40,6 +43,7 @@ export function CacheHitSidebar(props: { const detail = createSectionFold(true) const speed = createSectionFold(true) const model = createSectionFold(true) + const lineages = createSectionFold(true) const agents = createSectionFold(true) const borderOn = () => props.display.panelBorder @@ -49,6 +53,8 @@ export function CacheHitSidebar(props: { theme: () => props.theme, display: props.display, messages: props.messages, + metricMessages: props.metricMessages ?? props.messages, + metricMessageStatus: props.metricMessageStatus, main: props.main, subAgents: props.subAgents, providers: props.providers, @@ -115,6 +121,7 @@ export function CacheHitSidebar(props: { detail={detail} speed={speed} model={model} + lineages={lineages} showSpeed={props.display.showSpeed} streamingNow={props.streamingNow} formatCost={props.formatCost} diff --git a/tests/cache-ttl.test.ts b/tests/cache-ttl.test.ts index 6e0348f..e4e74d1 100644 --- a/tests/cache-ttl.test.ts +++ b/tests/cache-ttl.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { getTTL, formatElapsed, DEFAULT_TTL_MS, BUILT_IN_TTL } from "../src/cache-ttl.ts" +import { findLastCacheActivityByLineage, getTTL, formatElapsed, DEFAULT_TTL_MS, BUILT_IN_TTL } from "../src/cache-ttl.ts" import type { CacheTTLConfig } from "../src/plugin-config.ts" const MINUTE = 60_000 @@ -66,3 +66,23 @@ describe("formatElapsed", () => { expect(formatElapsed(2 * HOUR + 15 * MINUTE + 30_000)).toBe("2h 15m") }) }) + +describe("findLastCacheActivityByLineage", () => { + test("keeps independent latest cache activity for each model", () => { + const activity = findLastCacheActivityByLineage([ + { role: "assistant", id: "sol", providerID: "openai", modelID: "sol", time: { created: 1, completed: 2 }, tokens: { cache: { read: 10 } } }, + { role: "assistant", id: "luna", providerID: "openai", modelID: "luna", time: { created: 3, completed: 4 }, tokens: { cache: { read: 10 } } }, + { role: "assistant", id: "sol-2", providerID: "openai", modelID: "sol", time: { created: 5, completed: 6 }, tokens: { input: 10 } }, + ]) + expect(activity.get("openai:sol")?.id).toBe("sol") + expect(activity.get("openai:luna")?.id).toBe("luna") + }) + + test("excludes summary and compaction activity", () => { + const activity = findLastCacheActivityByLineage([ + { role: "assistant", summary: true, providerID: "openai", modelID: "gpt", time: { created: 1, completed: 2 }, tokens: { cache: { read: 10 } } }, + { role: "assistant", agent: "compaction", providerID: "openai", modelID: "gpt", time: { created: 3, completed: 4 }, tokens: { cache: { read: 10 } } }, + ]) + expect(activity.size).toBe(0) + }) +}) diff --git a/tests/dynamic-pricing-recompute.test.ts b/tests/dynamic-pricing-recompute.test.ts index e40ee69..1fdae1f 100644 --- a/tests/dynamic-pricing-recompute.test.ts +++ b/tests/dynamic-pricing-recompute.test.ts @@ -106,6 +106,14 @@ describe("recomputeSessionCost", () => { recomputeSessionCost([msg({ id: "x", providerID: "nope", modelID: "nope", tokens: { input: 100 } })], PROVIDERS, RULES), ).toBeNull() }) + + test("excludes compaction messages", () => { + const result = recomputeSessionCost([ + msg({ id: "compact", agent: "compaction", providerID: "deepseek", modelID: "deepseek/deepseek-v4-flash", tokens: { input: 100, output: 10 } }), + msg({ id: "normal", providerID: "deepseek", modelID: "deepseek/deepseek-v4-flash", tokens: { input: 100, output: 10 } }), + ], PROVIDERS, RULES) + expect(result?.counted).toBe(1) + }) }) describe("recomputeSubAgentCost", () => { diff --git a/tests/lineage-stats.test.ts b/tests/lineage-stats.test.ts new file mode 100644 index 0000000..e0b593f --- /dev/null +++ b/tests/lineage-stats.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { aggregateLineages } from "../src/lineage-stats.ts" +import { messageLineageKey, UNKNOWN_LINEAGE_KEY } from "../src/stats.ts" + +describe("messageLineageKey", () => { + test("uses provider and model ids", () => { + expect(messageLineageKey({ role: "assistant", providerID: "openai", modelID: "gpt-5.6-sol" })).toBe( + "openai:gpt-5.6-sol", + ) + }) + + test("keeps missing metadata in the unknown bucket", () => { + expect(messageLineageKey({ role: "assistant", providerID: "openai" })).toBe(UNKNOWN_LINEAGE_KEY) + expect(messageLineageKey({ role: "assistant", modelID: "gpt-5.6-sol" })).toBe(UNKNOWN_LINEAGE_KEY) + }) +}) + +describe("aggregateLineages", () => { + test("separates mixed models and sums weighted cache ratios", () => { + const buckets = aggregateLineages([ + { + role: "assistant", + id: "luna-1", + providerID: "openai", + modelID: "gpt-5.6-luna", + time: { created: 300, completed: 400 }, + agent: "build", + tokens: { input: 50, output: 4, cache: { read: 50, write: 2 } }, + cost: 0.2, + }, + { + role: "assistant", + id: "sol-1", + providerID: "openai", + modelID: "gpt-5.6-sol", + time: { created: 100, completed: 200 }, + agent: "plan", + tokens: { input: 100, output: 8, cache: { read: 0 } }, + cost: 0.1, + }, + { + role: "assistant", + id: "luna-2", + providerID: "openai", + modelID: "gpt-5.6-luna", + time: { created: 500, completed: 600 }, + agent: "build", + tokens: { input: 10, output: 2, cache: { read: 90 } }, + cost: 0.3, + }, + ]) + expect(buckets).toHaveLength(2) + const luna = buckets.find((bucket) => bucket.modelID === "gpt-5.6-luna")! + expect(luna.callCount).toBe(2) + expect(luna.input).toBe(60) + expect(luna.cacheRead).toBe(140) + expect(luna.cacheRatio).toBeCloseTo(140 / 200, 8) + expect(luna.lastCall?.id).toBe("luna-2") + expect(luna.agentCounts.build).toBe(2) + }) + + test("excludes summaries and compaction calls", () => { + const buckets = aggregateLineages([ + { role: "assistant", summary: true, providerID: "openai", modelID: "gpt", tokens: { input: 100 } }, + { role: "assistant", agent: "compaction", providerID: "openai", modelID: "gpt", tokens: { input: 100 } }, + { role: "assistant", providerID: "openai", modelID: "gpt", tokens: { input: 10, cache: { read: 90 } } }, + ]) + expect(buckets).toHaveLength(1) + expect(buckets[0].callCount).toBe(1) + expect(buckets[0].input).toBe(10) + }) + + test("does not attribute missing metadata to the last known model", () => { + const buckets = aggregateLineages([ + { role: "assistant", providerID: "openai", modelID: "gpt-sol", tokens: { input: 10 } }, + { role: "assistant", providerID: "openai", tokens: { input: 20 } }, + { role: "assistant", modelID: "gpt-sol", tokens: { input: 30 } }, + ]) + expect(buckets).toHaveLength(2) + expect(buckets.find((bucket) => bucket.key === UNKNOWN_LINEAGE_KEY)?.input).toBe(50) + expect(buckets.find((bucket) => bucket.modelID === "gpt-sol")?.input).toBe(10) + }) + + test("orders last call by completed time, then created time and id", () => { + const buckets = aggregateLineages([ + { role: "assistant", id: "b", providerID: "p", modelID: "m", time: { created: 20, completed: 30 } }, + { role: "assistant", id: "a", providerID: "p", modelID: "m", time: { created: 10, completed: 30 } }, + ]) + expect(buckets[0].lastCall?.id).toBe("b") + }) +}) diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts index 992915a..8e8ea15 100644 --- a/tests/pricing.test.ts +++ b/tests/pricing.test.ts @@ -3,6 +3,7 @@ import { lookupModelCost, computePricing, computeSubsSaved, + computeSessionPricing, EMPTY_PRICING, } from "../src/pricing.ts" import type { ProviderInfo, SubAgentSummary } from "../src/types.ts" @@ -135,3 +136,60 @@ describe("computeSubsSaved", () => { expect(result).toBeCloseTo(2.7, 10) }) }) + +describe("computeSessionPricing", () => { + test("uses each message model and reports net cache value", () => { + const result = computeSessionPricing([ + { + role: "assistant", + providerID: "anthropic", + modelID: "claude-sonnet-4-20250514", + tokens: { input: 1_000_000, cache: { read: 1_000_000, write: 1_000_000 } }, + }, + { + role: "assistant", + providerID: "openai", + modelID: "gpt-4o", + tokens: { input: 1_000_000, cache: { read: 1_000_000 } }, + }, + ], MOCK_PROVIDERS) + expect(result.counted).toBe(2) + expect(result.readSavings).toBeCloseTo(5.2, 10) + expect(result.writePremium).toBeCloseTo(0.75, 10) + expect(result.netCacheValue).toBeCloseTo(4.45, 10) + }) + + test("can report a negative cache value when writes exceed read savings", () => { + const result = computeSessionPricing([ + { + role: "assistant", + providerID: "anthropic", + modelID: "claude-sonnet-4-20250514", + tokens: { cache: { write: 1_000_000 } }, + }, + ], MOCK_PROVIDERS) + expect(result.netCacheValue).toBeCloseTo(-0.75, 10) + }) + + test("excludes compaction messages", () => { + const result = computeSessionPricing([ + { role: "assistant", agent: "compaction", providerID: "anthropic", modelID: "claude-sonnet-4-20250514", tokens: { input: 1_000_000 } }, + ], MOCK_PROVIDERS) + expect(result.counted).toBe(0) + expect(result.cost).toBe(0) + }) + + test("reports messages with missing rates as unpriced", () => { + const result = computeSessionPricing([ + { + role: "assistant", + providerID: "missing", + modelID: "unknown", + cost: 1, + tokens: { input: 1_000_000 }, + }, + ], MOCK_PROVIDERS) + expect(result.counted).toBe(0) + expect(result.unpriced).toBe(1) + }) +}) diff --git a/tests/session-messages.test.ts b/tests/session-messages.test.ts new file mode 100644 index 0000000..ba49aee --- /dev/null +++ b/tests/session-messages.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { loadSessionMessages, SESSION_MESSAGE_LIMIT } from "../src/session-messages.ts" + +const fallback = [{ role: "assistant", id: "mirror", tokens: { input: 1 } }] + +describe("loadSessionMessages", () => { + test("loads assistant info from the direct session endpoint", async () => { + let request: unknown + const result = await loadSessionMessages({ + client: { + messages: async (opts) => { + request = opts + return { + data: [ + { info: { role: "user", id: "u1" }, parts: [] }, + { info: { role: "assistant", id: "a1", modelID: "gpt-5.6" }, parts: [] }, + ], + } + }, + }, + sessionId: "s1", + directory: "/work", + fallback, + }) + expect(request).toEqual({ + path: { id: "s1" }, + query: { directory: "/work", limit: SESSION_MESSAGE_LIMIT }, + }) + expect(result).toEqual({ + messages: [{ role: "assistant", id: "a1", modelID: "gpt-5.6" }], + status: "complete", + source: "direct", + }) + }) + + test("accepts an empty complete response", async () => { + const result = await loadSessionMessages({ + client: { messages: async () => [] }, + sessionId: "s1", + directory: "/work", + fallback, + }) + expect(result).toEqual({ messages: [], status: "complete", source: "direct" }) + }) + + test("falls back to the mirror for malformed responses", async () => { + const result = await loadSessionMessages({ + client: { messages: async () => ({ data: [{ bad: true }] }) }, + sessionId: "s1", + directory: "/work", + fallback, + }) + expect(result.messages).toEqual(fallback) + expect(result.status).toBe("unavailable") + expect(result.source).toBe("mirror") + expect(result.reason).toBe("malformed-response") + }) + + test("falls back when the direct client is unavailable", async () => { + const result = await loadSessionMessages({ + client: {}, + sessionId: "s1", + directory: "/work", + fallback, + }) + expect(result).toEqual({ + messages: fallback, + status: "unavailable", + source: "mirror", + reason: "missing-client", + }) + }) + + test("marks an exact limit response as potentially capped", async () => { + const result = await loadSessionMessages({ + client: { messages: async () => Array.from({ length: 2 }, (_, i) => ({ info: { role: "assistant", id: String(i) }, parts: [] })) }, + sessionId: "s1", + directory: "/work", + fallback, + limit: 2, + }) + expect(result.status).toBe("capped") + expect(result.source).toBe("direct") + expect(result.reason).toBe("limit-reached") + }) +}) diff --git a/tests/stats.test.ts b/tests/stats.test.ts index f9da91d..4b2e968 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -66,6 +66,18 @@ describe("aggregateSessionFromMessages", () => { expect(snap.cost).toBe(0.005) expect(snap.cacheRead).toBe(500) }) + + test("excludes summary and compaction messages from totals", () => { + const snap = aggregateSessionFromMessages([ + { role: "assistant", summary: true, tokens: { input: 100, output: 10, cache: { read: 90 } }, cost: 1 }, + { role: "assistant", agent: "compaction", tokens: { input: 200, output: 20, cache: { read: 180 } }, cost: 2 }, + { role: "assistant", modelID: "gpt-5.6", tokens: { input: 10, output: 5, cache: { read: 90 } }, cost: 0.1 }, + ]) + expect(snap.input).toBe(10) + expect(snap.output).toBe(5) + expect(snap.cacheRead).toBe(90) + expect(snap.cost).toBe(0.1) + }) }) describe("cacheHitRatio", () => { @@ -98,6 +110,7 @@ describe("perMessageHitPercent", () => { test("null for summary or empty denom", () => { expect(perMessageHitPercent({ role: "assistant", summary: true })).toBeNull() expect(perMessageHitPercent({ role: "assistant", tokens: { input: 0 } })).toBeNull() + expect(perMessageHitPercent({ role: "assistant", agent: "compaction", tokens: { input: 10, cache: { read: 90 } } })).toBeNull() }) test("matches ratio", () => { @@ -119,6 +132,29 @@ describe("computePerCallHitTrend", () => { expect(r.hitPercent).toBeCloseTo(90, 5) expect(r.trendPercent).toBeCloseTo(90, 5) expect(r.hasTrend).toBe(true) + expect(r.state).toBe("steady") + }) + + test("does not compare different model lineages", () => { + const r = computePerCallHitTrend([ + { role: "assistant", providerID: "openai", modelID: "gpt-sol", tokens: { input: 100, cache: { read: 0 } } }, + { role: "assistant", providerID: "openai", modelID: "gpt-luna", tokens: { input: 10, cache: { read: 90 } } }, + ]) + expect(r.hitPercent).toBeCloseTo(90, 5) + expect(r.trendPercent).toBe(0) + expect(r.hasTrend).toBe(false) + expect(r.state).toBe("switch") + }) + + test("shows a trend after two calls on the new lineage", () => { + const r = computePerCallHitTrend([ + { role: "assistant", providerID: "openai", modelID: "gpt-sol", tokens: { input: 100, cache: { read: 0 } } }, + { role: "assistant", providerID: "openai", modelID: "gpt-luna", tokens: { input: 10, cache: { read: 90 } } }, + { role: "assistant", providerID: "openai", modelID: "gpt-luna", tokens: { input: 50, cache: { read: 50 } } }, + ]) + expect(r.trendPercent).toBeCloseTo(-40, 5) + expect(r.hasTrend).toBe(true) + expect(r.state).toBe("steady") }) }) diff --git a/tests/timeline-collector.test.ts b/tests/timeline-collector.test.ts index 604c302..834cb99 100644 --- a/tests/timeline-collector.test.ts +++ b/tests/timeline-collector.test.ts @@ -257,6 +257,21 @@ describe("createTimelineCollector (event-driven)", () => { expect(appended).toHaveLength(1) }) + test("keeps compaction rows with an explicit metrics skip marker", async () => { + const appended: LlmCallRecord[] = [] + const c = collector({ + config: { ...DEFAULT_TIMELINE, enabled: true, logSummaryMessages: true }, + getRootSessionId: () => "r", + getChildIds: () => [], + append: async (_p, rec) => appended.push(rec), + }) + c.handleMessage("r", msg({ id: "compact", agent: "compaction" })) + await new Promise((r) => setTimeout(r, 50)) + expect(appended).toHaveLength(1) + expect(appended[0].skippedForMetrics).toBe(true) + expect(appended[0].hitPercent).toBeNull() + }) + test("sets scope to main for root session messages", async () => { const appended: LlmCallRecord[] = [] const c = collector({ diff --git a/tests/timeline-rotation.test.ts b/tests/timeline-rotation.test.ts index 9fa4170..24e0a3f 100644 --- a/tests/timeline-rotation.test.ts +++ b/tests/timeline-rotation.test.ts @@ -30,6 +30,7 @@ const baseRecord = (): LlmCallRecord => ({ cost: 0, hitPercent: null, skippedForHit: false, + skippedForMetrics: false, }) describe("trimFileToMaxLines", () => { diff --git a/tests/timeline-writer.test.ts b/tests/timeline-writer.test.ts index 584f3ba..2f7b5fc 100644 --- a/tests/timeline-writer.test.ts +++ b/tests/timeline-writer.test.ts @@ -30,6 +30,7 @@ const sample: LlmCallRecord = { cost: 0.01, hitPercent: 83.33, skippedForHit: false, + skippedForMetrics: false, } describe("timeline writer", () => { diff --git a/tests/token-speed.test.ts b/tests/token-speed.test.ts index 820a4e0..eef7088 100644 --- a/tests/token-speed.test.ts +++ b/tests/token-speed.test.ts @@ -58,6 +58,11 @@ describe("computeAvgTokenSpeed", () => { expect(computeAvgTokenSpeed(msgs)).toBe(0) }) + test("skips compaction messages", () => { + const msgs = [{ agent: "compaction", tokens: { output: 100 }, time: { created: 0, completed: 1000 } }] + expect(computeAvgTokenSpeed(msgs)).toBe(0) + }) + test("skips messages with duration < 500ms", () => { const msgs = [ { @@ -159,6 +164,11 @@ describe("computeAvgTokenTpotMs", () => { expect(computeAvgTokenTpotMs(msgs)).toBeUndefined() }) + test("skips compaction messages", () => { + const msgs = [{ agent: "compaction", tokens: { output: 100 }, time: { created: 0, completed: 1000 } }] + expect(computeAvgTokenTpotMs(msgs)).toBeUndefined() + }) + test("skips messages with duration < 500ms", () => { const msgs = [{ tokens: { output: 100 }, time: { created: 0, completed: 400 } }] expect(computeAvgTokenTpotMs(msgs)).toBeUndefined()