@selesai/code 0.9.14 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +6 -9
  3. package/dist/defaults/models.json +79 -29
  4. package/dist/defaults/settings.json +3 -7
  5. package/dist/extensions/package.json +3 -4
  6. package/dist/extensions/pi-zentui/.editorconfig +12 -0
  7. package/dist/extensions/pi-zentui/.github/CODEOWNERS +1 -0
  8. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/bug_report.yml +110 -0
  9. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config.yml +8 -0
  10. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config_question.yml +56 -0
  11. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/feature_request.yml +63 -0
  12. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/rendering_issue.yml +106 -0
  13. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/runtime_detection.yml +103 -0
  14. package/dist/extensions/pi-zentui/.github/dependabot.yml +47 -0
  15. package/dist/extensions/pi-zentui/.github/pull_request_template.md +34 -0
  16. package/dist/extensions/pi-zentui/.github/release.yml +18 -0
  17. package/dist/extensions/pi-zentui/.github/workflows/ci.yml +79 -0
  18. package/dist/extensions/pi-zentui/.github/workflows/publish.yml +63 -0
  19. package/dist/extensions/pi-zentui/AGENTS.md +107 -0
  20. package/dist/extensions/pi-zentui/CONTRIBUTING.md +68 -0
  21. package/dist/extensions/pi-zentui/LICENSE +21 -0
  22. package/dist/extensions/pi-zentui/README.md +188 -0
  23. package/dist/extensions/pi-zentui/SECURITY.md +18 -0
  24. package/dist/extensions/pi-zentui/assets/main-cover.png +0 -0
  25. package/dist/extensions/pi-zentui/assets/screenshots/editor-accent-rail.png +0 -0
  26. package/dist/extensions/pi-zentui/assets/screenshots/editor-minimalist.png +0 -0
  27. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode-copy-friendly.png +0 -0
  28. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode.png +0 -0
  29. package/dist/extensions/pi-zentui/assets/screenshots/user-message-compact.png +0 -0
  30. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed-copy-friendly.png +0 -0
  31. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed.png +0 -0
  32. package/dist/extensions/pi-zentui/assets/screenshots/user-message-labeled.png +0 -0
  33. package/dist/extensions/pi-zentui/assets/zentui.png +0 -0
  34. package/dist/extensions/pi-zentui/biome.json +31 -0
  35. package/dist/extensions/pi-zentui/docs/configuration.md +509 -0
  36. package/dist/extensions/pi-zentui/docs/footer-format.md +162 -0
  37. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-editor.ts +99 -0
  38. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-layout-patch.ts +457 -0
  39. package/dist/extensions/pi-zentui/extensions/zentui/completion-menu.ts +157 -0
  40. package/dist/extensions/pi-zentui/extensions/zentui/config.ts +2100 -0
  41. package/dist/extensions/pi-zentui/extensions/zentui/editor-metadata-format.ts +321 -0
  42. package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +73 -0
  43. package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +89 -0
  44. package/dist/extensions/pi-zentui/extensions/zentui/footer-format.ts +364 -0
  45. package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +122 -0
  46. package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +812 -0
  47. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +568 -0
  48. package/dist/extensions/pi-zentui/extensions/zentui/git.ts +384 -0
  49. package/dist/extensions/pi-zentui/extensions/zentui/icons.ts +252 -0
  50. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +1406 -0
  51. package/dist/extensions/pi-zentui/extensions/zentui/interaction-summary.ts +1049 -0
  52. package/dist/extensions/pi-zentui/extensions/zentui/live-context.ts +75 -0
  53. package/dist/extensions/pi-zentui/extensions/zentui/minimalist-editor.ts +463 -0
  54. package/dist/extensions/pi-zentui/extensions/zentui/package-version.ts +651 -0
  55. package/dist/extensions/pi-zentui/extensions/zentui/project-refresh.ts +120 -0
  56. package/dist/extensions/pi-zentui/extensions/zentui/project-state.ts +59 -0
  57. package/dist/extensions/pi-zentui/extensions/zentui/prototype-patch-registry.ts +166 -0
  58. package/dist/extensions/pi-zentui/extensions/zentui/repository-root.ts +98 -0
  59. package/dist/extensions/pi-zentui/extensions/zentui/runtime.ts +841 -0
  60. package/dist/extensions/pi-zentui/extensions/zentui/selector-border.ts +93 -0
  61. package/dist/extensions/pi-zentui/extensions/zentui/session-lifecycle.ts +60 -0
  62. package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +1796 -0
  63. package/dist/extensions/pi-zentui/extensions/zentui/settings-previews.ts +128 -0
  64. package/dist/extensions/pi-zentui/extensions/zentui/state.ts +94 -0
  65. package/dist/extensions/pi-zentui/extensions/zentui/style.ts +424 -0
  66. package/dist/extensions/pi-zentui/extensions/zentui/telemetry.ts +82 -0
  67. package/dist/extensions/pi-zentui/extensions/zentui/ui.ts +1290 -0
  68. package/dist/extensions/pi-zentui/extensions/zentui/user-message-osc.ts +248 -0
  69. package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +215 -0
  70. package/dist/extensions/pi-zentui/extensions/zentui/user-message.ts +206 -0
  71. package/dist/extensions/pi-zentui/extensions/zentui/working-line-messages.ts +24 -0
  72. package/dist/extensions/pi-zentui/extensions/zentui/working-line-spinners.ts +54 -0
  73. package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +1433 -0
  74. package/dist/extensions/pi-zentui/package-lock.json +5311 -0
  75. package/dist/extensions/pi-zentui/package.json +60 -0
  76. package/dist/extensions/pi-zentui/test/accent-rail-editor.test.ts +232 -0
  77. package/dist/extensions/pi-zentui/test/accent-rail-layout-patch.test.ts +669 -0
  78. package/dist/extensions/pi-zentui/test/completion-menu.test.ts +265 -0
  79. package/dist/extensions/pi-zentui/test/config.test.ts +3119 -0
  80. package/dist/extensions/pi-zentui/test/editor-metadata-format.test.ts +405 -0
  81. package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +260 -0
  82. package/dist/extensions/pi-zentui/test/editor-viewport-indicators.test.ts +1286 -0
  83. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +7196 -0
  84. package/dist/extensions/pi-zentui/test/extension-status.test.ts +182 -0
  85. package/dist/extensions/pi-zentui/test/fixtures/starship-nerd-font-symbols.toml +172 -0
  86. package/dist/extensions/pi-zentui/test/footer-format.test.ts +431 -0
  87. package/dist/extensions/pi-zentui/test/footer-layout.test.ts +113 -0
  88. package/dist/extensions/pi-zentui/test/footer-repository-path.test.ts +123 -0
  89. package/dist/extensions/pi-zentui/test/format.test.ts +865 -0
  90. package/dist/extensions/pi-zentui/test/git.test.ts +212 -0
  91. package/dist/extensions/pi-zentui/test/icons.test.ts +69 -0
  92. package/dist/extensions/pi-zentui/test/interaction-summary.test.ts +1402 -0
  93. package/dist/extensions/pi-zentui/test/live-context-integration.test.ts +378 -0
  94. package/dist/extensions/pi-zentui/test/live-context.test.ts +139 -0
  95. package/dist/extensions/pi-zentui/test/minimalist-editor.test.ts +811 -0
  96. package/dist/extensions/pi-zentui/test/package-version.test.ts +321 -0
  97. package/dist/extensions/pi-zentui/test/project-refresh.test.ts +250 -0
  98. package/dist/extensions/pi-zentui/test/project-state.test.ts +159 -0
  99. package/dist/extensions/pi-zentui/test/repository-root.test.ts +108 -0
  100. package/dist/extensions/pi-zentui/test/responsive-dependencies.test.ts +825 -0
  101. package/dist/extensions/pi-zentui/test/runtime.test.ts +183 -0
  102. package/dist/extensions/pi-zentui/test/session-lifecycle.test.ts +40 -0
  103. package/dist/extensions/pi-zentui/test/settings-command.test.ts +1381 -0
  104. package/dist/extensions/pi-zentui/test/settings-previews.test.ts +347 -0
  105. package/dist/extensions/pi-zentui/test/state.test.ts +84 -0
  106. package/dist/extensions/pi-zentui/test/telemetry-lifecycle-integration.test.ts +276 -0
  107. package/dist/extensions/pi-zentui/test/telemetry.test.ts +105 -0
  108. package/dist/extensions/pi-zentui/test/turn-summary-lifecycle.integration.test.ts +327 -0
  109. package/dist/extensions/pi-zentui/test/user-message-osc.test.ts +164 -0
  110. package/dist/extensions/pi-zentui/test/user-message-styles.test.ts +292 -0
  111. package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +730 -0
  112. package/dist/extensions/pi-zentui/test/working-line-messages.test.ts +34 -0
  113. package/dist/extensions/pi-zentui/test/working-line.test.ts +1990 -0
  114. package/dist/extensions/pi-zentui/tsconfig.json +16 -0
  115. package/dist/extensions/tps.test.ts +38 -0
  116. package/dist/extensions/{pi-powerline-footer/tps.ts → tps.ts} +4 -0
  117. package/dist/skills/design-references/SKILL.md +31 -0
  118. package/dist/skills/design-references/references/clickhouse/DESIGN.md +544 -0
  119. package/dist/themes/adventure.json +84 -0
  120. package/dist/themes/adwaita-dark.json +84 -0
  121. package/dist/themes/arcoiris.json +84 -0
  122. package/dist/themes/arthur.json +84 -0
  123. package/dist/themes/atom.json +84 -0
  124. package/dist/themes/aura.json +84 -0
  125. package/dist/themes/black-metal-bathory.json +84 -0
  126. package/dist/themes/black-metal-burzum.json +84 -0
  127. package/dist/themes/black-metal-khold.json +84 -0
  128. package/dist/themes/box.json +84 -0
  129. package/dist/themes/brogrammer.json +84 -0
  130. package/dist/themes/carbonfox.json +84 -0
  131. package/dist/themes/catppuccin-mocha.json +84 -0
  132. package/dist/themes/citruszest.json +84 -0
  133. package/dist/themes/cursor-dark.json +84 -0
  134. package/dist/themes/cutie-pro.json +84 -0
  135. package/dist/themes/dark-modern.json +84 -0
  136. package/dist/themes/dark-pastel.json +84 -0
  137. package/dist/themes/dimmed-monokai.json +84 -0
  138. package/dist/themes/doom-peacock.json +84 -0
  139. package/dist/themes/dracula-plus.json +84 -0
  140. package/dist/themes/dracula.json +80 -0
  141. package/dist/themes/earthsong.json +84 -0
  142. package/dist/themes/everforest-dark-hard.json +84 -0
  143. package/dist/themes/everforest-dark.json +80 -0
  144. package/dist/themes/fahrenheit.json +84 -0
  145. package/dist/themes/flatland.json +84 -0
  146. package/dist/themes/flexoki-dark.json +84 -0
  147. package/dist/themes/front-end-delight.json +84 -0
  148. package/dist/themes/fun-forrest.json +84 -0
  149. package/dist/themes/galizur.json +84 -0
  150. package/dist/themes/github-dark-colorblind.json +84 -0
  151. package/dist/themes/github-dark-high-contrast.json +84 -0
  152. package/dist/themes/glacier.json +84 -0
  153. package/dist/themes/gruber-darker.json +84 -0
  154. package/dist/themes/gruvbox-dark-hard.json +84 -0
  155. package/dist/themes/gruvbox-dark.json +84 -0
  156. package/dist/themes/gruvbox-material.json +84 -0
  157. package/dist/themes/guezwhoz.json +84 -0
  158. package/dist/themes/hacktober.json +84 -0
  159. package/dist/themes/hardcore.json +84 -0
  160. package/dist/themes/havn-skumring.json +84 -0
  161. package/dist/themes/ic-orange-ppl.json +84 -0
  162. package/dist/themes/iterm2-smoooooth.json +84 -0
  163. package/dist/themes/iterm2-tango-dark.json +84 -0
  164. package/dist/themes/japanesque.json +84 -0
  165. package/dist/themes/jellybeans.json +84 -0
  166. package/dist/themes/kanagawa-wave.json +84 -0
  167. package/dist/themes/kurokula.json +84 -0
  168. package/dist/themes/later-this-evening.json +84 -0
  169. package/dist/themes/lovelace.json +84 -0
  170. package/dist/themes/material-darker.json +84 -0
  171. package/dist/themes/matte-black.json +84 -0
  172. package/dist/themes/mellow.json +84 -0
  173. package/dist/themes/miasma.json +84 -0
  174. package/dist/themes/nord.json +80 -0
  175. package/dist/themes/nvim-dark.json +84 -0
  176. package/dist/themes/one-dark.json +80 -0
  177. package/dist/themes/popping-and-locking.json +84 -0
  178. package/dist/themes/rose-pine.json +80 -0
  179. package/dist/themes/sea-shells.json +84 -0
  180. package/dist/themes/sleepy-hollow.json +84 -0
  181. package/dist/themes/smyck.json +84 -0
  182. package/dist/themes/solarized-dark.json +80 -0
  183. package/dist/themes/tokyo-night.json +80 -0
  184. package/dist/themes/tomorrow-night-bright.json +84 -0
  185. package/dist/themes/tomorrow-night-burns.json +84 -0
  186. package/dist/themes/tomorrow-night.json +84 -0
  187. package/dist/themes/twilight.json +84 -0
  188. package/dist/themes/vague.json +84 -0
  189. package/dist/themes/vesper.json +84 -0
  190. package/dist/themes/xcode-dark-hc.json +84 -0
  191. package/docs/custom-provider.md +0 -1
  192. package/docs/plans/auto-model-routing-extension.md +490 -0
  193. package/package.json +3 -3
  194. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +0 -27
  195. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +0 -687
  196. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +0 -16
  197. package/dist/extensions/pi-powerline-footer/README.md +0 -513
  198. package/dist/extensions/pi-powerline-footer/banner.png +0 -0
  199. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +0 -586
  200. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +0 -583
  201. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +0 -204
  202. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +0 -286
  203. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +0 -108
  204. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +0 -60
  205. package/dist/extensions/pi-powerline-footer/cd-command.ts +0 -190
  206. package/dist/extensions/pi-powerline-footer/colors.ts +0 -70
  207. package/dist/extensions/pi-powerline-footer/context-usage.ts +0 -156
  208. package/dist/extensions/pi-powerline-footer/currency-rates.ts +0 -142
  209. package/dist/extensions/pi-powerline-footer/editor-composition.ts +0 -32
  210. package/dist/extensions/pi-powerline-footer/editor-performance.ts +0 -99
  211. package/dist/extensions/pi-powerline-footer/git-status.ts +0 -329
  212. package/dist/extensions/pi-powerline-footer/guide.ts +0 -220
  213. package/dist/extensions/pi-powerline-footer/icons.ts +0 -190
  214. package/dist/extensions/pi-powerline-footer/index.ts +0 -3428
  215. package/dist/extensions/pi-powerline-footer/lifecycle.ts +0 -10
  216. package/dist/extensions/pi-powerline-footer/package.json +0 -49
  217. package/dist/extensions/pi-powerline-footer/paths.ts +0 -22
  218. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -446
  219. package/dist/extensions/pi-powerline-footer/presets.ts +0 -102
  220. package/dist/extensions/pi-powerline-footer/queue/store.ts +0 -313
  221. package/dist/extensions/pi-powerline-footer/queue/types.ts +0 -53
  222. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +0 -29
  223. package/dist/extensions/pi-powerline-footer/segments.ts +0 -570
  224. package/dist/extensions/pi-powerline-footer/separators.ts +0 -57
  225. package/dist/extensions/pi-powerline-footer/session-usage.ts +0 -44
  226. package/dist/extensions/pi-powerline-footer/shortcuts.ts +0 -61
  227. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +0 -1905
  228. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +0 -144
  229. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +0 -156
  230. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +0 -344
  231. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +0 -48
  232. package/dist/extensions/pi-powerline-footer/tests/editor-performance.test.ts +0 -43
  233. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +0 -136
  234. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +0 -39
  235. package/dist/extensions/pi-powerline-footer/tests/git-optional-locks.test.ts +0 -64
  236. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +0 -92
  237. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +0 -129
  238. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -116
  239. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +0 -79
  240. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +0 -63
  241. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +0 -65
  242. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +0 -14
  243. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +0 -173
  244. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +0 -47
  245. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +0 -37
  246. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +0 -76
  247. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +0 -249
  248. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +0 -39
  249. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +0 -237
  250. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +0 -199
  251. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +0 -317
  252. package/dist/extensions/pi-powerline-footer/theme.example.json +0 -24
  253. package/dist/extensions/pi-powerline-footer/theme.json +0 -12
  254. package/dist/extensions/pi-powerline-footer/theme.ts +0 -241
  255. package/dist/extensions/pi-powerline-footer/token-stats.ts +0 -304
  256. package/dist/extensions/pi-powerline-footer/tsconfig.json +0 -13
  257. package/dist/extensions/pi-powerline-footer/types.ts +0 -235
  258. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +0 -34
  259. package/dist/extensions/pi-powerline-footer/welcome.ts +0 -709
  260. package/dist/extensions/pi-powerline-footer/working-vibes.ts +0 -721
  261. package/dist/extensions/preview-tools-disabled.test.ts +0 -37
  262. package/dist/extensions/preview-tools-disabled.ts +0 -11
  263. package/dist/themes/powerline-footer/theme.json +0 -33
