@selesai/code 0.9.15 → 0.10.1

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 (269) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +6 -9
  3. package/dist/defaults/models.json +2 -2
  4. package/dist/defaults/settings.json +3 -7
  5. package/dist/extensions/package.json +2 -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/modes/interactive/components/settings-selector.d.ts +1 -1
  118. package/dist/modes/interactive/interactive-mode.js +1 -1
  119. package/dist/skills/design-references/SKILL.md +31 -0
  120. package/dist/skills/design-references/references/clickhouse/DESIGN.md +544 -0
  121. package/dist/themes/adventure.json +84 -0
  122. package/dist/themes/adwaita-dark.json +84 -0
  123. package/dist/themes/arcoiris.json +84 -0
  124. package/dist/themes/arthur.json +84 -0
  125. package/dist/themes/atom.json +84 -0
  126. package/dist/themes/aura.json +84 -0
  127. package/dist/themes/black-metal-bathory.json +84 -0
  128. package/dist/themes/black-metal-burzum.json +84 -0
  129. package/dist/themes/black-metal-khold.json +84 -0
  130. package/dist/themes/box.json +84 -0
  131. package/dist/themes/brogrammer.json +84 -0
  132. package/dist/themes/carbonfox.json +84 -0
  133. package/dist/themes/catppuccin-mocha.json +84 -0
  134. package/dist/themes/citruszest.json +84 -0
  135. package/dist/themes/cursor-dark.json +84 -0
  136. package/dist/themes/cutie-pro.json +84 -0
  137. package/dist/themes/dark-modern.json +84 -0
  138. package/dist/themes/dark-pastel.json +84 -0
  139. package/dist/themes/dimmed-monokai.json +84 -0
  140. package/dist/themes/doom-peacock.json +84 -0
  141. package/dist/themes/dracula-plus.json +84 -0
  142. package/dist/themes/dracula.json +80 -0
  143. package/dist/themes/earthsong.json +84 -0
  144. package/dist/themes/everforest-dark-hard.json +84 -0
  145. package/dist/themes/everforest-dark.json +80 -0
  146. package/dist/themes/fahrenheit.json +84 -0
  147. package/dist/themes/flatland.json +84 -0
  148. package/dist/themes/flexoki-dark.json +84 -0
  149. package/dist/themes/front-end-delight.json +84 -0
  150. package/dist/themes/fun-forrest.json +84 -0
  151. package/dist/themes/galizur.json +84 -0
  152. package/dist/themes/github-dark-colorblind.json +84 -0
  153. package/dist/themes/github-dark-high-contrast.json +84 -0
  154. package/dist/themes/glacier.json +84 -0
  155. package/dist/themes/gruber-darker.json +84 -0
  156. package/dist/themes/gruvbox-dark-hard.json +84 -0
  157. package/dist/themes/gruvbox-dark.json +84 -0
  158. package/dist/themes/gruvbox-material.json +84 -0
  159. package/dist/themes/guezwhoz.json +84 -0
  160. package/dist/themes/hacktober.json +84 -0
  161. package/dist/themes/hardcore.json +84 -0
  162. package/dist/themes/havn-skumring.json +84 -0
  163. package/dist/themes/ic-orange-ppl.json +84 -0
  164. package/dist/themes/iterm2-smoooooth.json +84 -0
  165. package/dist/themes/iterm2-tango-dark.json +84 -0
  166. package/dist/themes/japanesque.json +84 -0
  167. package/dist/themes/jellybeans.json +84 -0
  168. package/dist/themes/kanagawa-wave.json +84 -0
  169. package/dist/themes/kurokula.json +84 -0
  170. package/dist/themes/later-this-evening.json +84 -0
  171. package/dist/themes/lovelace.json +84 -0
  172. package/dist/themes/material-darker.json +84 -0
  173. package/dist/themes/matte-black.json +84 -0
  174. package/dist/themes/mellow.json +84 -0
  175. package/dist/themes/miasma.json +84 -0
  176. package/dist/themes/nord.json +80 -0
  177. package/dist/themes/nvim-dark.json +84 -0
  178. package/dist/themes/one-dark.json +80 -0
  179. package/dist/themes/popping-and-locking.json +84 -0
  180. package/dist/themes/rose-pine.json +80 -0
  181. package/dist/themes/sea-shells.json +84 -0
  182. package/dist/themes/sleepy-hollow.json +84 -0
  183. package/dist/themes/smyck.json +84 -0
  184. package/dist/themes/solarized-dark.json +80 -0
  185. package/dist/themes/tokyo-night.json +80 -0
  186. package/dist/themes/tomorrow-night-bright.json +84 -0
  187. package/dist/themes/tomorrow-night-burns.json +84 -0
  188. package/dist/themes/tomorrow-night.json +84 -0
  189. package/dist/themes/twilight.json +84 -0
  190. package/dist/themes/vague.json +84 -0
  191. package/dist/themes/vesper.json +84 -0
  192. package/dist/themes/xcode-dark-hc.json +84 -0
  193. package/docs/custom-provider.md +0 -1
  194. package/package.json +3 -3
  195. package/dist/extensions/auto-model/auto-model.test.ts +0 -18
  196. package/dist/extensions/auto-model/classifier.ts +0 -23
  197. package/dist/extensions/auto-model/config.ts +0 -51
  198. package/dist/extensions/auto-model/index.ts +0 -22
  199. package/dist/extensions/auto-model/lifecycle.test.ts +0 -24
  200. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +0 -27
  201. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +0 -687
  202. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +0 -16
  203. package/dist/extensions/pi-powerline-footer/README.md +0 -513
  204. package/dist/extensions/pi-powerline-footer/banner.png +0 -0
  205. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +0 -586
  206. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +0 -583
  207. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +0 -204
  208. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +0 -286
  209. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +0 -108
  210. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +0 -60
  211. package/dist/extensions/pi-powerline-footer/cd-command.ts +0 -190
  212. package/dist/extensions/pi-powerline-footer/colors.ts +0 -70
  213. package/dist/extensions/pi-powerline-footer/context-usage.ts +0 -156
  214. package/dist/extensions/pi-powerline-footer/currency-rates.ts +0 -142
  215. package/dist/extensions/pi-powerline-footer/editor-composition.ts +0 -32
  216. package/dist/extensions/pi-powerline-footer/editor-performance.ts +0 -99
  217. package/dist/extensions/pi-powerline-footer/git-status.ts +0 -329
  218. package/dist/extensions/pi-powerline-footer/guide.ts +0 -220
  219. package/dist/extensions/pi-powerline-footer/icons.ts +0 -190
  220. package/dist/extensions/pi-powerline-footer/index.ts +0 -3428
  221. package/dist/extensions/pi-powerline-footer/lifecycle.ts +0 -10
  222. package/dist/extensions/pi-powerline-footer/package.json +0 -49
  223. package/dist/extensions/pi-powerline-footer/paths.ts +0 -22
  224. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -446
  225. package/dist/extensions/pi-powerline-footer/presets.ts +0 -102
  226. package/dist/extensions/pi-powerline-footer/queue/store.ts +0 -313
  227. package/dist/extensions/pi-powerline-footer/queue/types.ts +0 -53
  228. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +0 -29
  229. package/dist/extensions/pi-powerline-footer/segments.ts +0 -570
  230. package/dist/extensions/pi-powerline-footer/separators.ts +0 -57
  231. package/dist/extensions/pi-powerline-footer/session-usage.ts +0 -44
  232. package/dist/extensions/pi-powerline-footer/shortcuts.ts +0 -61
  233. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +0 -1905
  234. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +0 -144
  235. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +0 -156
  236. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +0 -344
  237. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +0 -48
  238. package/dist/extensions/pi-powerline-footer/tests/editor-performance.test.ts +0 -43
  239. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +0 -136
  240. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +0 -39
  241. package/dist/extensions/pi-powerline-footer/tests/git-optional-locks.test.ts +0 -64
  242. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +0 -92
  243. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +0 -129
  244. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -116
  245. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +0 -79
  246. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +0 -63
  247. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +0 -65
  248. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +0 -14
  249. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +0 -173
  250. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +0 -47
  251. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +0 -37
  252. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +0 -76
  253. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +0 -249
  254. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +0 -39
  255. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +0 -237
  256. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +0 -199
  257. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +0 -317
  258. package/dist/extensions/pi-powerline-footer/theme.example.json +0 -24
  259. package/dist/extensions/pi-powerline-footer/theme.json +0 -12
  260. package/dist/extensions/pi-powerline-footer/theme.ts +0 -241
  261. package/dist/extensions/pi-powerline-footer/token-stats.ts +0 -304
  262. package/dist/extensions/pi-powerline-footer/tsconfig.json +0 -13
  263. package/dist/extensions/pi-powerline-footer/types.ts +0 -235
  264. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +0 -34
  265. package/dist/extensions/pi-powerline-footer/welcome.ts +0 -709
  266. package/dist/extensions/pi-powerline-footer/working-vibes.ts +0 -721
  267. package/dist/extensions/preview-tools-disabled.test.ts +0 -37
  268. package/dist/extensions/preview-tools-disabled.ts +0 -11
  269. 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
+ });