@@ -0,0 +1,1402 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import { buildSessionContext } from "@earendil-works/pi-coding-agent";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ formatTurnSummary,
6
+ InteractionMetricsTracker,
7
+ isTurnSummaryData,
8
+ parseAssistantMessageTokens,
9
+ renderTurnSummaryEntry,
10
+ subtractThoughtIntervalsWithinCap,
11
+ TURN_SUMMARY_ENTRY_TYPE,
12
+ } from "../extensions/zentui/interaction-summary";
13
+
14
+ const stripTerminalSequences = stripVTControlCharacters;
15
+
16
+ type AssistantOptions = {
17
+ responseId?: string;
18
+ timestamp?: number;
19
+ provider?: string;
20
+ model?: string;
21
+ stopReason?: string;
22
+ content?: unknown[];
23
+ };
24
+
25
+ function assistant(input: number, output: number, options: AssistantOptions = {}) {
26
+ return {
27
+ role: "assistant" as const,
28
+ usage: { input, output, cacheRead: 999, cacheWrite: 888, cost: { total: 1 } },
29
+ content: options.content ?? [],
30
+ api: "test",
31
+ provider: options.provider ?? "provider",
32
+ model: options.model ?? "model",
33
+ stopReason: options.stopReason ?? "stop",
34
+ timestamp: options.timestamp ?? 1,
35
+ responseId: options.responseId,
36
+ };
37
+ }
38
+
39
+ function delta(
40
+ type: "text_delta" | "thinking_delta",
41
+ contentIndex: number,
42
+ value: string,
43
+ cumulative?: string,
44
+ ) {
45
+ const content: unknown[] = [];
46
+ if (cumulative !== undefined) {
47
+ content[contentIndex] =
48
+ type === "text_delta"
49
+ ? { type: "text", text: cumulative }
50
+ : { type: "thinking", thinking: cumulative };
51
+ }
52
+ return {
53
+ type,
54
+ contentIndex,
55
+ delta: value,
56
+ partial: assistant(0, 0, { content }),
57
+ } as never;
58
+ }
59
+
60
+ function toolDelta(contentIndex: number, value: string, includeBlock = true) {
61
+ const content: unknown[] = [];
62
+ if (includeBlock) {
63
+ content[contentIndex] = { type: "toolCall", id: "call-1", name: "bash", arguments: {} };
64
+ }
65
+ return {
66
+ type: "toolcall_delta",
67
+ contentIndex,
68
+ delta: value,
69
+ partial: assistant(0, 0, { content }),
70
+ } as never;
71
+ }
72
+
73
+ function accepted(
74
+ tokens: { input: number; output: number },
75
+ source: "final" | "last-snapshot" | "no-snapshot" = "final",
76
+ displayTokens = { ...tokens, outputApproximate: false },
77
+ ) {
78
+ return { status: "accepted", tokens, displayTokens, source };
79
+ }
80
+
81
+ describe("live interaction token display", () => {
82
+ it("starts terminal-only streams at zero approximate and estimates text plus thinking", () => {
83
+ const tracker = new InteractionMetricsTracker();
84
+ tracker.agentStart();
85
+ tracker.turnStart();
86
+ expect(tracker.messageUpdate(assistant(0, 0)).displayTokens).toEqual({
87
+ input: 0,
88
+ output: 0,
89
+ outputApproximate: true,
90
+ });
91
+ expect(
92
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "abc", "abc")).displayTokens,
93
+ ).toEqual({ input: 0, output: 1, outputApproximate: true });
94
+ expect(
95
+ tracker.messageUpdate(assistant(0, 0), delta("thinking_delta", 1, "defgh", "defgh"))
96
+ .displayTokens,
97
+ ).toEqual({ input: 0, output: 2, outputApproximate: true });
98
+ });
99
+
100
+ it("counts cumulative Unicode code points and ignores unchanged or stale snapshots", () => {
101
+ const tracker = new InteractionMetricsTracker();
102
+ tracker.agentStart();
103
+ tracker.turnStart();
104
+ const first = tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "💡ab", "💡ab"));
105
+ expect(first.displayTokens).toEqual({ input: 0, output: 1, outputApproximate: true });
106
+ expect(
107
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "💡ab", "💡ab")).usageChanged,
108
+ ).toBe(false);
109
+ expect(
110
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "x", "💡a")).usageChanged,
111
+ ).toBe(false);
112
+ });
113
+
114
+ it("counts split surrogate pairs once in raw fallback and saturates estimates", () => {
115
+ const tracker = new InteractionMetricsTracker();
116
+ tracker.agentStart();
117
+ tracker.turnStart();
118
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "\ud83d"));
119
+ expect(
120
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "\udca1abc")).displayTokens,
121
+ ).toEqual({ input: 0, output: 1, outputApproximate: true });
122
+ const huge = new InteractionMetricsTracker();
123
+ huge.agentStart();
124
+ huge.turnStart();
125
+ huge.messageUpdate(assistant(0, Number.MAX_SAFE_INTEGER));
126
+ expect(
127
+ huge.messageUpdate(assistant(0, Number.MAX_SAFE_INTEGER), delta("text_delta", 0, "more"))
128
+ .displayTokens,
129
+ ).toEqual({ input: 0, output: Number.MAX_SAFE_INTEGER, outputApproximate: true });
130
+ });
131
+
132
+ it("anchors estimates to advancing provider output and final corrects downward", () => {
133
+ const tracker = new InteractionMetricsTracker();
134
+ tracker.agentStart();
135
+ tracker.turnStart();
136
+ tracker.messageUpdate(assistant(10, 2), delta("text_delta", 0, "abcdefgh", "abcdefgh"));
137
+ expect(
138
+ tracker.messageUpdate(assistant(10, 2), delta("text_delta", 0, "ijkl", "abcdefghijkl"))
139
+ .displayTokens,
140
+ ).toEqual({ input: 10, output: 3, outputApproximate: true });
141
+ expect(tracker.messageUpdate(assistant(9, 1)).displayTokens).toEqual({
142
+ input: 10,
143
+ output: 3,
144
+ outputApproximate: true,
145
+ });
146
+ expect(tracker.messageEnd(assistant(10, 2))).toEqual(accepted({ input: 10, output: 2 }));
147
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 2 });
148
+ });
149
+
150
+ it("retains live estimate state across non-idle partition while settling provider-only totals", () => {
151
+ const tracker = new InteractionMetricsTracker();
152
+ tracker.agentStart(0);
153
+ tracker.turnStart();
154
+ tracker.messageEnd(assistant(5, 1, { responseId: "settled" }));
155
+ tracker.agentEnd(10);
156
+ tracker.agentStart(20);
157
+ tracker.turnStart();
158
+ tracker.messageUpdate(
159
+ assistant(0, 0, { responseId: "live" }),
160
+ delta("text_delta", 0, "abcdefgh", "abcdefgh"),
161
+ );
162
+ expect(tracker.settle(false, 30)).toMatchObject({
163
+ summary: { input: 5, output: 1 },
164
+ nextTokens: { input: 0, output: 2, outputApproximate: true },
165
+ });
166
+ expect(tracker.currentDisplayTokens()).toEqual({
167
+ input: 0,
168
+ output: 2,
169
+ outputApproximate: true,
170
+ });
171
+ });
172
+
173
+ it("handles sparse cumulative thinking indexes and divergent snapshots conservatively", () => {
174
+ const tracker = new InteractionMetricsTracker();
175
+ tracker.agentStart();
176
+ tracker.turnStart();
177
+ expect(
178
+ tracker.messageUpdate(assistant(0, 0), delta("thinking_delta", 7, "abcd", "abcd"))
179
+ .displayTokens,
180
+ ).toEqual({ input: 0, output: 1, outputApproximate: true });
181
+ expect(
182
+ tracker.messageUpdate(assistant(0, 0), delta("thinking_delta", 7, "efgh", "abcdefgh"))
183
+ .displayTokens,
184
+ ).toEqual({ input: 0, output: 2, outputApproximate: true });
185
+ expect(
186
+ tracker.messageUpdate(assistant(0, 0), delta("thinking_delta", 7, "zzzz", "abcdzzzz"))
187
+ .usageChanged,
188
+ ).toBe(false);
189
+ expect(tracker.diagnostics()).toMatchObject({
190
+ contentBlocks: 1,
191
+ retainedContentUnits: 8,
192
+ estimateIncomplete: true,
193
+ });
194
+ });
195
+
196
+ it("joins cumulative split surrogates across an unchanged pending snapshot", () => {
197
+ const tracker = new InteractionMetricsTracker();
198
+ tracker.agentStart();
199
+ tracker.turnStart();
200
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "\ud83d", "\ud83d"));
201
+ expect(
202
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "", "\ud83d")).usageChanged,
203
+ ).toBe(false);
204
+ expect(
205
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "\udca1abc", "💡abc"))
206
+ .displayTokens,
207
+ ).toEqual({ input: 0, output: 1, outputApproximate: true });
208
+ });
209
+
210
+ it("validates malformed event boundaries before any mutation", () => {
211
+ for (const event of [
212
+ { type: "thinking_delta", contentIndex: 0, delta: "x" },
213
+ { type: "thinking_delta", contentIndex: 0, delta: 1, partial: { content: [] } },
214
+ { type: "thinking_delta", contentIndex: 0, delta: "x", partial: { content: {} } },
215
+ {
216
+ type: "thinking_delta",
217
+ contentIndex: 0,
218
+ delta: "x",
219
+ partial: { content: [{ type: "text", text: "x" }] },
220
+ },
221
+ { type: "thinking_start", contentIndex: 65_536, partial: { content: [] } },
222
+ ] as const) {
223
+ const tracker = new InteractionMetricsTracker();
224
+ tracker.agentStart(0);
225
+ tracker.turnStart(0);
226
+ expect(() => tracker.messageUpdate(assistant(-1, 1), event as never, 10)).not.toThrow();
227
+ expect(tracker.currentTokens()).toEqual({ input: 0, output: 0 });
228
+ expect(tracker.currentThought(20)).toEqual({ durationMs: 0, active: false });
229
+ expect(tracker.diagnostics().contentBlocks).toBe(0);
230
+ }
231
+ });
232
+
233
+ it("releases bounded estimator state on closure", () => {
234
+ const tracker = new InteractionMetricsTracker();
235
+ tracker.agentStart();
236
+ tracker.turnStart();
237
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "abcdefgh", "abcdefgh"));
238
+ expect(tracker.diagnostics()).toMatchObject({ activeResponses: 1, contentBlocks: 1 });
239
+ expect(tracker.messageEnd(assistant(2, 1))).toEqual(accepted({ input: 2, output: 1 }));
240
+ expect(tracker.diagnostics()).toMatchObject({
241
+ activeResponses: 0,
242
+ contentBlocks: 0,
243
+ retainedContentUnits: 0,
244
+ });
245
+ });
246
+
247
+ it("processes progressive cumulative snapshots incrementally with bounded retention", () => {
248
+ const tracker = new InteractionMetricsTracker();
249
+ tracker.agentStart();
250
+ tracker.turnStart();
251
+ let cumulative = "";
252
+ const started = performance.now();
253
+ for (let index = 0; index < 20_000; index++) {
254
+ cumulative += "x";
255
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "x", cumulative));
256
+ }
257
+ expect(performance.now() - started).toBeLessThan(5_000);
258
+ expect(tracker.currentDisplayTokens()).toEqual({
259
+ input: 0,
260
+ output: 5_000,
261
+ outputApproximate: true,
262
+ });
263
+ expect(tracker.diagnostics()).toMatchObject({
264
+ contentBlocks: 1,
265
+ maxContentTailUnits: 64,
266
+ retainedContentUnits: 64,
267
+ });
268
+ });
269
+
270
+ it("bounds block, delta, snapshot, generated, and thought estimator state without losing final usage", () => {
271
+ const tracker = new InteractionMetricsTracker();
272
+ tracker.agentStart(0);
273
+ tracker.turnStart(0);
274
+ for (let index = 0; index < 129; index++) {
275
+ expect(() =>
276
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", index, "x")),
277
+ ).not.toThrow();
278
+ }
279
+ expect(tracker.diagnostics().contentBlocks).toBe(128);
280
+ expect(() =>
281
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "x".repeat(65_537))),
282
+ ).not.toThrow();
283
+ expect(() =>
284
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "x", "x".repeat(1_048_577))),
285
+ ).not.toThrow();
286
+ for (let index = 0; index < 65; index++) {
287
+ expect(() =>
288
+ tracker.messageUpdate(
289
+ assistant(0, 0),
290
+ delta("text_delta", index % 128, "x".repeat(65_536)),
291
+ ),
292
+ ).not.toThrow();
293
+ }
294
+ for (let index = 0; index < 257; index++) {
295
+ expect(() =>
296
+ tracker.messageUpdate(
297
+ assistant(0, 0),
298
+ {
299
+ type: "thinking_start",
300
+ contentIndex: index,
301
+ partial: { content: [] },
302
+ } as never,
303
+ index,
304
+ ),
305
+ ).not.toThrow();
306
+ }
307
+ expect(tracker.diagnostics()).toMatchObject({
308
+ contentBlocks: 128,
309
+ thoughtBlocks: 256,
310
+ estimateIncomplete: true,
311
+ });
312
+ expect(tracker.messageEnd(assistant(11, 7), 300)).toEqual(accepted({ input: 11, output: 7 }));
313
+ });
314
+
315
+ it("counts every raw tool-call chunk independently and exact final usage reconciles it", () => {
316
+ const tracker = new InteractionMetricsTracker();
317
+ tracker.agentStart(0);
318
+ tracker.turnStart(0);
319
+ expect(tracker.messageUpdate(assistant(0, 0), toolDelta(0, "abcdefgh"), 10)).toMatchObject({
320
+ displayTokens: { input: 0, output: 2, outputApproximate: true },
321
+ usageChanged: true,
322
+ thoughtChanged: false,
323
+ });
324
+ expect(
325
+ tracker.messageUpdate(assistant(0, 0), toolDelta(0, "abcdefgh"), 20).displayTokens,
326
+ ).toEqual({ input: 0, output: 4, outputApproximate: true });
327
+ expect(tracker.currentThought(30)).toEqual({ durationMs: 0, active: false });
328
+ expect(tracker.diagnostics()).toMatchObject({ contentBlocks: 1, thoughtBlocks: 0 });
329
+ expect(tracker.messageEnd(assistant(7, 1), 40)).toEqual(accepted({ input: 7, output: 1 }));
330
+ });
331
+
332
+ it("ceilings mixed text, thinking, and tool-call output together", () => {
333
+ const tracker = new InteractionMetricsTracker();
334
+ tracker.agentStart(0);
335
+ tracker.turnStart(0);
336
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "abc", "abc"));
337
+ tracker.messageUpdate(assistant(0, 0), delta("thinking_delta", 1, "def", "def"));
338
+ expect(tracker.messageUpdate(assistant(0, 0), toolDelta(2, "ghi")).displayTokens).toEqual({
339
+ input: 0,
340
+ output: 3,
341
+ outputApproximate: true,
342
+ });
343
+ expect(tracker.diagnostics().contentBlocks).toBe(3);
344
+ });
345
+
346
+ it("rejects malformed tool deltas atomically and bounds oversized tool-call state", () => {
347
+ for (const event of [
348
+ { type: "toolcall_delta", contentIndex: 0, delta: "x" },
349
+ { type: "toolcall_delta", contentIndex: 0, delta: 1, partial: { content: [] } },
350
+ { type: "toolcall_delta", contentIndex: 0, delta: "x", partial: { content: [] } },
351
+ { type: "toolcall_delta", contentIndex: 0, delta: "x", partial: { content: {} } },
352
+ {
353
+ type: "toolcall_delta",
354
+ contentIndex: 0,
355
+ delta: "x",
356
+ partial: { content: [{ type: "text", text: "x" }] },
357
+ },
358
+ ] as const) {
359
+ const tracker = new InteractionMetricsTracker();
360
+ tracker.agentStart();
361
+ tracker.turnStart();
362
+ expect(tracker.messageUpdate(assistant(0, 0), event as never).usageChanged).toBe(false);
363
+ expect(tracker.diagnostics()).toMatchObject({ contentBlocks: 0, estimateIncomplete: false });
364
+ }
365
+
366
+ const oversized = new InteractionMetricsTracker();
367
+ oversized.agentStart();
368
+ oversized.turnStart();
369
+ expect(
370
+ oversized.messageUpdate(assistant(0, 0), toolDelta(0, "x".repeat(65_537))).usageChanged,
371
+ ).toBe(true);
372
+ expect(oversized.currentDisplayTokens()).toEqual({
373
+ input: 0,
374
+ output: 0,
375
+ outputApproximate: true,
376
+ });
377
+ expect(oversized.diagnostics()).toMatchObject({
378
+ contentBlocks: 0,
379
+ estimateIncomplete: true,
380
+ });
381
+ expect(oversized.messageEnd(assistant(11, 7))).toEqual(accepted({ input: 11, output: 7 }));
382
+ });
383
+
384
+ it("caps tool-call blocks and preserves exact final reconciliation", () => {
385
+ const tracker = new InteractionMetricsTracker();
386
+ tracker.agentStart();
387
+ tracker.turnStart();
388
+ for (let index = 0; index < 129; index++) {
389
+ tracker.messageUpdate(assistant(0, 0), toolDelta(index, "x"));
390
+ }
391
+ expect(tracker.diagnostics()).toMatchObject({
392
+ contentBlocks: 128,
393
+ estimateIncomplete: true,
394
+ });
395
+ expect(tracker.messageEnd(assistant(9, 4))).toEqual(accepted({ input: 9, output: 4 }));
396
+ expect(tracker.diagnostics()).toMatchObject({ contentBlocks: 0, retainedContentUnits: 0 });
397
+ });
398
+
399
+ it("never commits an estimate on malformed closure", () => {
400
+ const tracker = new InteractionMetricsTracker();
401
+ tracker.agentStart();
402
+ tracker.turnStart();
403
+ tracker.messageUpdate(assistant(0, 0), delta("text_delta", 0, "abcdefgh", "abcdefgh"));
404
+ expect(tracker.messageEnd(assistant(-1, 1))).toEqual(
405
+ accepted({ input: 0, output: 0 }, "no-snapshot", {
406
+ input: 0,
407
+ output: 2,
408
+ outputApproximate: true,
409
+ }),
410
+ );
411
+ expect(tracker.currentTokens()).toEqual({ input: 0, output: 0 });
412
+ });
413
+ });
414
+
415
+ describe("interaction metrics tracker", () => {
416
+ it("promotes a response-less stream to a normalized ID and ignores its duplicate final", () => {
417
+ const tracker = new InteractionMetricsTracker();
418
+ tracker.agentStart(1_000);
419
+ tracker.turnStart();
420
+ expect(
421
+ tracker.messageUpdate(assistant(100, 4, { timestamp: 1, provider: "first", model: "first" }))
422
+ .displayTokens,
423
+ ).toEqual({ input: 100, output: 4, outputApproximate: false });
424
+ expect(
425
+ tracker.messageUpdate(
426
+ assistant(120, 7, {
427
+ responseId: " response-a ",
428
+ timestamp: 2,
429
+ provider: "second",
430
+ model: "second",
431
+ }),
432
+ ).displayTokens,
433
+ ).toEqual({ input: 120, output: 7, outputApproximate: false });
434
+ expect(
435
+ tracker.messageEnd(assistant(150, 9, { responseId: "response-a", timestamp: 3 })),
436
+ ).toEqual(accepted({ input: 150, output: 9 }));
437
+ expect(
438
+ tracker.messageEnd(assistant(999, 99, { responseId: " response-a ", timestamp: 4 })),
439
+ ).toEqual({ status: "rejected" });
440
+ });
441
+
442
+ it("deduplicates the same ID even after a new turn slot", () => {
443
+ const tracker = new InteractionMetricsTracker();
444
+ tracker.agentStart();
445
+ tracker.turnStart();
446
+ tracker.messageEnd(assistant(10, 1, { responseId: "a" }));
447
+ tracker.turnStart();
448
+ expect(tracker.messageEnd(assistant(10, 1, { responseId: " a " }))).toEqual({
449
+ status: "duplicate",
450
+ });
451
+ });
452
+
453
+ it("keeps short IDs exact and treats overlong IDs as anonymous", () => {
454
+ const tracker = new InteractionMetricsTracker();
455
+ const first = `${"a".repeat(126)}-1`;
456
+ const second = `${"a".repeat(126)}-2`;
457
+ tracker.agentStart();
458
+ tracker.turnStart();
459
+ expect(tracker.messageEnd(assistant(10, 1, { responseId: first }))).toEqual(
460
+ accepted({ input: 10, output: 1 }),
461
+ );
462
+ tracker.turnStart();
463
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: second }))).toEqual(
464
+ accepted({ input: 30, output: 3 }),
465
+ );
466
+ tracker.turnStart();
467
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: first }))).toEqual({
468
+ status: "duplicate",
469
+ });
470
+
471
+ const overlong = "x".repeat(129);
472
+ tracker.turnStart();
473
+ expect(tracker.messageEnd(assistant(3, 4, { responseId: overlong }))).toEqual(
474
+ accepted({ input: 33, output: 7 }),
475
+ );
476
+ expect(tracker.diagnostics().responseIds).toBe(2);
477
+ });
478
+
479
+ it("rejects multi-megabyte response IDs before normalization or retention", () => {
480
+ const tracker = new InteractionMetricsTracker();
481
+ const overlong = ` ${"x".repeat(2 * 1024 * 1024)} `;
482
+ tracker.agentStart();
483
+ tracker.turnStart();
484
+ const startedAt = performance.now();
485
+ expect(tracker.messageUpdate(assistant(2, 1, { responseId: overlong })).displayTokens).toEqual({
486
+ input: 2,
487
+ output: 1,
488
+ outputApproximate: false,
489
+ });
490
+ expect(tracker.messageEnd(assistant(7, 3, { responseId: overlong }))).toEqual(
491
+ accepted({ input: 7, output: 3 }),
492
+ );
493
+ expect(performance.now() - startedAt).toBeLessThan(1_000);
494
+ expect(tracker.diagnostics().responseIds).toBe(0);
495
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: overlong }))).toEqual({
496
+ status: "rejected",
497
+ });
498
+ });
499
+
500
+ it("counts a reused response ID independently in a later low-level run", () => {
501
+ const tracker = new InteractionMetricsTracker();
502
+ tracker.agentStart();
503
+ tracker.turnStart();
504
+ tracker.messageEnd(assistant(10, 1, { responseId: "reused" }));
505
+ tracker.agentEnd();
506
+ tracker.agentStart();
507
+ tracker.turnStart();
508
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: " reused " }))).toEqual(
509
+ accepted({
510
+ input: 30,
511
+ output: 3,
512
+ }),
513
+ );
514
+ });
515
+
516
+ it("rejects distinct finals until turn_start authorizes each slot", () => {
517
+ const tracker = new InteractionMetricsTracker();
518
+ tracker.agentStart();
519
+ expect(tracker.messageEnd(assistant(10, 1, { responseId: "a", timestamp: 7 }))).toEqual({
520
+ status: "rejected",
521
+ });
522
+ tracker.turnStart();
523
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b", timestamp: 7 }))).toEqual(
524
+ accepted({ input: 20, output: 2 }),
525
+ );
526
+ });
527
+
528
+ it("counts response-less same-metadata responses only when turn_start separates them", () => {
529
+ const tracker = new InteractionMetricsTracker();
530
+ tracker.agentStart();
531
+ tracker.turnStart();
532
+ tracker.messageEnd(assistant(10, 1, { timestamp: 7 }));
533
+ expect(tracker.messageEnd(assistant(10, 1, { timestamp: 7 }))).toEqual({ status: "rejected" });
534
+ tracker.turnStart();
535
+ expect(tracker.messageEnd(assistant(10, 1, { timestamp: 7 }))).toEqual(
536
+ accepted({
537
+ input: 20,
538
+ output: 2,
539
+ }),
540
+ );
541
+ });
542
+
543
+ it("deduplicates a streaming error final despite changed metadata and timestamp", () => {
544
+ const tracker = new InteractionMetricsTracker();
545
+ tracker.agentStart();
546
+ tracker.turnStart();
547
+ tracker.messageUpdate(
548
+ assistant(5, 1, {
549
+ responseId: "error-response",
550
+ provider: "first",
551
+ model: "first",
552
+ timestamp: 1,
553
+ }),
554
+ );
555
+ expect(
556
+ tracker.messageEnd(
557
+ assistant(8, 2, {
558
+ responseId: "error-response",
559
+ provider: "second",
560
+ model: "second",
561
+ stopReason: "error",
562
+ timestamp: 2,
563
+ }),
564
+ ),
565
+ ).toEqual(accepted({ input: 8, output: 2 }));
566
+ expect(
567
+ tracker.messageEnd(
568
+ assistant(80, 20, {
569
+ responseId: "error-response",
570
+ provider: "third",
571
+ model: "third",
572
+ stopReason: "error",
573
+ timestamp: 3,
574
+ }),
575
+ ),
576
+ ).toEqual({ status: "rejected" });
577
+ });
578
+
579
+ it("deduplicates a response-less aborted streaming final", () => {
580
+ const tracker = new InteractionMetricsTracker();
581
+ tracker.agentStart();
582
+ tracker.turnStart();
583
+ tracker.messageUpdate(assistant(4, 1, { stopReason: "aborted", timestamp: 1 }));
584
+ expect(tracker.messageEnd(assistant(6, 2, { stopReason: "aborted", timestamp: 2 }))).toEqual(
585
+ accepted({
586
+ input: 6,
587
+ output: 2,
588
+ }),
589
+ );
590
+ expect(tracker.messageEnd(assistant(60, 20, { stopReason: "aborted", timestamp: 3 }))).toEqual({
591
+ status: "rejected",
592
+ });
593
+ });
594
+
595
+ it("displays cumulative usage throughout a three-response tool loop", () => {
596
+ const tracker = new InteractionMetricsTracker();
597
+ tracker.agentStart();
598
+ for (const [input, output, expectedUpdate, expectedFinal, responseId, stopReason] of [
599
+ [
600
+ 10,
601
+ 1,
602
+ { input: 9, output: 1, outputApproximate: false },
603
+ { input: 10, output: 1 },
604
+ "a",
605
+ "toolUse",
606
+ ],
607
+ [
608
+ 20,
609
+ 2,
610
+ { input: 29, output: 3, outputApproximate: false },
611
+ { input: 30, output: 3 },
612
+ "b",
613
+ "toolUse",
614
+ ],
615
+ [
616
+ 30,
617
+ 3,
618
+ { input: 59, output: 6, outputApproximate: false },
619
+ { input: 60, output: 6 },
620
+ "c",
621
+ "stop",
622
+ ],
623
+ ] as const) {
624
+ tracker.turnStart();
625
+ expect(
626
+ tracker.messageUpdate(assistant(input - 1, output, { responseId, stopReason }))
627
+ .displayTokens,
628
+ ).toEqual(expectedUpdate);
629
+ expect(tracker.messageEnd(assistant(input, output, { responseId, stopReason }))).toEqual(
630
+ accepted(expectedFinal),
631
+ );
632
+ }
633
+ expect(tracker.currentTokens()).toEqual({ input: 60, output: 6 });
634
+ });
635
+
636
+ it("retains committed totals for a next response's initial zero snapshot", () => {
637
+ const tracker = new InteractionMetricsTracker();
638
+ tracker.agentStart();
639
+ tracker.turnStart();
640
+ tracker.messageEnd(assistant(10, 1, { responseId: "first" }));
641
+ tracker.turnStart();
642
+ expect(tracker.messageUpdate(assistant(0, 0, { responseId: "second" })).displayTokens).toEqual({
643
+ input: 10,
644
+ output: 1,
645
+ outputApproximate: true,
646
+ });
647
+ expect(tracker.messageUpdate(assistant(7, 2, { responseId: "second" })).displayTokens).toEqual({
648
+ input: 17,
649
+ output: 3,
650
+ outputApproximate: false,
651
+ });
652
+ });
653
+
654
+ it.each([undefined, "final-id"])(
655
+ "accepts authorized final-only usage with ID %s",
656
+ (responseId) => {
657
+ const tracker = new InteractionMetricsTracker();
658
+ tracker.agentStart();
659
+ tracker.turnStart();
660
+ expect(tracker.messageEnd(assistant(4, 2, { responseId }))).toEqual(
661
+ accepted({ input: 4, output: 2 }),
662
+ );
663
+ },
664
+ );
665
+
666
+ it("shows repeated initial zero updates once and ignores placeholder regression", () => {
667
+ const tracker = new InteractionMetricsTracker();
668
+ tracker.agentStart();
669
+ tracker.turnStart();
670
+ expect(tracker.messageUpdate(assistant(0, 0, { responseId: "stream" })).displayTokens).toEqual({
671
+ input: 0,
672
+ output: 0,
673
+ outputApproximate: true,
674
+ });
675
+ expect(tracker.messageUpdate(assistant(0, 0, { responseId: "stream" })).usageChanged).toBe(
676
+ false,
677
+ );
678
+ expect(tracker.currentTokens()).toEqual({ input: 0, output: 0 });
679
+ expect(tracker.messageUpdate(assistant(8, 2, { responseId: "stream" })).displayTokens).toEqual({
680
+ input: 8,
681
+ output: 2,
682
+ outputApproximate: false,
683
+ });
684
+ expect(tracker.messageUpdate(assistant(0, 0, { responseId: "stream" })).displayTokens).toEqual({
685
+ input: 8,
686
+ output: 2,
687
+ outputApproximate: false,
688
+ });
689
+ expect(tracker.currentTokens()).toEqual({ input: 8, output: 2 });
690
+ });
691
+
692
+ it("commits a zero-only final exactly once", () => {
693
+ const tracker = new InteractionMetricsTracker();
694
+ tracker.agentStart(1_000);
695
+ tracker.turnStart();
696
+ expect(
697
+ tracker.messageUpdate(assistant(0, 0, { responseId: "zero-only" })).displayTokens,
698
+ ).toEqual({ input: 0, output: 0, outputApproximate: true });
699
+ expect(tracker.messageEnd(assistant(0, 0, { responseId: "zero-only" }))).toEqual(
700
+ accepted({
701
+ input: 0,
702
+ output: 0,
703
+ }),
704
+ );
705
+ expect(tracker.messageEnd(assistant(9, 3, { responseId: "zero-only" }))).toEqual({
706
+ status: "rejected",
707
+ });
708
+ tracker.agentEnd();
709
+ expect(tracker.settle(true, 2_000)?.summary).toEqual({
710
+ durationMs: 1_000,
711
+ thoughtDurationMs: 0,
712
+ input: 0,
713
+ output: 0,
714
+ });
715
+ });
716
+
717
+ it("rejects invalid and non-assistant updates without changing accounting", () => {
718
+ const tracker = new InteractionMetricsTracker();
719
+ tracker.agentStart();
720
+ tracker.turnStart();
721
+ tracker.messageUpdate(assistant(8, 2));
722
+ expect(tracker.messageUpdate(assistant(-1, 4)).usageChanged).toBe(false);
723
+ expect(
724
+ tracker.messageUpdate({ role: "user", usage: { input: 99, output: 99 } }).usageChanged,
725
+ ).toBe(false);
726
+ expect(tracker.currentTokens()).toEqual({ input: 8, output: 2 });
727
+ });
728
+
729
+ it.each([
730
+ [assistant(0, 0), { input: 0, output: 0 }],
731
+ [assistant(-1, 0), undefined],
732
+ [assistant(0, 1.5), undefined],
733
+ [assistant(Number.POSITIVE_INFINITY, 0), undefined],
734
+ [assistant(Number.MAX_SAFE_INTEGER + 1, 0), undefined],
735
+ [{ role: "user", usage: { input: 0, output: 0 } }, undefined],
736
+ ])("strictly parses assistant usage %#", (message, expected) => {
737
+ expect(parseAssistantMessageTokens(message)).toEqual(expected);
738
+ });
739
+
740
+ it("saturates token fields independently and settles valid data with a clamped duration", () => {
741
+ const tracker = new InteractionMetricsTracker();
742
+ tracker.agentStart(1_000);
743
+ tracker.turnStart();
744
+ expect(tracker.messageEnd(assistant(-1, 4, { responseId: "invalid-negative" }))).toEqual(
745
+ accepted({ input: 0, output: 0 }, "no-snapshot"),
746
+ );
747
+ expect(
748
+ tracker.messageEnd(
749
+ assistant(Number.MAX_SAFE_INTEGER + 1, 4, { responseId: "invalid-unsafe" }),
750
+ ),
751
+ ).toEqual({ status: "rejected" });
752
+ tracker.turnStart();
753
+ tracker.messageEnd(assistant(Number.MAX_SAFE_INTEGER - 2, 10, { responseId: "first-valid" }));
754
+ tracker.turnStart();
755
+ expect(
756
+ tracker.messageEnd(
757
+ assistant(5, Number.MAX_SAFE_INTEGER - 20, { responseId: "second-valid" }),
758
+ ),
759
+ ).toEqual(
760
+ accepted({
761
+ input: Number.MAX_SAFE_INTEGER,
762
+ output: Number.MAX_SAFE_INTEGER - 10,
763
+ }),
764
+ );
765
+ tracker.turnStart();
766
+ expect(tracker.messageEnd(assistant(0, 20, { responseId: "third-valid" }))).toEqual(
767
+ accepted({
768
+ input: Number.MAX_SAFE_INTEGER,
769
+ output: Number.MAX_SAFE_INTEGER,
770
+ }),
771
+ );
772
+ tracker.agentEnd();
773
+ const settled = tracker.settle(true, Number.POSITIVE_INFINITY);
774
+ expect(settled?.summary).toEqual({
775
+ durationMs: Number.MAX_SAFE_INTEGER,
776
+ thoughtDurationMs: 0,
777
+ input: Number.MAX_SAFE_INTEGER,
778
+ output: Number.MAX_SAFE_INTEGER,
779
+ });
780
+ expect(
781
+ isTurnSummaryData({
782
+ version: 1,
783
+ durationMs: settled?.summary.durationMs,
784
+ input: settled?.summary.input,
785
+ output: settled?.summary.output,
786
+ }),
787
+ ).toBe(true);
788
+ });
789
+
790
+ it("spans low-level runs and resets only after settlement", () => {
791
+ const tracker = new InteractionMetricsTracker();
792
+ expect(tracker.agentStart(1_000)).toEqual({ interactionStarted: true });
793
+ tracker.turnStart();
794
+ tracker.messageEnd(assistant(10, 2, { responseId: "a" }));
795
+ tracker.agentEnd();
796
+ expect(tracker.agentStart(2_000)).toEqual({ interactionStarted: false });
797
+ tracker.turnStart();
798
+ tracker.messageEnd(assistant(20, 3, { responseId: "b" }));
799
+ tracker.agentEnd();
800
+ expect(tracker.settle(true, 3_000)?.summary).toEqual({
801
+ durationMs: 2_000,
802
+ thoughtDurationMs: 0,
803
+ input: 30,
804
+ output: 5,
805
+ });
806
+ expect(tracker.settle(true)).toBeUndefined();
807
+ tracker.agentStart(50_000);
808
+ tracker.shutdown();
809
+ expect(tracker.settle(true)).toBeUndefined();
810
+ });
811
+
812
+ it.each(["error", "aborted"])(
813
+ "commits the last valid snapshot once when a matching %s final has malformed usage",
814
+ (stopReason) => {
815
+ const tracker = new InteractionMetricsTracker();
816
+ tracker.agentStart(1_000);
817
+ tracker.turnStart();
818
+ tracker.messageUpdate(assistant(10, 1, { responseId: "a" }));
819
+ const malformed = assistant(-1, 99, { responseId: "a", stopReason });
820
+ expect(tracker.messageEnd(malformed)).toEqual(
821
+ accepted({ input: 10, output: 1 }, "last-snapshot"),
822
+ );
823
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 1 });
824
+ expect(tracker.messageEnd(malformed)).toEqual({ status: "rejected" });
825
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 1 });
826
+
827
+ tracker.turnStart();
828
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b" }))).toEqual(
829
+ accepted({
830
+ input: 30,
831
+ output: 3,
832
+ }),
833
+ );
834
+ tracker.agentEnd();
835
+ expect(tracker.settle(true, 2_000)?.summary).toEqual({
836
+ durationMs: 1_000,
837
+ thoughtDurationMs: 0,
838
+ input: 30,
839
+ output: 3,
840
+ });
841
+ },
842
+ );
843
+
844
+ it("preserves a live estimate when a malformed final has no valid snapshot", () => {
845
+ const tracker = new InteractionMetricsTracker();
846
+ tracker.agentStart();
847
+ tracker.turnStart();
848
+ tracker.messageUpdate(
849
+ assistant(0, 0, { responseId: "invalid" }),
850
+ delta("thinking_delta", 0, "abcdefgh", "abcdefgh"),
851
+ );
852
+ expect(tracker.messageEnd(assistant(-1, 1, { responseId: "invalid" }))).toEqual(
853
+ accepted({ input: 0, output: 0 }, "no-snapshot", {
854
+ input: 0,
855
+ output: 2,
856
+ outputApproximate: true,
857
+ }),
858
+ );
859
+ expect(tracker.currentTokens()).toEqual({ input: 0, output: 0 });
860
+ expect(tracker.currentDisplayTokens()).toEqual({
861
+ input: 0,
862
+ output: 2,
863
+ outputApproximate: true,
864
+ });
865
+ tracker.turnStart();
866
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b" }))).toEqual(
867
+ accepted({
868
+ input: 20,
869
+ output: 2,
870
+ }),
871
+ );
872
+ });
873
+
874
+ it("preserves an estimate above a lower provider anchor on malformed final", () => {
875
+ const tracker = new InteractionMetricsTracker();
876
+ tracker.agentStart();
877
+ tracker.turnStart();
878
+ tracker.messageUpdate(assistant(10, 1, { responseId: "anchored" }));
879
+ tracker.messageUpdate(
880
+ assistant(10, 1, { responseId: "anchored" }),
881
+ delta("text_delta", 0, "abcdefghijkl", "abcdefghijkl"),
882
+ );
883
+ expect(tracker.messageEnd(assistant(-1, 9, { responseId: "anchored" }))).toEqual(
884
+ accepted({ input: 10, output: 1 }, "last-snapshot", {
885
+ input: 10,
886
+ output: 4,
887
+ outputApproximate: true,
888
+ }),
889
+ );
890
+ tracker.agentEnd();
891
+ expect(tracker.settle(true)?.summary).toMatchObject({ input: 10, output: 1 });
892
+ });
893
+
894
+ it("commits an unfinalized valid snapshot before opening the next response", () => {
895
+ const tracker = new InteractionMetricsTracker();
896
+ tracker.agentStart();
897
+ tracker.turnStart();
898
+ tracker.messageUpdate(assistant(10, 1, { responseId: "a" }));
899
+ tracker.turnStart();
900
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b" }))).toEqual(
901
+ accepted({
902
+ input: 30,
903
+ output: 3,
904
+ }),
905
+ );
906
+ });
907
+
908
+ it("rejects mismatched updates and finals without modifying or closing the active response", () => {
909
+ const tracker = new InteractionMetricsTracker();
910
+ tracker.agentStart(0);
911
+ tracker.turnStart(0);
912
+ tracker.messageUpdate(
913
+ assistant(10, 1, { responseId: "a" }),
914
+ {
915
+ type: "thinking_start",
916
+ contentIndex: 0,
917
+ partial: assistant(0, 0),
918
+ } as never,
919
+ 0,
920
+ );
921
+ expect(tracker.messageUpdate(assistant(99, 9, { responseId: "b" }), undefined, 10)).toEqual({
922
+ displayTokens: { input: 10, output: 1, outputApproximate: false },
923
+ usageChanged: false,
924
+ thoughtChanged: false,
925
+ });
926
+ expect(tracker.messageEnd(assistant(99, 9, { responseId: "b" }), 20)).toEqual({
927
+ status: "rejected",
928
+ });
929
+ expect(tracker.currentThought(30)).toEqual({ durationMs: 30, active: true });
930
+ expect(tracker.messageEnd(assistant(12, 2, { responseId: "a" }), 40)).toEqual(
931
+ accepted({
932
+ input: 12,
933
+ output: 2,
934
+ }),
935
+ );
936
+ expect(tracker.currentThought(50)).toEqual({ durationMs: 40, active: false });
937
+ });
938
+
939
+ it("does not let a mismatched malformed final close or commit the active response", () => {
940
+ const tracker = new InteractionMetricsTracker();
941
+ tracker.agentStart(0);
942
+ tracker.turnStart(0);
943
+ tracker.messageUpdate(
944
+ assistant(10, 1, { responseId: "a" }),
945
+ {
946
+ type: "thinking_start",
947
+ contentIndex: 0,
948
+ partial: assistant(0, 0),
949
+ } as never,
950
+ 0,
951
+ );
952
+ expect(tracker.messageEnd(assistant(-1, 9, { responseId: "b" }), 20)).toEqual({
953
+ status: "rejected",
954
+ });
955
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 1 });
956
+ expect(tracker.currentThought(30)).toEqual({ durationMs: 30, active: true });
957
+ expect(tracker.messageEnd(assistant(-1, 9, { responseId: "a" }), 40)).toEqual(
958
+ accepted({ input: 10, output: 1 }, "last-snapshot"),
959
+ );
960
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 1 });
961
+ expect(tracker.currentThought(50)).toEqual({ durationMs: 40, active: false });
962
+ tracker.turnStart();
963
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b" }))).toEqual(
964
+ accepted({
965
+ input: 30,
966
+ output: 3,
967
+ }),
968
+ );
969
+ });
970
+
971
+ it("does not let a stale committed ID promote a fresh response-less turn slot", () => {
972
+ const tracker = new InteractionMetricsTracker();
973
+ tracker.agentStart();
974
+ tracker.turnStart();
975
+ tracker.messageEnd(assistant(10, 1, { responseId: "a" }));
976
+ tracker.turnStart();
977
+ expect(tracker.messageUpdate(assistant(99, 9, { responseId: "a" })).usageChanged).toBe(false);
978
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "b" }))).toEqual(
979
+ accepted({
980
+ input: 30,
981
+ output: 3,
982
+ }),
983
+ );
984
+ });
985
+
986
+ it("keeps authorized live and exact accounting at estimator and response-ID caps", () => {
987
+ const tracker = new InteractionMetricsTracker();
988
+ tracker.agentStart();
989
+ for (let index = 0; index < 128; index++) {
990
+ tracker.turnStart();
991
+ tracker.messageUpdate(
992
+ assistant(0, 0, { responseId: `estimated-${index}` }),
993
+ delta("text_delta", 0, "abcd", "abcd"),
994
+ );
995
+ tracker.messageEnd(assistant(1, 1, { responseId: `estimated-${index}` }));
996
+ }
997
+ tracker.turnStart();
998
+ expect(
999
+ tracker.messageUpdate(
1000
+ assistant(0, 0, { responseId: "exact-only" }),
1001
+ delta("text_delta", 0, "abcd", "abcd"),
1002
+ ).displayTokens,
1003
+ ).toEqual({ input: 128, output: 128, outputApproximate: true });
1004
+ expect(tracker.messageEnd(assistant(2, 3, { responseId: "exact-only" }))).toEqual(
1005
+ accepted({ input: 130, output: 131 }),
1006
+ );
1007
+
1008
+ for (let index = 129; index < 256; index++) {
1009
+ tracker.turnStart();
1010
+ tracker.messageEnd(assistant(1, 1, { responseId: `exact-${index}` }));
1011
+ }
1012
+ expect(tracker.diagnostics().responseIds).toBe(256);
1013
+ tracker.turnStart();
1014
+ expect(tracker.messageEnd(assistant(3, 4, { responseId: "overflow-id" }))).toEqual(
1015
+ accepted({ input: 260, output: 262 }),
1016
+ );
1017
+ tracker.turnStart();
1018
+ expect(
1019
+ tracker.messageUpdate(
1020
+ assistant(99, 99, { responseId: "overflow-id" }),
1021
+ delta("text_delta", 0, "replay", "replay"),
1022
+ ),
1023
+ ).toMatchObject({ usageChanged: false });
1024
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: "overflow-id" }))).toEqual({
1025
+ status: "duplicate",
1026
+ });
1027
+ expect(tracker.messageEnd(assistant(5, 6, { responseId: "fresh-after-overflow" }))).toEqual(
1028
+ accepted({ input: 265, output: 268 }),
1029
+ );
1030
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: "fresh-after-overflow" }))).toEqual({
1031
+ status: "rejected",
1032
+ });
1033
+ expect(tracker.messageEnd(assistant(99, 99))).toEqual({ status: "rejected" });
1034
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: "overflow-unsolicited" }))).toEqual({
1035
+ status: "rejected",
1036
+ });
1037
+
1038
+ tracker.turnStart();
1039
+ expect(tracker.messageEnd(assistant(1, 1))).toEqual(accepted({ input: 266, output: 269 }));
1040
+ expect(tracker.messageEnd(assistant(99, 99))).toEqual({ status: "rejected" });
1041
+
1042
+ tracker.turnStart();
1043
+ expect(
1044
+ tracker.messageUpdate(
1045
+ assistant(4, 2, { responseId: "authorized-at-cap" }),
1046
+ delta("text_delta", 0, "abcdefgh", "abcdefgh"),
1047
+ ).displayTokens,
1048
+ ).toEqual({ input: 270, output: 271, outputApproximate: true });
1049
+ expect(tracker.messageUpdate(assistant(99, 99, { responseId: "mismatch" })).usageChanged).toBe(
1050
+ false,
1051
+ );
1052
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: "mismatch" }))).toEqual({
1053
+ status: "rejected",
1054
+ });
1055
+ expect(tracker.messageEnd(assistant(9, 9, { responseId: "authorized-at-cap" }))).toEqual(
1056
+ accepted({ input: 275, output: 278 }),
1057
+ );
1058
+ expect(tracker.diagnostics().responseIds).toBe(256);
1059
+ expect(tracker.messageEnd(assistant(99, 99, { responseId: "authorized-at-cap" }))).toEqual({
1060
+ status: "rejected",
1061
+ });
1062
+ expect(
1063
+ tracker.messageUpdate(assistant(99, 99, { responseId: "unknown" }), {
1064
+ type: "unknown",
1065
+ } as never).usageChanged,
1066
+ ).toBe(false);
1067
+ });
1068
+
1069
+ it("bounds run state while preserving folded exact totals", () => {
1070
+ const tracker = new InteractionMetricsTracker();
1071
+ for (let index = 0; index < 40; index++) {
1072
+ tracker.agentStart(index);
1073
+ tracker.turnStart(index);
1074
+ tracker.messageEnd(assistant(1, 2, { responseId: `run-${index}` }));
1075
+ tracker.agentEnd(index + 1);
1076
+ }
1077
+ expect(tracker.currentTokens()).toEqual({ input: 40, output: 80 });
1078
+ expect(tracker.diagnostics().runs).toBeLessThanOrEqual(32);
1079
+ });
1080
+
1081
+ it("ignores late events after agent_end and accepts them only after a real next agent_start", () => {
1082
+ const tracker = new InteractionMetricsTracker();
1083
+ tracker.agentStart(0);
1084
+ tracker.turnStart(0);
1085
+ tracker.messageEnd(assistant(10, 1, { responseId: "a" }));
1086
+ tracker.agentEnd(10);
1087
+ expect(tracker.messageUpdate(assistant(90, 9, { responseId: "late" })).usageChanged).toBe(
1088
+ false,
1089
+ );
1090
+ expect(tracker.messageEnd(assistant(90, 9, { responseId: "late" }))).toEqual({
1091
+ status: "rejected",
1092
+ });
1093
+ expect(tracker.currentTokens()).toEqual({ input: 10, output: 1 });
1094
+ tracker.agentStart(20);
1095
+ tracker.turnStart(20);
1096
+ expect(tracker.messageEnd(assistant(20, 2, { responseId: "late" }))).toEqual(
1097
+ accepted({
1098
+ input: 30,
1099
+ output: 3,
1100
+ }),
1101
+ );
1102
+ });
1103
+
1104
+ it("retains a live response ID and snapshot across non-idle partitioning", () => {
1105
+ const tracker = new InteractionMetricsTracker();
1106
+ tracker.agentStart(1_000);
1107
+ tracker.turnStart();
1108
+ tracker.messageEnd(assistant(10, 1, { responseId: "settled" }));
1109
+ tracker.agentEnd();
1110
+ tracker.agentStart(5_000);
1111
+ tracker.turnStart();
1112
+ tracker.messageUpdate(assistant(7, 2, { responseId: " live " }));
1113
+ expect(tracker.settle(false, 6_000)).toEqual({
1114
+ summary: { durationMs: 5_000, thoughtDurationMs: 0, input: 10, output: 1 },
1115
+ nextStartedAt: 5_000,
1116
+ nextTokens: { input: 7, output: 2, outputApproximate: false },
1117
+ nextThought: { durationMs: 0, active: false },
1118
+ });
1119
+ expect(tracker.messageEnd(assistant(9, 3, { responseId: "live", timestamp: 99 }))).toEqual(
1120
+ accepted({
1121
+ input: 9,
1122
+ output: 3,
1123
+ }),
1124
+ );
1125
+ expect(tracker.messageEnd(assistant(9, 3, { responseId: "live" }))).toEqual({
1126
+ status: "rejected",
1127
+ });
1128
+ });
1129
+ });
1130
+
1131
+ describe("thought duration tracking", () => {
1132
+ const thinking = (
1133
+ type: "thinking_start" | "thinking_delta" | "thinking_end",
1134
+ contentIndex: number,
1135
+ ) =>
1136
+ ({
1137
+ type,
1138
+ contentIndex,
1139
+ partial: assistant(0, 0),
1140
+ ...(type === "thinking_delta" ? { delta: "x" } : {}),
1141
+ ...(type === "thinking_end" ? { content: "x" } : {}),
1142
+ }) as never;
1143
+
1144
+ it("retains a bounded exact lower bound after adversarial subtraction", () => {
1145
+ const result = subtractThoughtIntervalsWithinCap(
1146
+ [{ start: 0, end: 513 }],
1147
+ Array.from({ length: 257 }, (_, index) => ({
1148
+ start: index * 2 + 1,
1149
+ end: index * 2 + 2,
1150
+ })),
1151
+ );
1152
+ expect(result.incomplete).toBe(true);
1153
+ expect(result.intervals).toHaveLength(256);
1154
+ expect(result.intervals[0]).toEqual({ start: 0, end: 1 });
1155
+ expect(result.intervals.at(-1)).toEqual({ start: 510, end: 511 });
1156
+ for (let index = 0; index < result.intervals.length; index++) {
1157
+ const interval = result.intervals[index];
1158
+ expect(interval.end).toBeGreaterThan(interval.start);
1159
+ expect(interval.start).toBeGreaterThanOrEqual(0);
1160
+ expect(interval.end).toBeLessThanOrEqual(513);
1161
+ if (index > 0) expect(interval.start).toBeGreaterThan(result.intervals[index - 1].end);
1162
+ }
1163
+ });
1164
+
1165
+ it("counts the union of overlapping blocks and updates open elapsed from receipt time", () => {
1166
+ let now = 1_000;
1167
+ const tracker = new InteractionMetricsTracker(() => now);
1168
+ tracker.agentStart();
1169
+ tracker.turnStart();
1170
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_start", 0));
1171
+ now = 2_000;
1172
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_start", 1));
1173
+ now = 3_000;
1174
+ expect(tracker.currentThought()).toEqual({ durationMs: 2_000, active: true });
1175
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_end", 0));
1176
+ now = 4_000;
1177
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_end", 1));
1178
+ expect(tracker.currentThought()).toEqual({ durationMs: 3_000, active: false });
1179
+ });
1180
+
1181
+ it("keeps an exact lower-bound union after the strict thought interval cap", () => {
1182
+ const tracker = new InteractionMetricsTracker();
1183
+ tracker.agentStart(0);
1184
+ for (let index = 0; index < 256; index++) {
1185
+ tracker.turnStart(index * 4);
1186
+ tracker.messageUpdate(
1187
+ assistant(0, 0, { responseId: `thought-${index}` }),
1188
+ thinking("thinking_start", 0),
1189
+ index * 4,
1190
+ );
1191
+ tracker.messageUpdate(
1192
+ assistant(0, 0, { responseId: `thought-${index}` }),
1193
+ thinking("thinking_end", 0),
1194
+ index * 4 + 1,
1195
+ );
1196
+ tracker.messageEnd(assistant(0, 0, { responseId: `thought-${index}` }), index * 4 + 1);
1197
+ }
1198
+ expect(tracker.diagnostics().thoughtIntervals).toBe(256);
1199
+ expect(tracker.currentThought(2_000)).toEqual({ durationMs: 256, active: false });
1200
+
1201
+ tracker.turnStart(2_000);
1202
+ tracker.messageUpdate(
1203
+ assistant(0, 0, { responseId: "overflow" }),
1204
+ thinking("thinking_start", 0),
1205
+ 2_000,
1206
+ );
1207
+ tracker.messageUpdate(
1208
+ assistant(0, 0, { responseId: "overflow" }),
1209
+ thinking("thinking_end", 0),
1210
+ 2_001,
1211
+ );
1212
+ expect(tracker.diagnostics().thoughtIntervals).toBe(256);
1213
+ expect(tracker.currentThought(2_001)).toEqual({ durationMs: 256, active: false });
1214
+
1215
+ tracker.turnStart(500);
1216
+ tracker.messageUpdate(
1217
+ assistant(0, 0, { responseId: "covered-overlap" }),
1218
+ thinking("thinking_start", 0),
1219
+ 500,
1220
+ );
1221
+ tracker.messageUpdate(
1222
+ assistant(0, 0, { responseId: "covered-overlap" }),
1223
+ thinking("thinking_end", 0),
1224
+ 505,
1225
+ );
1226
+ expect(tracker.diagnostics().thoughtIntervals).toBe(256);
1227
+ expect(tracker.currentThought(2_100)).toEqual({ durationMs: 256, active: false });
1228
+
1229
+ tracker.turnStart(2_002);
1230
+ tracker.messageUpdate(
1231
+ assistant(0, 0, { responseId: "gap-after-incomplete" }),
1232
+ thinking("thinking_start", 0),
1233
+ 2_002,
1234
+ );
1235
+ tracker.messageUpdate(
1236
+ assistant(0, 0, { responseId: "gap-after-incomplete" }),
1237
+ thinking("thinking_end", 0),
1238
+ 2_003,
1239
+ );
1240
+ expect(tracker.diagnostics().thoughtIntervals).toBe(256);
1241
+ expect(tracker.currentThought(2_100)).toEqual({ durationMs: 256, active: false });
1242
+ expect(tracker.settle(true, 2_100)?.summary.thoughtDurationMs).toBe(256);
1243
+ expect(tracker.currentThought()).toBeUndefined();
1244
+ });
1245
+
1246
+ it("handles duplicates, delta without start, missing ends, and reused content indexes", () => {
1247
+ let now = 10;
1248
+ const tracker = new InteractionMetricsTracker(() => now);
1249
+ tracker.agentStart();
1250
+ tracker.turnStart();
1251
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_delta", 0));
1252
+ tracker.messageUpdate(assistant(0, 0, { responseId: "a" }), thinking("thinking_start", 0));
1253
+ now = 20;
1254
+ tracker.messageEnd(assistant(1, 1, { responseId: "a" }));
1255
+ tracker.turnStart();
1256
+ now = 30;
1257
+ tracker.messageUpdate(assistant(0, 0, { responseId: "b" }), thinking("thinking_start", 0));
1258
+ now = 40;
1259
+ tracker.agentEnd();
1260
+ expect(tracker.currentThought()).toEqual({ durationMs: 20, active: false });
1261
+ });
1262
+
1263
+ it("ignores late thought after agent_end", () => {
1264
+ const tracker = new InteractionMetricsTracker();
1265
+ tracker.agentStart(0);
1266
+ tracker.agentEnd(10);
1267
+ const result = tracker.messageUpdate(
1268
+ assistant(0, 0, { responseId: "late" }),
1269
+ thinking("thinking_start", 0),
1270
+ 20,
1271
+ );
1272
+ expect(result.thoughtChanged).toBe(false);
1273
+ expect(tracker.currentThought(30)).toEqual({ durationMs: 0, active: false });
1274
+ });
1275
+
1276
+ it("partitions ended thought without double-counting a concurrent retained run", () => {
1277
+ const tracker = new InteractionMetricsTracker();
1278
+ tracker.agentStart(0);
1279
+ tracker.turnStart(0);
1280
+ tracker.messageUpdate(assistant(0, 0, { responseId: "old" }), thinking("thinking_start", 0), 0);
1281
+ tracker.agentEnd(100);
1282
+ tracker.agentStart(50);
1283
+ tracker.turnStart(50);
1284
+ tracker.messageUpdate(
1285
+ assistant(0, 0, { responseId: "live" }),
1286
+ thinking("thinking_start", 0),
1287
+ 50,
1288
+ );
1289
+ const split = tracker.settle(false, 100);
1290
+ expect(split?.summary.thoughtDurationMs).toBe(100);
1291
+ expect(split?.nextThought).toEqual({ durationMs: 0, active: true });
1292
+ expect(tracker.currentThought(150)).toEqual({ durationMs: 50, active: true });
1293
+ });
1294
+ });
1295
+
1296
+ describe("turn summary entry", () => {
1297
+ it("validates versioned numeric data and formats exact persistent text", () => {
1298
+ const data = { version: 1 as const, durationMs: 42_999, input: 27_000, output: 1_400 };
1299
+ expect(isTurnSummaryData(data)).toBe(true);
1300
+ expect(formatTurnSummary(data)).toBe(" Turn took 42s · ↑27k ↓1.4k");
1301
+ const rendered = renderTurnSummaryEntry(
1302
+ { data },
1303
+ {},
1304
+ { fg: (_color: string, text: string) => `\x1b[2m${text}\x1b[0m` },
1305
+ );
1306
+ expect(stripTerminalSequences(rendered?.render(100).join("\n") ?? "").trimEnd()).toBe(
1307
+ " Turn took 42s · ↑27k ↓1.4k",
1308
+ );
1309
+ });
1310
+
1311
+ it("validates and renders v2 with its persisted style independently of the current theme", () => {
1312
+ const data = {
1313
+ version: 2 as const,
1314
+ durationMs: 1,
1315
+ input: 2,
1316
+ output: 3,
1317
+ stylePrefix: "\x1b[1;38;5;202m",
1318
+ };
1319
+ expect(isTurnSummaryData(data)).toBe(true);
1320
+ const rendered = renderTurnSummaryEntry(
1321
+ { data },
1322
+ { colorSource: "theme", workingLineHigh: "green" },
1323
+ { fg: () => "changed-theme" },
1324
+ )?.render(100)[0];
1325
+ expect(rendered).toContain("\x1b[1;38;5;202m Turn took 0s · ↑2 ↓3\x1b[0m");
1326
+ expect(rendered).not.toContain("changed-theme");
1327
+ });
1328
+
1329
+ it("validates v3 exactly and formats thought while omitting zero", () => {
1330
+ const data = {
1331
+ version: 3 as const,
1332
+ durationMs: 56_999,
1333
+ thoughtDurationMs: 10_999,
1334
+ input: 7_100,
1335
+ output: 779,
1336
+ stylePrefix: "\x1b[1;36m",
1337
+ };
1338
+ expect(isTurnSummaryData(data)).toBe(true);
1339
+ expect(formatTurnSummary(data)).toBe(" Turn took 56s · thought for 10s · ↑7.1k ↓779");
1340
+ expect(formatTurnSummary({ ...data, thoughtDurationMs: 0 })).toBe(
1341
+ " Turn took 56s · ↑7.1k ↓779",
1342
+ );
1343
+ expect(isTurnSummaryData({ ...data, thoughtDurationMs: -1 })).toBe(false);
1344
+ expect(isTurnSummaryData({ ...data, extra: true })).toBe(false);
1345
+ expect(isTurnSummaryData({ ...data, stylePrefix: "\x1b]8;;bad\x07" })).toBe(false);
1346
+ });
1347
+
1348
+ it("accepts four safe SGR sequences but rejects a fifth sequence and injected controls", () => {
1349
+ const safe = {
1350
+ version: 2 as const,
1351
+ durationMs: 1,
1352
+ input: 2,
1353
+ output: 3,
1354
+ stylePrefix: "\x1b[38;5;202m\x1b[4m\x1b[3m\x1b[1m",
1355
+ };
1356
+ expect(isTurnSummaryData(safe)).toBe(true);
1357
+ const rendered = renderTurnSummaryEntry({ data: safe }, {}, { fg: (_color, text) => text })
1358
+ ?.render(100)
1359
+ .join("\n");
1360
+ expect(rendered).toContain(`${safe.stylePrefix} Turn took 0s · ↑2 ↓3\x1b[0m`);
1361
+ expect(isTurnSummaryData({ ...safe, stylePrefix: `${safe.stylePrefix}\x1b[2m` })).toBe(false);
1362
+ expect(isTurnSummaryData({ ...safe, stylePrefix: `${safe.stylePrefix}\x1b]8;;bad\x07` })).toBe(
1363
+ false,
1364
+ );
1365
+ expect(
1366
+ isTurnSummaryData({
1367
+ ...safe,
1368
+ stylePrefix: "\x1b[38;2;0000000255;0000000255;0000000255m".repeat(4),
1369
+ }),
1370
+ ).toBe(false);
1371
+ });
1372
+
1373
+ it.each([
1374
+ null,
1375
+ {},
1376
+ { version: 2, durationMs: 1, input: 1, output: 1 },
1377
+ { version: 2, durationMs: 1, input: 1, output: 1, stylePrefix: "\x1b[0m" },
1378
+ { version: 2, durationMs: 1, input: 1, output: 1, stylePrefix: "\x1b[31mprint" },
1379
+ { version: 2, durationMs: 1, input: 1, output: 1, stylePrefix: "\x1b]8;;bad\x07" },
1380
+ { version: 2, durationMs: 1, input: 1, output: 1, stylePrefix: "\x1b[2J" },
1381
+ { version: 2, durationMs: 1, input: 1, output: 1, stylePrefix: "\x1b[31m", extra: true },
1382
+ { version: 1, durationMs: -1, input: 1, output: 1 },
1383
+ { version: 1, durationMs: 1, input: 1.5, output: 1 },
1384
+ { version: 1, durationMs: 1, input: 1, output: 1, extra: true },
1385
+ ])("rejects invalid renderer data %#", (data) => {
1386
+ expect(renderTurnSummaryEntry({ data }, {}, { fg: (_color, text) => text })).toBeUndefined();
1387
+ });
1388
+
1389
+ it("proves custom entries persist while contributing zero model messages", () => {
1390
+ const entry = {
1391
+ type: "custom" as const,
1392
+ id: "summary",
1393
+ parentId: null,
1394
+ timestamp: new Date(0).toISOString(),
1395
+ customType: TURN_SUMMARY_ENTRY_TYPE,
1396
+ data: { version: 1, durationMs: 1, input: 0, output: 0 },
1397
+ };
1398
+ const entries = [entry];
1399
+ expect(entries[0]?.data).toEqual(entry.data);
1400
+ expect(buildSessionContext(entries).messages).toEqual([]);
1401
+ });
1402
+ });