@mycadet/assistant 0.12.9 → 0.12.11

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 (658) hide show
  1. package/Dockerfile +0 -8
  2. package/eslint-rules/__tests__/cli-no-daemon-internals.test.ts +1 -1
  3. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  4. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  5. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  6. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  7. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  8. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
  9. package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
  10. package/openapi.yaml +62 -910
  11. package/package.json +3 -7
  12. package/scripts/build-test-fixtures.ts +1 -1
  13. package/scripts/generate-openapi.ts +22 -6
  14. package/scripts/postinstall.ts +4 -10
  15. package/scripts/run-tests.ts +0 -21
  16. package/src/__tests__/adaptive-thinking-repair.test.ts +19 -57
  17. package/src/__tests__/agent-loop.test.ts +0 -76
  18. package/src/__tests__/anthropic-provider.test.ts +6 -6
  19. package/src/__tests__/assert-not-live-db.ts +1 -1
  20. package/src/__tests__/assistant-id-boundary-guard.test.ts +3 -3
  21. package/src/__tests__/attachments-store-heic-normalize.test.ts +1 -1
  22. package/src/__tests__/attachments-store.test.ts +1 -1
  23. package/src/__tests__/audit-log-rotation.test.ts +1 -1
  24. package/src/__tests__/btw-routes.test.ts +1 -1
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +21 -27
  26. package/src/__tests__/call-conversation-messages.test.ts +0 -5
  27. package/src/__tests__/call-recovery.test.ts +2 -7
  28. package/src/__tests__/call-routes-http.test.ts +0 -12
  29. package/src/__tests__/call-store.test.ts +2 -7
  30. package/src/__tests__/catalog-files.test.ts +7 -5
  31. package/src/__tests__/checker.test.ts +1 -1
  32. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +4 -4
  33. package/src/__tests__/cloud-client-verify-credential.test.ts +0 -4
  34. package/src/__tests__/cloud-credential-catalog-cli.test.ts +14 -14
  35. package/src/__tests__/cloud-profile-guard.test.ts +87 -404
  36. package/src/__tests__/compaction-events.test.ts +2 -2
  37. package/src/__tests__/config-loader-backfill.test.ts +74 -163
  38. package/src/__tests__/config-loader-cloud-defaults.test.ts +11 -11
  39. package/src/__tests__/config-loader-corrupt.test.ts +10 -10
  40. package/src/__tests__/config-loader-quarantine-notice.test.ts +7 -7
  41. package/src/__tests__/config-loader-validation-reset-notice.test.ts +11 -11
  42. package/src/__tests__/config-schema.test.ts +26 -27
  43. package/src/__tests__/config-set-route.test.ts +2 -6
  44. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +28 -300
  45. package/src/__tests__/conversation-agent-loop-overflow.test.ts +39 -246
  46. package/src/__tests__/conversation-agent-loop.test.ts +2 -58
  47. package/src/__tests__/conversation-clear-safety.test.ts +0 -30
  48. package/src/__tests__/conversation-inference-profile-route.test.ts +1 -1
  49. package/src/__tests__/conversation-queue.test.ts +1 -1
  50. package/src/__tests__/conversation-runtime-assembly.test.ts +1 -1
  51. package/src/__tests__/conversation-skill-tools.test.ts +4 -444
  52. package/src/__tests__/conversation-slash-commands.test.ts +1 -1
  53. package/src/__tests__/conversation-store.test.ts +1 -84
  54. package/src/__tests__/conversation-surface-state.test.ts +0 -16
  55. package/src/__tests__/credential-health-service.test.ts +14 -1
  56. package/src/__tests__/credential-record-retire.test.ts +1 -1
  57. package/src/__tests__/credential-record-write-through.test.ts +1 -1
  58. package/src/__tests__/credential-security-invariants.test.ts +0 -145
  59. package/src/__tests__/custom-profile-ensure.test.ts +1 -1
  60. package/src/__tests__/default-profile-catalog-fallback.test.ts +5 -15
  61. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -2
  62. package/src/__tests__/dictation-profile-store.test.ts +6 -6
  63. package/src/__tests__/disk-usage.test.ts +28 -164
  64. package/src/__tests__/docker-signing-key-bootstrap.test.ts +1 -1
  65. package/src/__tests__/embedding-cloud-proxy-selection.test.ts +1 -1
  66. package/src/__tests__/emit-event-signal.test.ts +1 -1
  67. package/src/__tests__/gateway-client-cloud-outbound.test.ts +3 -3
  68. package/src/__tests__/gateway-only-enforcement.test.ts +1 -1
  69. package/src/__tests__/guardian-dispatch.test.ts +0 -13
  70. package/src/__tests__/helpers/seed-app.ts +1 -1
  71. package/src/__tests__/host-cu-routes-targeted.test.ts +1 -1
  72. package/src/__tests__/host-shell-tool.test.ts +1 -1
  73. package/src/__tests__/identity-routes.test.ts +1 -1
  74. package/src/__tests__/inference-profile-session-handler.test.ts +1 -1
  75. package/src/__tests__/inference-profile-session-ipc.test.ts +1 -1
  76. package/src/__tests__/injector-chain.test.ts +1 -1
  77. package/src/__tests__/injector-config-quarantine-notice.test.ts +1 -1
  78. package/src/__tests__/injector-config-validation-reset-notice.test.ts +1 -1
  79. package/src/__tests__/injector-pkb-v2-silenced.test.ts +1 -1
  80. package/src/__tests__/{platform.test.ts → layout.test.ts} +1 -1
  81. package/src/__tests__/list-all-apps.test.ts +1 -1
  82. package/src/__tests__/live-workspace-guard.test.ts +2 -2
  83. package/src/__tests__/llm-resolver-override-or-default.test.ts +5 -6
  84. package/src/__tests__/llm-resolver.test.ts +7 -7
  85. package/src/__tests__/llm-schema-fallback-profile.test.ts +9 -10
  86. package/src/__tests__/llm-usage-store.test.ts +0 -495
  87. package/src/__tests__/log-export-routes.test.ts +1 -1
  88. package/src/__tests__/mcp-config-secret-boundary.test.ts +12 -53
  89. package/src/__tests__/mcp-list-plugin-servers.test.ts +1 -1
  90. package/src/__tests__/memory-config-file-parity.test.ts +1 -1
  91. package/src/__tests__/memory-v2-static-injector.test.ts +1 -1
  92. package/src/__tests__/messages-read-boundary-guard.test.ts +0 -2
  93. package/src/__tests__/notification-guardian-path.test.ts +0 -7
  94. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  95. package/src/__tests__/openai-provider.test.ts +8 -8
  96. package/src/__tests__/path-policy.test.ts +1 -1
  97. package/src/__tests__/persona-resolver.test.ts +1 -1
  98. package/src/__tests__/plugin-app-serve-routes.test.ts +1 -1
  99. package/src/__tests__/plugin-effective-enabled-set.test.ts +1 -1
  100. package/src/__tests__/plugin-import-boundary-guard.test.ts +4 -6
  101. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -3
  102. package/src/__tests__/provider-cloud-proxy-integration.test.ts +1 -5
  103. package/src/__tests__/provider-connections-backfill.test.ts +10 -10
  104. package/src/__tests__/prune-old-conversations-job.test.ts +6 -31
  105. package/src/__tests__/resolve-app.test.ts +1 -1
  106. package/src/__tests__/run-due-schedules.test.ts +1 -1
  107. package/src/__tests__/scaffold-managed-skill-tool.test.ts +0 -53
  108. package/src/__tests__/schedule-routes.test.ts +1 -1
  109. package/src/__tests__/schedule-store.test.ts +1 -1
  110. package/src/__tests__/secret-routes-cloud-proxy.test.ts +1 -1
  111. package/src/__tests__/skill-load-plugin-scope.test.ts +1 -1
  112. package/src/__tests__/surface-completion-compaction-boundary.test.ts +1 -1
  113. package/src/__tests__/tool-approval-handler.test.ts +1 -1
  114. package/src/__tests__/tool-audit.test.ts +5 -235
  115. package/src/__tests__/tool-executor-lifecycle-events.test.ts +54 -114
  116. package/src/__tests__/tools-get-route.test.ts +1 -1
  117. package/src/__tests__/turn-outcome-read.test.ts +1 -1
  118. package/src/__tests__/twilio-routes.test.ts +0 -1
  119. package/src/__tests__/usage-attribution.test.ts +0 -31
  120. package/src/__tests__/verification-control-plane-policy.test.ts +3 -10
  121. package/src/__tests__/visible-app-context.test.ts +1 -1
  122. package/src/__tests__/voice-ingress-preflight.test.ts +1 -1
  123. package/src/__tests__/workspace-git-service.test.ts +1 -1
  124. package/src/__tests__/workspace-greetings.test.ts +1 -1
  125. package/src/acp/__tests__/helpers/acp-config-stub.ts +1 -1
  126. package/src/activation/progress-store.ts +1 -1
  127. package/src/activation/turn-hooks.ts +1 -1
  128. package/src/agent/loop.ts +0 -51
  129. package/src/apps/app-store.ts +1 -1
  130. package/src/avatar/__tests__/avatar-store.test.ts +2 -172
  131. package/src/avatar/accent-backfill.ts +1 -1
  132. package/src/avatar/avatar-manifest.ts +1 -1
  133. package/src/avatar/avatar-store.ts +10 -69
  134. package/src/avatar/ensure-raster.ts +1 -1
  135. package/src/avatar/identity-avatar.ts +1 -1
  136. package/src/avatar/sync-avatar.ts +1 -1
  137. package/src/avatar/traits-png-sync.ts +1 -1
  138. package/src/bundler/compiler-tools.ts +1 -1
  139. package/src/bundler/package-resolver.ts +1 -1
  140. package/src/calls/__tests__/call-controller.test.ts +25 -27
  141. package/src/calls/active-call-lease.ts +1 -1
  142. package/src/calls/call-controller.ts +3 -3
  143. package/src/calls/call-domain.ts +4 -8
  144. package/src/calls/call-store.ts +2 -76
  145. package/src/calls/resolve-call-tts-provider.ts +3 -3
  146. package/src/calls/twilio-config.ts +3 -3
  147. package/src/calls/twilio-rest.ts +2 -2
  148. package/src/calls/voice-ingress-preflight.ts +2 -2
  149. package/src/cli/bundled-modules.ts +0 -2
  150. package/src/cli/commands/__tests__/apps.test.ts +1 -1
  151. package/src/cli/commands/__tests__/plugins.test.ts +9 -66
  152. package/src/cli/commands/__tests__/ps.test.ts +67 -0
  153. package/src/cli/commands/auth.help.ts +2 -2
  154. package/src/cli/commands/avatar.ts +1 -1
  155. package/src/cli/commands/changelog.ts +1 -1
  156. package/src/cli/commands/cloud/__tests__/subscription.test.ts +2 -2
  157. package/src/cli/commands/cloud/index.help.ts +1 -1
  158. package/src/cli/commands/cloud/index.ts +2 -2
  159. package/src/cli/commands/db/refresh.ts +1 -1
  160. package/src/cli/commands/db/repair-step-conversation-backfill.ts +1 -1
  161. package/src/cli/commands/db/repair.ts +1 -1
  162. package/src/cli/commands/db/status.ts +1 -1
  163. package/src/cli/commands/inference-profiles.ts +1 -12
  164. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +1 -116
  165. package/src/cli/commands/memory/index.help.ts +1 -43
  166. package/src/cli/commands/memory/memory-v3.ts +0 -64
  167. package/src/cli/commands/plugins.ts +54 -205
  168. package/src/cli/commands/ps.ts +5 -2
  169. package/src/cli/commands/status.ts +1 -1
  170. package/src/cli/index.help.ts +0 -2
  171. package/src/cli/lib/__tests__/inspect-plugin.test.ts +36 -66
  172. package/src/cli/lib/__tests__/install-from-cloud.test.ts +1 -1
  173. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -117
  174. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +2 -185
  175. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +36 -66
  176. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +4 -6
  177. package/src/cli/lib/__tests__/plugin-details.test.ts +113 -96
  178. package/src/cli/lib/__tests__/search-plugins.test.ts +9 -16
  179. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +1 -1
  180. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +38 -15
  181. package/src/cli/lib/diff-plugin.ts +1 -1
  182. package/src/cli/lib/inspect-plugin.ts +13 -97
  183. package/src/cli/lib/install-from-cloud.ts +1 -1
  184. package/src/cli/lib/install-from-github.ts +75 -154
  185. package/src/cli/lib/install-plugin-dependencies.ts +1 -1
  186. package/src/cli/lib/list-installed-plugins.ts +1 -1
  187. package/src/cli/lib/open-browser.ts +1 -1
  188. package/src/cli/lib/plugin-catalog-cache.ts +8 -51
  189. package/src/cli/lib/plugin-catalog-resolve.ts +3 -22
  190. package/src/cli/lib/plugin-details.ts +5 -27
  191. package/src/cli/lib/plugin-marketplace.ts +18 -266
  192. package/src/cli/lib/search-plugins.ts +23 -43
  193. package/src/cli/lib/toggle-plugin.ts +1 -1
  194. package/src/cli/lib/uninstall-plugin.ts +1 -1
  195. package/src/cli/lib/upgrade-plugin.ts +3 -49
  196. package/src/cli/program.ts +1 -3
  197. package/src/cli.ts +1 -1
  198. package/src/cloud/client.ts +0 -6
  199. package/src/cloud/consent-cache.test.ts +22 -201
  200. package/src/cloud/consent-cache.ts +16 -111
  201. package/src/cloud/sync-identity.ts +1 -1
  202. package/src/config/__tests__/chat-settings.test.ts +1 -1
  203. package/src/config/__tests__/cloud-identity.test.ts +15 -2
  204. package/src/config/__tests__/default-plugin-skill-discovery.test.ts +1 -1
  205. package/src/config/__tests__/default-profile-catalog.test.ts +12 -51
  206. package/src/config/__tests__/deployment-context-defaults.test.ts +13 -13
  207. package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +4 -4
  208. package/src/config/__tests__/loader-last-known-good.test.ts +9 -9
  209. package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +7 -7
  210. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +1 -1
  211. package/src/config/__tests__/profile-materialization.test.ts +2 -4
  212. package/src/config/__tests__/sync-gated-profiles.test.ts +1 -106
  213. package/src/config/acp-agent-write.ts +1 -1
  214. package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +1 -1
  215. package/src/config/bundled-skills/settings/tools/open-system-settings.ts +1 -1
  216. package/src/config/call-site-defaults.ts +0 -6
  217. package/src/config/cloud-identity.ts +12 -5
  218. package/src/config/composer-preferences.ts +1 -1
  219. package/src/config/default-profile-catalog.ts +8 -75
  220. package/src/config/default-profile-names.ts +2 -25
  221. package/src/config/env-registry.ts +1 -14
  222. package/src/config/env.ts +8 -6
  223. package/src/config/llm-resolver.ts +1 -2
  224. package/src/config/loader.ts +13 -17
  225. package/src/config/profile-materialization.ts +1 -1
  226. package/src/config/schema.ts +1 -7
  227. package/src/config/schemas/call-site-catalog.ts +0 -7
  228. package/src/config/schemas/llm.ts +25 -32
  229. package/src/config/seed-inference-profiles.ts +16 -17
  230. package/src/config/skills.ts +2 -2
  231. package/src/config/sync-gated-profiles.ts +6 -24
  232. package/src/credential-health/credential-health-service.ts +10 -3
  233. package/src/daemon/approval-generators.ts +3 -3
  234. package/src/daemon/config-watcher.ts +2 -2
  235. package/src/daemon/conversation-agent-loop-handlers.ts +0 -18
  236. package/src/daemon/conversation-agent-loop.ts +6 -97
  237. package/src/daemon/conversation-process.ts +1 -1
  238. package/src/daemon/conversation-skill-tools.ts +4 -72
  239. package/src/daemon/conversation-store.ts +1 -1
  240. package/src/daemon/conversation-surface-state.ts +0 -33
  241. package/src/daemon/conversation-surfaces.ts +1 -86
  242. package/src/daemon/conversation-tool-setup.test.ts +1 -1
  243. package/src/daemon/conversation-tool-setup.ts +0 -23
  244. package/src/daemon/conversation.ts +3 -5
  245. package/src/daemon/daemon-control.ts +3 -3
  246. package/src/daemon/dictation-profile-store.ts +3 -3
  247. package/src/daemon/event-loop-watchdog.test.ts +2 -198
  248. package/src/daemon/event-loop-watchdog.ts +0 -185
  249. package/src/daemon/external-plugins-bootstrap.ts +1 -1
  250. package/src/daemon/first-greeting.ts +1 -1
  251. package/src/daemon/handlers/skills.ts +1 -1
  252. package/src/daemon/host-app-control-proxy.ts +1 -1
  253. package/src/daemon/host-cu-proxy.ts +2 -2
  254. package/src/daemon/identity-helpers.ts +1 -1
  255. package/src/daemon/lifecycle.ts +11 -15
  256. package/src/daemon/main.ts +1 -1
  257. package/src/daemon/now-scratchpad.ts +1 -1
  258. package/src/daemon/process-message.ts +4 -4
  259. package/src/daemon/profiler-run-store.ts +2 -2
  260. package/src/daemon/providers-setup.ts +2 -2
  261. package/src/daemon/shutdown-handlers.ts +0 -10
  262. package/src/daemon/sqlite-corruption-watchdog.test.ts +2 -142
  263. package/src/daemon/sqlite-corruption-watchdog.ts +17 -81
  264. package/src/daemon/tool-side-effects.ts +1 -1
  265. package/src/desktop/desktop-session-manager.ts +1 -1
  266. package/src/desktop/desktop-wallpaper-renderer.ts +1 -1
  267. package/src/heartbeat/heartbeat-service.ts +1 -1
  268. package/src/home/feed-writer.ts +1 -1
  269. package/src/home/home-greeting-cache.ts +1 -1
  270. package/src/home/relationship-state-writer.ts +2 -2
  271. package/src/hooks/__tests__/hook-live-reload.test.ts +1 -1
  272. package/src/hooks/hook-loader.ts +2 -2
  273. package/src/inbound/__tests__/oauth-callback-url.test.ts +2 -2
  274. package/src/inbound/oauth-callback-url.ts +3 -3
  275. package/src/ipc/__tests__/attachment-ipc.test.ts +1 -1
  276. package/src/ipc/socket-path.ts +1 -1
  277. package/src/live-voice/__tests__/live-voice-archive.test.ts +1 -1
  278. package/src/live-voice/__tests__/live-voice-vad.test.ts +1 -1
  279. package/src/live-voice/live-voice-session.ts +3 -66
  280. package/src/mcp/__tests__/effective-config.test.ts +1 -1
  281. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +2 -2
  282. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -3
  283. package/src/mcp/__tests__/reload-signal-emission.test.ts +1 -1
  284. package/src/mcp/__tests__/reload-signal.test.ts +1 -1
  285. package/src/mcp/__tests__/workspace-mcp-config.test.ts +19 -21
  286. package/src/mcp/reload-signal.ts +1 -1
  287. package/src/mcp/workspace-mcp-config.ts +1 -23
  288. package/src/messaging/draft-store.ts +1 -1
  289. package/src/monitoring/__tests__/active-conversations.test.ts +1 -1
  290. package/src/monitoring/__tests__/page-cache.test.ts +1 -1
  291. package/src/monitoring/__tests__/stall-capture.test.ts +1 -1
  292. package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +1 -1
  293. package/src/monitoring/active-conversations.ts +1 -1
  294. package/src/monitoring/control.ts +1 -1
  295. package/src/monitoring/daemon-boot-time.ts +1 -1
  296. package/src/monitoring/daemon-heartbeat.ts +1 -1
  297. package/src/monitoring/page-cache.ts +1 -1
  298. package/src/monitoring/plugin-auto-update.ts +1 -7
  299. package/src/monitoring/plugin-source-watch.ts +1 -1
  300. package/src/monitoring/recovery/db.ts +1 -1
  301. package/src/monitoring/recovery/inflight-content.ts +1 -1
  302. package/src/monitoring/resource-sampler.ts +3 -14
  303. package/src/monitoring/stall-capture.ts +1 -1
  304. package/src/monitoring/worker.ts +9 -76
  305. package/src/monitoring/workspace-git-heartbeat.ts +1 -1
  306. package/src/oauth/connect-orchestrator.ts +4 -4
  307. package/src/permissions/checker.ts +1 -1
  308. package/src/permissions/workspace-policy.ts +1 -1
  309. package/src/persistence/__tests__/main-db-readonly-probe.ts +27 -52
  310. package/src/persistence/__tests__/message-content-file.test.ts +1 -1
  311. package/src/persistence/__tests__/slow-sync-log.test.ts +0 -2
  312. package/src/persistence/attachments-store.ts +1 -1
  313. package/src/persistence/conversation-crud.ts +1 -38
  314. package/src/persistence/conversation-directories.ts +1 -1
  315. package/src/persistence/conversation-types.ts +0 -8
  316. package/src/persistence/db-async-query.ts +1 -1
  317. package/src/persistence/db-connection.ts +11 -1
  318. package/src/persistence/db-init.ts +1 -1
  319. package/src/persistence/db-maintenance.ts +1 -1
  320. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +1 -1
  321. package/src/persistence/embeddings/embedding-local.ts +4 -4
  322. package/src/persistence/embeddings/embedding-runtime-manager.ts +1 -1
  323. package/src/persistence/embeddings/qdrant-client.ts +1 -1
  324. package/src/persistence/embeddings/qdrant-manager.ts +1 -1
  325. package/src/persistence/job-handlers/__tests__/message-lexical-backfill.test.ts +1 -1
  326. package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +1 -1
  327. package/src/persistence/job-handlers/cleanup.ts +8 -17
  328. package/src/persistence/llm-usage-store.ts +2 -229
  329. package/src/persistence/message-content-file.ts +1 -1
  330. package/src/persistence/migrations/209-strip-thinking-from-consolidated.ts +1 -1
  331. package/src/persistence/migrations/222-strip-placeholder-sentinels-from-messages.ts +1 -1
  332. package/src/persistence/migrations/296-rewrite-balanced-economy-profile-pins.test.ts +1 -1
  333. package/src/persistence/migrations/306-rewrite-frontier-profile-pins.test.ts +1 -1
  334. package/src/persistence/migrations/329-move-onboarding-events-to-telemetry-db.test.ts +13 -0
  335. package/src/persistence/migrations/330-move-auth-fallback-events-to-telemetry-db.test.ts +13 -0
  336. package/src/persistence/migrations/331-move-lifecycle-events-to-telemetry-db.test.ts +13 -0
  337. package/src/persistence/migrations/332-move-skill-loaded-events-to-telemetry-db.test.ts +13 -0
  338. package/src/persistence/migrations/333-create-telemetry-events-table.test.ts +7 -4
  339. package/src/persistence/migrations/334-backfill-telemetry-events-outbox.test.ts +13 -0
  340. package/src/persistence/migrations/387-drop-telemetry-tables.ts +51 -0
  341. package/src/persistence/migrations/__tests__/327-create-flush-checkpoints.test.ts +9 -1
  342. package/src/persistence/migrations/__tests__/387-drop-telemetry-tables.test.ts +79 -0
  343. package/src/persistence/migrations/helpers/relocation.ts +1 -1
  344. package/src/persistence/migrations/run-migrations.ts +1 -1
  345. package/src/persistence/migrations/validate-migration-state.ts +1 -1
  346. package/src/persistence/raw-query.ts +1 -29
  347. package/src/persistence/slow-sync-log.ts +0 -23
  348. package/src/persistence/steps.ts +2 -0
  349. package/src/{telemetry → persistence}/tool-audit.ts +6 -121
  350. package/src/plugin-api/index.ts +1 -1
  351. package/src/plugin-api/plugin-name-env.ts +1 -1
  352. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +1 -1
  353. package/src/plugins/collect-source-versions.ts +1 -1
  354. package/src/plugins/defaults/channel/package.json +1 -1
  355. package/src/plugins/defaults/cloud-hosted/package.json +1 -1
  356. package/src/plugins/defaults/compaction/package.json +1 -1
  357. package/src/plugins/defaults/documents/package.json +1 -1
  358. package/src/plugins/defaults/empty-response/package.json +1 -1
  359. package/src/plugins/defaults/exploration-drift/package.json +1 -1
  360. package/src/plugins/defaults/image-fallback/package.json +1 -1
  361. package/src/plugins/defaults/image-recovery/package.json +1 -1
  362. package/src/plugins/defaults/max-tokens-continue/package.json +1 -1
  363. package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +1 -1
  364. package/src/plugins/defaults/memory/__tests__/db-maintenance.test.ts +1 -1
  365. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -82
  366. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +0 -41
  367. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -2
  368. package/src/plugins/defaults/memory/memory-retrospective-job.ts +3 -52
  369. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +0 -18
  370. package/src/plugins/defaults/memory/package.json +1 -1
  371. package/src/plugins/defaults/memory/paths.ts +4 -4
  372. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +1 -1
  373. package/src/plugins/defaults/memory/src/memory-v2-routes.ts +5 -5
  374. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +0 -207
  375. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
  376. package/src/plugins/defaults/memory/startup.ts +1 -22
  377. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +2 -253
  378. package/src/plugins/defaults/memory/v3/orchestrate.ts +5 -129
  379. package/src/plugins/defaults/memory/worker-control.ts +1 -1
  380. package/src/plugins/defaults/memory/worker.ts +2 -2
  381. package/src/plugins/defaults/session/package.json +1 -1
  382. package/src/plugins/defaults/surface-completion-nudge/package.json +1 -1
  383. package/src/plugins/defaults/task-progress/package.json +1 -1
  384. package/src/plugins/defaults/task-progress-nudge/package.json +1 -1
  385. package/src/plugins/defaults/title-generate/package.json +1 -1
  386. package/src/plugins/defaults/tool-error/package.json +1 -1
  387. package/src/plugins/defaults/tool-result-truncate/package.json +1 -1
  388. package/src/plugins/defaults/turn-context/package.json +1 -1
  389. package/src/plugins/defaults/workspace/injectors.ts +1 -1
  390. package/src/plugins/defaults/workspace/package.json +1 -1
  391. package/src/plugins/disabled-state.ts +1 -1
  392. package/src/plugins/ensure-plugin-api-shim.ts +1 -1
  393. package/src/plugins/ensure-shared-dep-links.ts +1 -1
  394. package/src/plugins/installed-plugin-dirs.ts +1 -1
  395. package/src/plugins/mcp-servers.ts +7 -9
  396. package/src/plugins/mtime-cache.ts +2 -2
  397. package/src/plugins/source-versions.ts +1 -1
  398. package/src/prompts/bootstrap-cleanup.ts +1 -1
  399. package/src/prompts/persona-resolver.ts +1 -1
  400. package/src/prompts/sections.ts +2 -2
  401. package/src/prompts/system-prompt.ts +10 -42
  402. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +0 -37
  403. package/src/providers/__tests__/cadet-connection-routing.test.ts +2 -2
  404. package/src/providers/cadet/client.ts +1 -1
  405. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -1
  406. package/src/providers/inference/adapter-factory.ts +1 -1
  407. package/src/providers/inference/backfill.ts +3 -10
  408. package/src/providers/inference/profile-probe.ts +1 -1
  409. package/src/providers/retry.ts +0 -2
  410. package/src/providers/types.ts +0 -7
  411. package/src/providers/usage-tracking.ts +1 -1
  412. package/src/routes/__tests__/route-host.test.ts +1 -1
  413. package/src/routes/control.ts +1 -1
  414. package/src/routes/route-host-client.ts +1 -1
  415. package/src/routes/worker.ts +2 -2
  416. package/src/runtime/agent-wake.ts +1 -1
  417. package/src/runtime/assistant-stream-state.ts +1 -1
  418. package/src/runtime/auth/token-service.ts +1 -1
  419. package/src/runtime/background-job-runner.ts +1 -1
  420. package/src/runtime/middleware/twilio-validation.ts +2 -2
  421. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +1 -1
  422. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +1 -1
  423. package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +3 -33
  424. package/src/runtime/routes/__tests__/cloud-credits-routes.test.ts +5 -4
  425. package/src/runtime/routes/__tests__/cloud-invoice-routes.test.ts +4 -3
  426. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +72 -298
  427. package/src/runtime/routes/__tests__/default-plugin-routes.test.ts +1 -1
  428. package/src/runtime/routes/__tests__/deprecated-route-context.test.ts +15 -62
  429. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +105 -12
  430. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +1 -1
  431. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -1
  432. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -211
  433. package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +1 -1
  434. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +1 -1
  435. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +1 -1
  436. package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +1 -1
  437. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +1 -1
  438. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +1 -1
  439. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +1 -1
  440. package/src/runtime/routes/__tests__/user-routes-request.test.ts +1 -1
  441. package/src/runtime/routes/app-management-routes.ts +1 -1
  442. package/src/runtime/routes/attachment-routes.ts +1 -1
  443. package/src/runtime/routes/audit-routes.ts +1 -1
  444. package/src/runtime/routes/avatar-routes.ts +3 -10
  445. package/src/runtime/routes/cloud-routes.ts +3 -3
  446. package/src/runtime/routes/content-source-routes.ts +1 -1
  447. package/src/runtime/routes/conversation-query-routes.ts +49 -706
  448. package/src/runtime/routes/conversation-routes.ts +1 -38
  449. package/src/runtime/routes/debug-routes.ts +1 -1
  450. package/src/runtime/routes/deprecated-route-context.ts +5 -61
  451. package/src/runtime/routes/heartbeat-routes.ts +1 -1
  452. package/src/runtime/routes/identity-routes.ts +1 -1
  453. package/src/runtime/routes/index.ts +0 -6
  454. package/src/runtime/routes/inference-profile-session-handler.ts +3 -3
  455. package/src/runtime/routes/inference-profiles-routes.ts +48 -21
  456. package/src/runtime/routes/inference-provider-connection-routes.ts +0 -1
  457. package/src/runtime/routes/llm-call-sites-routes.ts +3 -3
  458. package/src/runtime/routes/log-export/__tests__/installed-inventory.test.ts +1 -1
  459. package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +1 -1
  460. package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +1 -1
  461. package/src/runtime/routes/log-export/workspace-allowlist.ts +1 -1
  462. package/src/runtime/routes/log-export-routes.ts +17 -15
  463. package/src/runtime/routes/migration-rollback-routes.ts +1 -1
  464. package/src/runtime/routes/migration-routes.ts +2 -3
  465. package/src/runtime/routes/monitoring-routes.ts +2 -2
  466. package/src/runtime/routes/oauth-providers.ts +5 -5
  467. package/src/runtime/routes/plugins-routes.ts +38 -163
  468. package/src/runtime/routes/profiler-routes.ts +1 -1
  469. package/src/runtime/routes/route-host-worker-routes.ts +1 -1
  470. package/src/runtime/routes/sanity-routes.ts +1 -1
  471. package/src/runtime/routes/schedule-routes.ts +1 -1
  472. package/src/runtime/routes/schedule-worker-routes.ts +1 -1
  473. package/src/runtime/routes/settings-routes.ts +1 -1
  474. package/src/runtime/routes/sounds-config-routes.ts +1 -1
  475. package/src/runtime/routes/surface-conversation-resolver.ts +1 -7
  476. package/src/runtime/routes/user-route-dispatcher.ts +1 -3
  477. package/src/runtime/routes/user-route-import.ts +1 -1
  478. package/src/runtime/routes/user-route-resolution.ts +1 -1
  479. package/src/runtime/routes/user-routes-cli.ts +1 -1
  480. package/src/runtime/routes/workspace-commit-routes.ts +1 -1
  481. package/src/runtime/routes/workspace-greetings.ts +1 -1
  482. package/src/runtime/routes/workspace-routes.ts +1 -1
  483. package/src/runtime/routes/workspace-utils.ts +1 -1
  484. package/src/runtime/sync/resource-sync-events.ts +1 -1
  485. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +1 -1
  486. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -1
  487. package/src/schedule/plugin-schedule-availability.ts +1 -1
  488. package/src/schedule/plugin-schedule-declarations.ts +1 -1
  489. package/src/schedule/run-script.ts +1 -1
  490. package/src/schedule/scheduler.ts +1 -11
  491. package/src/schedule/worker-control.ts +1 -1
  492. package/src/schedule/worker.ts +3 -3
  493. package/src/security/oauth2.ts +2 -2
  494. package/src/security/secret-allowlist.ts +1 -1
  495. package/src/signals/cancel.ts +1 -1
  496. package/src/signals/conversation-undo.ts +1 -1
  497. package/src/signals/emit-event.ts +1 -1
  498. package/src/signals/event-stream.ts +1 -1
  499. package/src/signals/user-message.ts +1 -1
  500. package/src/skills/catalog-install.ts +2 -1
  501. package/src/skills/clawhub.ts +1 -1
  502. package/src/skills/cloud-store.ts +1 -1
  503. package/src/skills/path-classifier.ts +1 -1
  504. package/src/skills/skill-history.ts +1 -1
  505. package/src/skills/skillssh-registry.ts +1 -1
  506. package/src/skills/uninstall.ts +1 -1
  507. package/src/skills/workspace-skill-md-signature.ts +1 -1
  508. package/src/subagent/manager.ts +1 -1
  509. package/src/theme/workspace-theme.ts +1 -1
  510. package/src/tools/browser/auth-cache.ts +1 -1
  511. package/src/tools/browser/browser-manager.ts +1 -1
  512. package/src/tools/browser/runtime-check.ts +1 -1
  513. package/src/tools/executor.ts +4 -4
  514. package/src/tools/filesystem/write.ts +1 -1
  515. package/src/tools/permission-checker.ts +2 -51
  516. package/src/tools/run-standalone.ts +1 -1
  517. package/src/tools/skills/format-input-schema.ts +1 -1
  518. package/src/tools/skills/load.ts +1 -1
  519. package/src/tools/skills/scaffold-cloud.ts +0 -32
  520. package/src/tools/terminal/safe-env.ts +1 -2
  521. package/src/tools/terminal/sanitized-bash.ts +1 -1
  522. package/src/tools/terminal/shell.ts +1 -1
  523. package/src/tools/tool-approval-handler.ts +4 -1
  524. package/src/tools/workflows/manage-workflows.ts +2 -2
  525. package/src/tools/workflows/run-workflow.test.ts +1 -1
  526. package/src/tools/workspace-tools/loader.ts +1 -1
  527. package/src/tts/synthesis-stream.ts +2 -2
  528. package/src/usage/attribution.ts +1 -11
  529. package/src/util/browser.ts +1 -1
  530. package/src/util/bun-runtime.ts +2 -1
  531. package/src/util/device-id.ts +1 -1
  532. package/src/util/disk-usage.ts +4 -139
  533. package/src/util/layout.ts +3 -3
  534. package/src/util/logger.ts +1 -1
  535. package/src/util/logs-db-path.ts +1 -1
  536. package/src/util/memory-db-path.ts +1 -1
  537. package/src/util/{platform.ts → os.ts} +5 -30
  538. package/src/util/process-tree.ts +19 -0
  539. package/src/util/sqlite3-runtime.ts +1 -1
  540. package/src/util/telemetry-db-path.ts +1 -1
  541. package/src/watcher/__tests__/engine.test.ts +0 -23
  542. package/src/watcher/engine.ts +0 -7
  543. package/src/windows-compiled-cli.ts +0 -4
  544. package/src/windows-compiled-worker-entry.ts +0 -6
  545. package/src/workflows/leaf-runner.test.ts +1 -1
  546. package/src/workflows/leaf-runner.ts +1 -1
  547. package/src/workflows/library.ts +1 -1
  548. package/src/workspace/adaptive-thinking-repair.ts +5 -24
  549. package/src/workspace/byok-default-profile-ensure.ts +24 -26
  550. package/src/workspace/custom-profile-ensure.ts +2 -2
  551. package/src/workspace/git-service.ts +1 -1
  552. package/src/workspace/hatched-date.ts +1 -1
  553. package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +2 -2
  554. package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +1 -1
  555. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +1 -1
  556. package/src/workspace/migrations/077-seed-memory-router-callsite.ts +1 -1
  557. package/src/workspace/migrations/registry.ts +0 -4
  558. package/src/workspace/turn-commit.ts +1 -1
  559. package/docs/activation-funnel-telemetry.md +0 -324
  560. package/scripts/bundled-plugin-packages.ts +0 -154
  561. package/scripts/generate-bundled-plugin-packages.ts +0 -29
  562. package/src/__tests__/activation-early-marking.test.ts +0 -114
  563. package/src/__tests__/auth-fallback-events-store.test.ts +0 -99
  564. package/src/__tests__/config-get-vision-flag.test.ts +0 -139
  565. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +0 -400
  566. package/src/__tests__/internal-telemetry-routes.test.ts +0 -213
  567. package/src/__tests__/telemetry-routes.test.ts +0 -182
  568. package/src/__tests__/turn-events-store.test.ts +0 -376
  569. package/src/__tests__/ui-channel-variants.test.ts +0 -53
  570. package/src/avatar/__tests__/avatar-changed-telemetry.test.ts +0 -217
  571. package/src/avatar/avatar-changed-telemetry.ts +0 -128
  572. package/src/calls/__tests__/call-funnel-telemetry.test.ts +0 -204
  573. package/src/cli/commands/telemetry.help.ts +0 -27
  574. package/src/cli/commands/telemetry.ts +0 -83
  575. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +0 -251
  576. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +0 -113
  577. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +0 -384
  578. package/src/cli/lib/__tests__/plugin-pin-history.test.ts +0 -164
  579. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +0 -228
  580. package/src/cli/lib/bundled-marketplace.json +0 -1575
  581. package/src/cli/lib/bundled-plugin-packages.json +0 -827
  582. package/src/cli/lib/bundled-plugin-packages.ts +0 -87
  583. package/src/cli/lib/plugin-catalog-local.ts +0 -94
  584. package/src/cli/lib/plugin-pin-history.ts +0 -276
  585. package/src/cloud/feature-gate.ts +0 -8
  586. package/src/config/__tests__/cloud-rehydration.test.ts +0 -180
  587. package/src/config/cloud-rehydration.ts +0 -35
  588. package/src/daemon/__tests__/auto-profile-router.test.ts +0 -380
  589. package/src/daemon/auto-profile-router.ts +0 -428
  590. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +0 -391
  591. package/src/monitoring/__tests__/db-integrity-sample.test.ts +0 -113
  592. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +0 -374
  593. package/src/monitoring/db-integrity-check.ts +0 -163
  594. package/src/monitoring/db-integrity-sample.ts +0 -176
  595. package/src/monitoring/oom-kill-reporter.ts +0 -469
  596. package/src/onboarding/__tests__/onboarding-events-store.test.ts +0 -158
  597. package/src/onboarding/onboarding-events-store.ts +0 -296
  598. package/src/persistence/__tests__/raw-query-telemetry.test.ts +0 -76
  599. package/src/persistence/lifecycle-events-store.test.ts +0 -134
  600. package/src/persistence/lifecycle-events-store.ts +0 -110
  601. package/src/plugins/defaults/memory/__tests__/activation-session-store.test.ts +0 -76
  602. package/src/plugins/defaults/memory/activation-session-store.ts +0 -50
  603. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +0 -281
  604. package/src/runtime/routes/auto-profile-routes.ts +0 -83
  605. package/src/runtime/routes/internal-telemetry-routes.ts +0 -142
  606. package/src/runtime/routes/telemetry-routes.ts +0 -274
  607. package/src/security/auth-fallback-events-store.ts +0 -57
  608. package/src/telemetry/__tests__/activation-funnel.test.ts +0 -95
  609. package/src/telemetry/__tests__/config-setting-events-store.test.ts +0 -63
  610. package/src/telemetry/__tests__/config-setting-snapshot.test.ts +0 -143
  611. package/src/telemetry/__tests__/flush-checkpoints.test.ts +0 -56
  612. package/src/telemetry/__tests__/live-voice-funnel.test.ts +0 -174
  613. package/src/telemetry/__tests__/outbox-test-harness.ts +0 -85
  614. package/src/telemetry/__tests__/phone-call-funnel.test.ts +0 -72
  615. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +0 -282
  616. package/src/telemetry/__tests__/watchdog-events-store.test.ts +0 -71
  617. package/src/telemetry/activation-funnel.ts +0 -172
  618. package/src/telemetry/config-setting-events-store.ts +0 -40
  619. package/src/telemetry/config-setting-snapshot.ts +0 -170
  620. package/src/telemetry/flush-checkpoints.ts +0 -70
  621. package/src/telemetry/live-voice-funnel.ts +0 -185
  622. package/src/telemetry/memory-corpus-size.test.ts +0 -120
  623. package/src/telemetry/memory-corpus-size.ts +0 -184
  624. package/src/telemetry/memory-tier-reporter.test.ts +0 -195
  625. package/src/telemetry/memory-tier-reporter.ts +0 -130
  626. package/src/telemetry/phone-call-funnel.ts +0 -144
  627. package/src/telemetry/skill-loaded-events-store.test.ts +0 -93
  628. package/src/telemetry/skill-loaded-events-store.ts +0 -39
  629. package/src/telemetry/telemetry-event-sources.test.ts +0 -223
  630. package/src/telemetry/telemetry-event-sources.ts +0 -597
  631. package/src/telemetry/telemetry-events-outbox.test.ts +0 -375
  632. package/src/telemetry/telemetry-events-outbox.ts +0 -237
  633. package/src/telemetry/telemetry-main-db.ts +0 -48
  634. package/src/telemetry/telemetry-wire-source.json +0 -4
  635. package/src/telemetry/telemetry-wire-validation.test.ts +0 -157
  636. package/src/telemetry/telemetry-wire-validation.ts +0 -162
  637. package/src/telemetry/telemetry-wire.generated.ts +0 -483
  638. package/src/telemetry/tool-executed-events-store.test.ts +0 -210
  639. package/src/telemetry/tool-executed-events-store.ts +0 -102
  640. package/src/telemetry/trace-collection-policy.test.ts +0 -28
  641. package/src/telemetry/trace-collection-policy.ts +0 -30
  642. package/src/telemetry/turn-events-store.ts +0 -216
  643. package/src/telemetry/turn-trace-store.test.ts +0 -884
  644. package/src/telemetry/turn-trace-store.ts +0 -399
  645. package/src/telemetry/types.test.ts +0 -38
  646. package/src/telemetry/types.ts +0 -780
  647. package/src/telemetry/usage-telemetry-reporter.test.ts +0 -3445
  648. package/src/telemetry/usage-telemetry-reporter.ts +0 -566
  649. package/src/telemetry/watchdog-direct-emit.test.ts +0 -112
  650. package/src/telemetry/watchdog-direct-emit.ts +0 -104
  651. package/src/telemetry/watchdog-events-store.ts +0 -31
  652. package/src/tools/ui-surface/channel-variants.ts +0 -52
  653. package/src/watcher/__tests__/telemetry.test.ts +0 -178
  654. package/src/watcher/telemetry.ts +0 -93
  655. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +0 -165
  656. package/src/workspace/migrations/104-recheck-adaptive-thinking-model-implied-anthropic.ts +0 -151
  657. /package/src/{telemetry → persistence}/tool-usage-store.ts +0 -0
  658. /package/src/{telemetry → persistence}/turn-outcome.ts +0 -0
@@ -1,3445 +0,0 @@
1
- /**
2
- * Tests for UsageTelemetryReporter.
3
- *
4
- * Covers the authenticated send path (and the skip behavior when credentials
5
- * are absent or the cloud is disabled), watermark advancement, error
6
- * handling, batch recursion, device ID resolution, and payload shape.
7
- */
8
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
9
-
10
- import type { ConsentState } from "../cloud/consent-cache.js";
11
-
12
- // ---------------------------------------------------------------------------
13
- // Module mocks (must precede production imports)
14
- // ---------------------------------------------------------------------------
15
-
16
- // Watermark storage fake, injected via the reporter's constructor (see
17
- // `makeReporter` below) — the real store lives on the telemetry DB and has
18
- // its own DB-backed test, so it is deliberately NOT module-mocked here.
19
- const mockGetFlushCheckpoint = mock<(key: string) => string | null>(() => null);
20
- const mockSetFlushCheckpoint = mock<(key: string, value: string) => void>(
21
- () => {},
22
- );
23
- const mockIsFlushCheckpointStoreAvailable = mock<() => boolean>(() => true);
24
-
25
- const fakeFlushCheckpointStore = {
26
- isAvailable: () => mockIsFlushCheckpointStoreAvailable(),
27
- get: (key: string) => mockGetFlushCheckpoint(key),
28
- set: (key: string, value: string) => mockSetFlushCheckpoint(key, value),
29
- };
30
-
31
- const mockQueryUnreportedUsageEvents = mock(
32
- () =>
33
- [] as ReturnType<
34
- typeof import("../persistence/llm-usage-store.js").queryUnreportedUsageEvents
35
- >,
36
- );
37
-
38
- mock.module("../persistence/llm-usage-store.js", () => ({
39
- queryUnreportedUsageEvents: mockQueryUnreportedUsageEvents,
40
- }));
41
-
42
- const mockQueryUnreportedTurnEvents = mock(
43
- () =>
44
- [] as {
45
- id: string;
46
- createdAt: number;
47
- conversationId: string;
48
- conversationType: string;
49
- turnIndex: number;
50
- interfaceId: string | null;
51
- channelId: string | null;
52
- clientMetadata: string | null;
53
- outcome?: string | null;
54
- batchedInto?: string | null;
55
- failureCode?: string | null;
56
- }[],
57
- );
58
-
59
- mock.module("./turn-events-store.js", () => ({
60
- queryUnreportedTurnEvents: mockQueryUnreportedTurnEvents,
61
- }));
62
-
63
- let mockCloudClient: Record<string, unknown> | null = null;
64
-
65
- mock.module("../cloud/client.js", () => ({
66
- CadetCloudClient: {
67
- create: async () => mockCloudClient,
68
- },
69
- }));
70
-
71
- const mockGetCloudBaseUrl = mock(() => "https://cloud.mycadet.ai");
72
-
73
- const mockGetPlatformOrganizationId = mock(() => "");
74
- const mockGetPlatformUserId = mock(() => "");
75
-
76
- mock.module("../config/env.js", () => ({
77
- getCloudBaseUrl: mockGetCloudBaseUrl,
78
- getCloudOrganizationId: mockGetPlatformOrganizationId,
79
- getCloudUserId: mockGetPlatformUserId,
80
- // Re-export anything else the module might import transitively
81
- str: () => undefined,
82
- num: () => undefined,
83
- bool: () => false,
84
- }));
85
-
86
- const mockGetDeviceId = mock(() => "test-device-id");
87
-
88
- mock.module("../util/device-id.js", () => ({
89
- getDeviceId: mockGetDeviceId,
90
- }));
91
-
92
- mock.module("../version.js", () => ({
93
- APP_VERSION: "1.2.3-test",
94
- }));
95
-
96
- // Tri-state `share_analytics` consent (the reporter's flush gate). The
97
- // boolean accessor used by record-time gates derives from the same knob so a
98
- // test toggling consent flips both views consistently.
99
- const mockGetRawShareAnalytics = mock<() => ConsentState>(() => true);
100
- // Owner's `share_diagnostics` consent — one part of the trace-collection gate.
101
- const mockGetCachedShareDiagnostics = mock(() => false);
102
- // Owner's accepted diagnostics-consent version — the disclosing-version part of
103
- // the trace gate. Default to a far-future (unconditionally eligible) version so
104
- // the consent cases drive eligibility on that axis; the version-specific
105
- // cases override it with old/empty values.
106
- const mockGetCachedShareDiagnosticsVersion = mock(() => "2999-01-01");
107
-
108
- mock.module("../cloud/consent-cache.js", () => ({
109
- getRawShareAnalytics: mockGetRawShareAnalytics,
110
- getCachedShareDiagnostics: mockGetCachedShareDiagnostics,
111
- getCachedShareDiagnosticsVersion: mockGetCachedShareDiagnosticsVersion,
112
- }));
113
-
114
- interface MockTurnTrace {
115
- schema_version: 3;
116
- messages: {
117
- id: string;
118
- role: string;
119
- created_at: number;
120
- content: unknown;
121
- model: string | null;
122
- }[];
123
- tool_calls: unknown[];
124
- system_prompt: string | null;
125
- tool_definitions: {
126
- name: string;
127
- description: string;
128
- input_schema: Record<string, unknown>;
129
- }[];
130
- }
131
-
132
- // Returns a non-null bounded trace by default; individual tests override the
133
- // return value (including null, for the over-cap / assembly-failure path).
134
- function defaultBoundedTurnTrace(boundary: {
135
- conversationId: string;
136
- userMessageId: string;
137
- userMessageCreatedAt: number;
138
- }): MockTurnTrace | null {
139
- return {
140
- schema_version: 3,
141
- messages: [
142
- {
143
- id: boundary.userMessageId,
144
- role: "user",
145
- created_at: boundary.userMessageCreatedAt,
146
- content: [{ type: "text", text: "hello" }],
147
- model: null,
148
- },
149
- ],
150
- tool_calls: [],
151
- system_prompt: "You are a helpful assistant.",
152
- tool_definitions: [
153
- {
154
- name: "web_search",
155
- description: "Search the web",
156
- input_schema: {},
157
- },
158
- ],
159
- };
160
- }
161
-
162
- const mockAssembleBoundedTurnTrace = mock(defaultBoundedTurnTrace);
163
-
164
- // Turn-completeness gate. Default: every turn is settled (complete), so the
165
- // deferral barrier never fires unless a test opts a turn into "in-flight".
166
- const mockIsTurnSettled = mock(
167
- (_boundary: {
168
- conversationId: string;
169
- userMessageId: string;
170
- userMessageCreatedAt: number;
171
- }): boolean => true,
172
- );
173
-
174
- mock.module("./turn-trace-store.js", () => ({
175
- assembleBoundedTurnTrace: mockAssembleBoundedTurnTrace,
176
- isTurnSettled: mockIsTurnSettled,
177
- }));
178
-
179
- // The auth-fallback, tool-executed, and skill-loaded stores are
180
- // intentionally NOT mocked — they have their own DB-backed tests, and Bun's
181
- // `mock.module` is process-global, so mocking them here would leak into those
182
- // tests when files share an invocation. We seed the real DB instead so every
183
- // test stays order-independent.
184
-
185
- // ---------------------------------------------------------------------------
186
- // Production import (after mocks)
187
- // ---------------------------------------------------------------------------
188
-
189
- import {
190
- seedToolInvocation as seedToolInvocationRow,
191
- TOOL_INVOCATION_PII_SENTINEL as TOOL_PII_SENTINEL,
192
- type ToolInvocationSeedSpec,
193
- } from "../__tests__/test-support/tool-invocation-seed.js";
194
- import {
195
- recordActivationEvent,
196
- recordOnboardingEvent,
197
- } from "../onboarding/onboarding-events-store.js";
198
- import { getDb, getTelemetryDb } from "../persistence/db-connection.js";
199
- import { initializeDb } from "../persistence/db-init.js";
200
- import { recordLifecycleEvent } from "../persistence/lifecycle-events-store.js";
201
- import {
202
- conversations,
203
- telemetryEvents,
204
- toolInvocations,
205
- } from "../persistence/schema/index.js";
206
- import { recordAuthFallbackCounts } from "../security/auth-fallback-events-store.js";
207
- import type { UsageEvent } from "../usage/types.js";
208
- import {
209
- ACTIVATION_FUNNEL_VERSION,
210
- buildActivationDaemonEventId,
211
- } from "./activation-funnel.js";
212
- import { recordConfigSettingEvent } from "./config-setting-events-store.js";
213
- import { recordSkillLoadedEvent } from "./skill-loaded-events-store.js";
214
- import {
215
- ALL_TELEMETRY_EVENT_SOURCES,
216
- DAEMON_TELEMETRY_EVENT_SOURCES,
217
- MONITOR_TELEMETRY_EVENT_SOURCES,
218
- outboxSource,
219
- type TelemetryEventSource,
220
- } from "./telemetry-event-sources.js";
221
- import {
222
- insertTelemetryOutboxEvent,
223
- OUTBOX_MAX_ROW_AGE_MS,
224
- queryTelemetryOutboxBatch,
225
- } from "./telemetry-events-outbox.js";
226
- import type { OutboxTelemetryEventName, TelemetryEvent } from "./types.js";
227
- import {
228
- initToolExecutedWatermarkIfAbsent,
229
- UsageTelemetryReporter,
230
- } from "./usage-telemetry-reporter.js";
231
- import { recordWatchdogEvent } from "./watchdog-events-store.js";
232
-
233
- /**
234
- * Construct a reporter wired to the in-memory checkpoint fake. All tests go
235
- * through this so watermark reads/writes stay assertable via the mocks
236
- * without process-global module mocking of the real telemetry-DB store.
237
- */
238
- function makeReporter(): UsageTelemetryReporter {
239
- return new UsageTelemetryReporter(
240
- ALL_TELEMETRY_EVENT_SOURCES,
241
- fakeFlushCheckpointStore,
242
- );
243
- }
244
-
245
- await initializeDb();
246
-
247
- // ---------------------------------------------------------------------------
248
- // Helpers
249
- // ---------------------------------------------------------------------------
250
-
251
- let eventIdCounter = 0;
252
-
253
- // The reporter consumes `UnreportedUsageEvent` (UsageEvent + the
254
- // JOIN-computed fields `conversationType`, `turnIndex`,
255
- // `parentConversationId`, and `parentTurnIndex`). Build that shape
256
- // directly so the mock matches `queryUnreportedUsageEvents`' return type
257
- // exactly.
258
- type UnreportedUsageEventFixture = UsageEvent & {
259
- conversationType: string | null;
260
- turnIndex: number | null;
261
- parentConversationId: string | null;
262
- parentTurnIndex: number | null;
263
- subagentRole: string | null;
264
- subagentSpawnMode: string | null;
265
- };
266
-
267
- function makeUsageEvent(
268
- overrides: Partial<UnreportedUsageEventFixture> = {},
269
- ): UnreportedUsageEventFixture {
270
- eventIdCounter += 1;
271
- return {
272
- id: `evt-${eventIdCounter}`,
273
- createdAt: 1700000000000 + eventIdCounter * 1000,
274
- provider: "anthropic",
275
- model: "claude-sonnet-4-20250514",
276
- inputTokens: 100,
277
- outputTokens: 50,
278
- cacheCreationInputTokens: 10,
279
- cacheReadInputTokens: 5,
280
- rawUsage: null,
281
- actor: "main_agent",
282
- callSite: null,
283
- inferenceProfile: null,
284
- inferenceProfileSource: null,
285
- conversationId: "conv-1",
286
- runId: null,
287
- requestId: null,
288
- estimatedCostUsd: 0.001,
289
- pricingStatus: "priced",
290
- assistantVersion: "test-app-version",
291
- conversationType: "standard",
292
- turnIndex: 1,
293
- parentConversationId: null,
294
- parentTurnIndex: null,
295
- subagentRole: null,
296
- subagentSpawnMode: null,
297
- llmCallCount: 1,
298
- ...overrides,
299
- };
300
- }
301
-
302
- const TOOL_CONVERSATION_ID = "conv-reporter-tool-executed";
303
-
304
- function seedToolInvocation(
305
- spec: Omit<ToolInvocationSeedSpec, "conversationId">,
306
- ): void {
307
- seedToolInvocationRow(
308
- { db: getDb(), conversations, toolInvocations },
309
- { ...spec, conversationId: TOOL_CONVERSATION_ID },
310
- );
311
- }
312
-
313
- /**
314
- * Replace the default null-returning checkpoint mocks with a Map-backed
315
- * implementation so values persisted by the reporter (e.g. the
316
- * construction-time tool_executed watermark init) are visible to later
317
- * reads within the same test.
318
- */
319
- function useStatefulCheckpoints(
320
- seed: Record<string, string> = {},
321
- ): Map<string, string> {
322
- const checkpoints = new Map(Object.entries(seed));
323
- mockGetFlushCheckpoint.mockImplementation(
324
- (key) => checkpoints.get(key) ?? null,
325
- );
326
- mockSetFlushCheckpoint.mockImplementation((key, value) => {
327
- checkpoints.set(key, value);
328
- });
329
- return checkpoints;
330
- }
331
-
332
- const originalFetch = globalThis.fetch;
333
- let mockFetch: ReturnType<typeof mock>;
334
-
335
- // ---------------------------------------------------------------------------
336
- // Setup / Teardown
337
- // ---------------------------------------------------------------------------
338
-
339
- beforeEach(() => {
340
- eventIdCounter = 0;
341
- // Default consent ON so the happy-path send tests exercise the flush.
342
- mockGetRawShareAnalytics.mockReset();
343
- mockGetRawShareAnalytics.mockReturnValue(true);
344
- // Default `share_diagnostics` consent OFF — most tests don't expect a trace;
345
- // the trace-specific tests opt in explicitly.
346
- mockGetCachedShareDiagnostics.mockReset();
347
- mockGetCachedShareDiagnostics.mockReturnValue(false);
348
- // Default the accepted consent version eligible so trace tests drive the gate
349
- // via the share_diagnostics knob; version cases override it.
350
- mockGetCachedShareDiagnosticsVersion.mockReset();
351
- mockGetCachedShareDiagnosticsVersion.mockReturnValue("2999-01-01");
352
- mockAssembleBoundedTurnTrace.mockReset();
353
- mockAssembleBoundedTurnTrace.mockImplementation(defaultBoundedTurnTrace);
354
- mockIsTurnSettled.mockReset();
355
- mockIsTurnSettled.mockReturnValue(true);
356
- mockGetFlushCheckpoint.mockReset();
357
- mockSetFlushCheckpoint.mockReset();
358
- mockIsFlushCheckpointStoreAvailable.mockReset();
359
- mockIsFlushCheckpointStoreAvailable.mockReturnValue(true);
360
- mockQueryUnreportedUsageEvents.mockReset();
361
- mockQueryUnreportedTurnEvents.mockReset();
362
- mockQueryUnreportedTurnEvents.mockReturnValue([]);
363
- getDb().delete(toolInvocations).run();
364
- getTelemetryDb()!.delete(telemetryEvents).run();
365
- delete process.env.CADET_DISABLE_CLOUD;
366
- delete process.env.IS_CLOUD;
367
- mockGetCloudBaseUrl.mockReset();
368
- mockGetDeviceId.mockReset();
369
- mockGetDeviceId.mockReturnValue("test-device-id");
370
- mockGetPlatformOrganizationId.mockReset();
371
- mockGetPlatformOrganizationId.mockReturnValue("");
372
- mockGetPlatformUserId.mockReset();
373
- mockGetPlatformUserId.mockReturnValue("");
374
-
375
- // Defaults
376
- mockGetFlushCheckpoint.mockReturnValue(null);
377
- mockGetCloudBaseUrl.mockReturnValue("https://cloud.mycadet.ai");
378
-
379
- mockFetch = mock(() =>
380
- Promise.resolve(new Response('{"accepted":0}', { status: 200 })),
381
- );
382
- globalThis.fetch = mockFetch as unknown as typeof fetch;
383
-
384
- // Default to an authenticated client whose fetch delegates to the mockFetch
385
- // spy, so the existing payload/body assertions keep working. The reporter
386
- // sends authenticated-only; tests that exercise the no-credentials path
387
- // override this with `mockCloudClient = null`.
388
- mockCloudClient = {
389
- baseUrl: "https://test.mycadet.ai",
390
- assistantApiKey: "test-key",
391
- cloudAssistantId: "asst-123",
392
- fetch: (path: string, init?: RequestInit) => mockFetch(path, init),
393
- };
394
- });
395
-
396
- afterEach(() => {
397
- globalThis.fetch = originalFetch;
398
- delete process.env.CADET_DISABLE_CLOUD;
399
- delete process.env.IS_CLOUD;
400
- });
401
-
402
- // ---------------------------------------------------------------------------
403
- // Tests
404
- // ---------------------------------------------------------------------------
405
-
406
- describe("UsageTelemetryReporter", () => {
407
- test("authenticated flush uses client.fetch with platform path", async () => {
408
- const clientFetchMock = mock(async (_path: string, _init?: RequestInit) => {
409
- return new Response('{"accepted":2}', { status: 200 });
410
- });
411
- mockCloudClient = {
412
- baseUrl: "https://test.mycadet.ai",
413
- assistantApiKey: "test-key",
414
- cloudAssistantId: "asst-123",
415
- fetch: clientFetchMock,
416
- };
417
- const events = [makeUsageEvent(), makeUsageEvent()];
418
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
419
-
420
- const reporter = makeReporter();
421
- await reporter.flush();
422
-
423
- expect(clientFetchMock).toHaveBeenCalledTimes(1);
424
- const [path] = clientFetchMock.mock.calls[0] as [string, RequestInit];
425
- expect(path).toBe("/v1/telemetry/ingest/");
426
- // globalThis.fetch should NOT have been called directly
427
- expect(mockFetch).not.toHaveBeenCalled();
428
- });
429
-
430
- test("flush is skipped when no platform credentials are available", async () => {
431
- // Authenticated-only: with no client, nothing is sent and the watermark is
432
- // left intact so the backlog ships once credentials resolve.
433
- mockCloudClient = null;
434
-
435
- const events = [makeUsageEvent()];
436
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
437
-
438
- const reporter = makeReporter();
439
- await reporter.flush();
440
-
441
- expect(mockFetch).not.toHaveBeenCalled();
442
- const watermarkCalls = mockSetFlushCheckpoint.mock.calls.filter(
443
- (c) => c[0] === "telemetry:usage:last_reported_at",
444
- );
445
- expect(watermarkCalls.length).toBe(0);
446
- });
447
-
448
- test("flush is skipped when CADET_DISABLE_CLOUD is set in local mode", async () => {
449
- // The cloud-disabled toggle suppresses the send. Unlike the opt-out
450
- // branch, watermarks are NOT advanced, so the backlog ships once the flag
451
- // is cleared.
452
- process.env.CADET_DISABLE_CLOUD = "true";
453
-
454
- const events = [makeUsageEvent()];
455
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
456
-
457
- const reporter = makeReporter();
458
- // Construction initializes the absent tool_executed watermark; clear that
459
- // call so the assertion below covers only the flush.
460
- mockSetFlushCheckpoint.mockClear();
461
- await reporter.flush();
462
-
463
- expect(mockFetch).not.toHaveBeenCalled();
464
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
465
- });
466
-
467
- test("CADET_DISABLE_CLOUD is ignored when IS_CLOUD is set (managed mode)", async () => {
468
- // Cloud assistants always connect to the cloud; an inherited
469
- // CADET_DISABLE_CLOUD must not suppress telemetry for them (matches
470
- // areCloudFeaturesEnabled / CadetCloudClient.create()).
471
- process.env.IS_CLOUD = "true";
472
- process.env.CADET_DISABLE_CLOUD = "true";
473
-
474
- const events = [makeUsageEvent()];
475
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
476
-
477
- const reporter = makeReporter();
478
- await reporter.flush();
479
-
480
- expect(mockFetch).toHaveBeenCalledTimes(1);
481
- });
482
-
483
- test("watermark advances on successful upload", async () => {
484
- const events = [
485
- makeUsageEvent({ id: "evt-w1", createdAt: 1700000001000 }),
486
- makeUsageEvent({ id: "evt-w2", createdAt: 1700000002000 }),
487
- ];
488
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
489
- mockFetch.mockImplementation(() =>
490
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
491
- );
492
-
493
- const reporter = makeReporter();
494
- await reporter.flush();
495
-
496
- const watermarkCalls = mockSetFlushCheckpoint.mock.calls.filter(
497
- (c) => c[0] === "telemetry:usage:last_reported_at",
498
- );
499
- expect(watermarkCalls.length).toBeGreaterThanOrEqual(1);
500
- // The watermark should be set to the createdAt of the last event
501
- expect(watermarkCalls[watermarkCalls.length - 1][1]).toBe(
502
- String(1700000002000),
503
- );
504
-
505
- // The compound cursor ID should also be set to the last event's id
506
- const idCalls = mockSetFlushCheckpoint.mock.calls.filter(
507
- (c) => c[0] === "telemetry:usage:last_reported_id",
508
- );
509
- expect(idCalls.length).toBeGreaterThanOrEqual(1);
510
- expect(idCalls[idCalls.length - 1][1]).toBe("evt-w2");
511
- });
512
-
513
- test("watermark stays on failed upload", async () => {
514
- const events = [makeUsageEvent()];
515
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
516
- mockFetch.mockImplementation(() =>
517
- Promise.resolve(new Response("error", { status: 500 })),
518
- );
519
-
520
- const reporter = makeReporter();
521
- await reporter.flush();
522
-
523
- const watermarkCalls = mockSetFlushCheckpoint.mock.calls.filter(
524
- (c) => c[0] === "telemetry:usage:last_reported_at",
525
- );
526
- expect(watermarkCalls.length).toBe(0);
527
- });
528
-
529
- test("installation ID comes from per-device getDeviceId", async () => {
530
- const events = [makeUsageEvent()];
531
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
532
- mockFetch.mockImplementation(() =>
533
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
534
- );
535
-
536
- const reporter = makeReporter();
537
-
538
- // First flush — should use the per-device ID
539
- await reporter.flush();
540
- expect(mockFetch).toHaveBeenCalledTimes(1);
541
- const body1 = JSON.parse(
542
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
543
- );
544
- expect(body1.device_id).toBe("test-device-id");
545
-
546
- // Second flush — should use the same value
547
- mockQueryUnreportedUsageEvents.mockReturnValue([makeUsageEvent()]);
548
- await reporter.flush();
549
- expect(mockFetch).toHaveBeenCalledTimes(2);
550
- const body2 = JSON.parse(
551
- (mockFetch.mock.calls[1] as [string, RequestInit])[1].body as string,
552
- );
553
- expect(body2.device_id).toBe("test-device-id");
554
- });
555
-
556
- test("empty batch makes no HTTP call", async () => {
557
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
558
-
559
- const reporter = makeReporter();
560
- await reporter.flush();
561
-
562
- expect(mockFetch).not.toHaveBeenCalled();
563
- });
564
-
565
- test("batch recursion when full, capped at 10", async () => {
566
- // Always return exactly 500 events (BATCH_SIZE) to trigger recursion
567
- const fullBatch = Array.from({ length: 500 }, (_, i) =>
568
- makeUsageEvent({ id: `evt-batch-${i}`, createdAt: 1700000000000 + i }),
569
- );
570
- mockQueryUnreportedUsageEvents.mockReturnValue(fullBatch);
571
- mockFetch.mockImplementation(() =>
572
- Promise.resolve(new Response('{"accepted":500}', { status: 200 })),
573
- );
574
-
575
- const reporter = makeReporter();
576
- await reporter.flush();
577
-
578
- // MAX_CONSECUTIVE_BATCHES = 10
579
- expect(mockFetch).toHaveBeenCalledTimes(10);
580
- });
581
-
582
- test("flush summary reports sent/persisted/dropped from the ingest response", async () => {
583
- mockQueryUnreportedUsageEvents.mockReturnValue([
584
- makeUsageEvent(),
585
- makeUsageEvent(),
586
- makeUsageEvent(),
587
- ]);
588
- mockFetch.mockImplementation(() =>
589
- Promise.resolve(
590
- new Response(
591
- '{"accepted":3,"persisted":2,"dropped":{"analytics_opt_out":1}}',
592
- { status: 200 },
593
- ),
594
- ),
595
- );
596
-
597
- const reporter = makeReporter();
598
- const summary = await reporter.flush();
599
-
600
- expect(summary).toEqual({
601
- flushed: true,
602
- sent: 3,
603
- persisted: 2,
604
- dropped: 1,
605
- });
606
- });
607
-
608
- test("flush summary falls back to persisted=sent when the body omits persisted", async () => {
609
- // Older platform returns only `accepted` — assume everything landed.
610
- mockQueryUnreportedUsageEvents.mockReturnValue([
611
- makeUsageEvent(),
612
- makeUsageEvent(),
613
- ]);
614
- mockFetch.mockImplementation(() =>
615
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
616
- );
617
-
618
- const reporter = makeReporter();
619
- const summary = await reporter.flush();
620
-
621
- expect(summary).toEqual({
622
- flushed: true,
623
- sent: 2,
624
- persisted: 2,
625
- dropped: 0,
626
- });
627
- });
628
-
629
- test("flush summary accumulates counts across the batch recursion", async () => {
630
- const fullBatch = Array.from({ length: 500 }, (_, i) =>
631
- makeUsageEvent({ id: `evt-acc-${i}`, createdAt: 1700000000000 + i }),
632
- );
633
- // First collect fills a batch (recurse); second returns a short batch (stop).
634
- mockQueryUnreportedUsageEvents
635
- .mockReturnValueOnce(fullBatch)
636
- .mockReturnValue([makeUsageEvent({ id: "evt-acc-tail" })]);
637
- // First POST drops 10; second persists its single event.
638
- mockFetch
639
- .mockImplementationOnce(() =>
640
- Promise.resolve(
641
- new Response('{"accepted":500,"persisted":490}', { status: 200 }),
642
- ),
643
- )
644
- .mockImplementation(() =>
645
- Promise.resolve(new Response('{"persisted":1}', { status: 200 })),
646
- );
647
-
648
- const reporter = makeReporter();
649
- const summary = await reporter.flush();
650
-
651
- expect(mockFetch).toHaveBeenCalledTimes(2);
652
- expect(summary).toEqual({
653
- flushed: true,
654
- sent: 501,
655
- persisted: 491,
656
- dropped: 10,
657
- });
658
- });
659
-
660
- test("flush summary reports skip reasons without shipping", async () => {
661
- const reporter = makeReporter();
662
-
663
- // nothing-pending: no events across any source.
664
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
665
- expect(await reporter.flush()).toEqual({
666
- flushed: false,
667
- reason: "nothing-pending",
668
- });
669
-
670
- // opted-out: confirmed share_analytics opt-out.
671
- mockGetRawShareAnalytics.mockReturnValue(false);
672
- mockQueryUnreportedUsageEvents.mockReturnValue([makeUsageEvent()]);
673
- expect(await reporter.flush()).toEqual({
674
- flushed: false,
675
- reason: "opted-out",
676
- });
677
- mockGetRawShareAnalytics.mockReturnValue(true);
678
-
679
- // no-credentials: authenticated-only, no platform client resolved.
680
- mockCloudClient = null;
681
- expect(await reporter.flush()).toEqual({
682
- flushed: false,
683
- reason: "no-credentials",
684
- });
685
-
686
- expect(mockFetch).not.toHaveBeenCalled();
687
- });
688
-
689
- test("flush summary reports post-failed on a non-2xx response", async () => {
690
- mockQueryUnreportedUsageEvents.mockReturnValue([makeUsageEvent()]);
691
- mockFetch.mockImplementation(() =>
692
- Promise.resolve(new Response("error", { status: 500 })),
693
- );
694
-
695
- const reporter = makeReporter();
696
- expect(await reporter.flush()).toEqual({
697
- flushed: false,
698
- reason: "post-failed",
699
- });
700
- });
701
-
702
- test("stop() performs final flush", async () => {
703
- const events = [makeUsageEvent()];
704
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
705
- mockFetch.mockImplementation(() =>
706
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
707
- );
708
-
709
- const reporter = makeReporter();
710
- reporter.start();
711
-
712
- // Wait a tick so start()'s immediate flush settles.
713
- await new Promise((resolve) => setTimeout(resolve, 50));
714
- const callsBeforeStop = mockFetch.mock.calls.length;
715
-
716
- await reporter.stop();
717
-
718
- // stop() must trigger at least one additional flush beyond what start() did.
719
- expect(mockFetch.mock.calls.length).toBeGreaterThan(callsBeforeStop);
720
- });
721
-
722
- test("payload shape is correct", async () => {
723
- const event = makeUsageEvent({
724
- id: "evt-shape-test",
725
- provider: "anthropic",
726
- model: "claude-sonnet-4-20250514",
727
- inputTokens: 200,
728
- outputTokens: 100,
729
- cacheCreationInputTokens: 20,
730
- cacheReadInputTokens: 15,
731
- actor: "context_compactor",
732
- callSite: "compactionAgent",
733
- inferenceProfile: "quality-optimized",
734
- inferenceProfileSource: "conversation",
735
- llmCallCount: 3,
736
- createdAt: 1700000099000,
737
- });
738
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
739
- mockFetch.mockImplementation(() =>
740
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
741
- );
742
-
743
- const reporter = makeReporter();
744
- await reporter.flush();
745
-
746
- expect(mockFetch).toHaveBeenCalledTimes(1);
747
- const body = JSON.parse(
748
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
749
- );
750
-
751
- // Top-level: device_id, assistant_version, and events array (no turn_events key)
752
- expect(body.device_id).toBe("test-device-id");
753
- expect(body.assistant_version).toBe("1.2.3-test");
754
- expect(Array.isArray(body.events)).toBe(true);
755
- expect(body.events.length).toBe(1);
756
- expect(body.turn_events).toBeUndefined();
757
-
758
- const e = body.events[0];
759
- expect(e.type).toBe("llm_usage");
760
- expect(e.daemon_event_id).toBe("evt-shape-test");
761
- expect(e.provider).toBe("anthropic");
762
- expect(e.model).toBe("claude-sonnet-4-20250514");
763
- expect(e.input_tokens).toBe(200);
764
- expect(e.output_tokens).toBe(100);
765
- expect(e.cache_creation_input_tokens).toBe(20);
766
- expect(e.cache_read_input_tokens).toBe(15);
767
- expect(e.llm_call_count).toBe(3);
768
- expect(e.actor).toBe("context_compactor");
769
- expect(e.llm_call_site).toBe("compactionAgent");
770
- expect(e.inference_profile).toBe("quality-optimized");
771
- expect(e.inference_profile_source).toBe("conversation");
772
- expect(e.cost).toBe(0.001);
773
- expect(e.recorded_at).toBe(1700000099000);
774
- // raw_usage defaults to null on this fixture (the makeUsageEvent default),
775
- // confirming the wire shape carries the key as `null` rather than
776
- // dropping it for legacy rows or providers that did not return a
777
- // usage block.
778
- expect(e.raw_usage).toBeNull();
779
- });
780
-
781
- test("payload forwards the provider's raw_usage block verbatim", async () => {
782
- // The reporter must surface the literal usage object the provider
783
- // returned (Anthropic nests TTL breakdown under `cache_creation`,
784
- // OpenAI nests cached-read counts under `prompt_tokens_details`,
785
- // etc.) so downstream consumers can extract any provider-specific
786
- // detail without a wire-level schema change. Anything that
787
- // transforms, summarises, or strips fields here destroys data the
788
- // admin charts and dbt models depend on.
789
- const rawUsage = {
790
- input_tokens: 200,
791
- output_tokens: 100,
792
- cache_creation_input_tokens: 750,
793
- cache_creation: {
794
- ephemeral_5m_input_tokens: 250,
795
- ephemeral_1h_input_tokens: 500,
796
- },
797
- cache_read_input_tokens: 15,
798
- service_tier: "standard",
799
- };
800
- const event = makeUsageEvent({
801
- id: "evt-raw-usage",
802
- provider: "anthropic",
803
- model: "claude-sonnet-4-20250514",
804
- cacheCreationInputTokens: 750,
805
- rawUsage,
806
- });
807
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
808
- mockFetch.mockImplementation(() =>
809
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
810
- );
811
-
812
- const reporter = makeReporter();
813
- await reporter.flush();
814
-
815
- const body = JSON.parse(
816
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
817
- );
818
- const e = body.events[0];
819
- expect(e.cache_creation_input_tokens).toBe(750);
820
- expect(e.raw_usage).toEqual(rawUsage);
821
- });
822
-
823
- test("payload preserves null attribution for historical usage rows", async () => {
824
- const event = makeUsageEvent({
825
- id: "evt-legacy-usage",
826
- callSite: null,
827
- inferenceProfile: null,
828
- inferenceProfileSource: null,
829
- llmCallCount: null,
830
- });
831
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
832
- mockFetch.mockImplementation(() =>
833
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
834
- );
835
-
836
- const reporter = makeReporter();
837
- await reporter.flush();
838
-
839
- const body = JSON.parse(
840
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
841
- );
842
- expect(body.events[0]).toMatchObject({
843
- type: "llm_usage",
844
- daemon_event_id: "evt-legacy-usage",
845
- llm_call_site: null,
846
- inference_profile: null,
847
- inference_profile_source: null,
848
- llm_call_count: null,
849
- });
850
- });
851
-
852
- test("cost is null when estimatedCostUsd is null (unpriced event)", async () => {
853
- const event = makeUsageEvent({
854
- id: "evt-unpriced",
855
- estimatedCostUsd: null,
856
- pricingStatus: "unpriced",
857
- });
858
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
859
- mockFetch.mockImplementation(() =>
860
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
861
- );
862
-
863
- const reporter = makeReporter();
864
- await reporter.flush();
865
-
866
- const body = JSON.parse(
867
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
868
- );
869
- expect(body.events[0].cost).toBeNull();
870
- });
871
-
872
- test("organization_id and user_id included in payload when available", async () => {
873
- mockGetPlatformOrganizationId.mockReturnValue("org-123");
874
- mockGetPlatformUserId.mockReturnValue("user-456");
875
- const events = [makeUsageEvent()];
876
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
877
- mockFetch.mockImplementation(() =>
878
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
879
- );
880
-
881
- const reporter = makeReporter();
882
- await reporter.flush();
883
-
884
- expect(mockFetch).toHaveBeenCalledTimes(1);
885
- const body = JSON.parse(
886
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
887
- );
888
- expect(body.organization_id).toBe("org-123");
889
- expect(body.user_id).toBe("user-456");
890
- });
891
-
892
- test("organization_id and user_id omitted from payload when empty", async () => {
893
- mockGetPlatformOrganizationId.mockReturnValue("");
894
- mockGetPlatformUserId.mockReturnValue("");
895
- const events = [makeUsageEvent()];
896
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
897
- mockFetch.mockImplementation(() =>
898
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
899
- );
900
-
901
- const reporter = makeReporter();
902
- await reporter.flush();
903
-
904
- expect(mockFetch).toHaveBeenCalledTimes(1);
905
- const body = JSON.parse(
906
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
907
- );
908
- expect(body.organization_id).toBeUndefined();
909
- expect(body.user_id).toBeUndefined();
910
- });
911
-
912
- test("payload does not include assistant_id", async () => {
913
- const events = [makeUsageEvent()];
914
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
915
- mockFetch.mockImplementation(() =>
916
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
917
- );
918
-
919
- const reporter = makeReporter();
920
- await reporter.flush();
921
-
922
- expect(mockFetch).toHaveBeenCalledTimes(1);
923
- const body = JSON.parse(
924
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
925
- );
926
- expect(body.assistant_id).toBeUndefined();
927
- });
928
-
929
- test("turn events are included in the events array with type discriminator", async () => {
930
- const usageEvent = makeUsageEvent({ id: "evt-mixed-usage" });
931
- mockQueryUnreportedUsageEvents.mockReturnValue([usageEvent]);
932
- mockQueryUnreportedTurnEvents.mockReturnValue([
933
- {
934
- id: "evt-mixed-turn",
935
- createdAt: 1700000050000,
936
- conversationId: "conv-mixed",
937
- conversationType: "standard",
938
- turnIndex: 1,
939
- interfaceId: null,
940
- channelId: null,
941
- clientMetadata: null,
942
- },
943
- ]);
944
- mockFetch.mockImplementation(() =>
945
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
946
- );
947
-
948
- const reporter = makeReporter();
949
- await reporter.flush();
950
-
951
- expect(mockFetch).toHaveBeenCalledTimes(1);
952
- const body = JSON.parse(
953
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
954
- );
955
-
956
- // Single events array containing both types
957
- expect(body.events.length).toBe(2);
958
- expect(body.turn_events).toBeUndefined();
959
-
960
- const llmEvent = body.events.find(
961
- (e: { type: string }) => e.type === "llm_usage",
962
- );
963
- const turnEvent = body.events.find(
964
- (e: { type: string }) => e.type === "turn",
965
- );
966
-
967
- expect(llmEvent).toBeDefined();
968
- expect(llmEvent.daemon_event_id).toBe("evt-mixed-usage");
969
-
970
- expect(turnEvent).toBeDefined();
971
- expect(turnEvent.daemon_event_id).toBe("evt-mixed-turn");
972
- expect(turnEvent.recorded_at).toBe(1700000050000);
973
- expect(turnEvent.conversation_id).toBe("conv-mixed");
974
- expect(turnEvent.conversation_type).toBe("standard");
975
- expect(turnEvent.turn_index).toBe(1);
976
- });
977
-
978
- test("turn events carry conversation_type for background/scheduled conversations", async () => {
979
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
980
- mockQueryUnreportedTurnEvents.mockReturnValue([
981
- {
982
- id: "evt-turn-standard",
983
- createdAt: 1700000100000,
984
- conversationId: "conv-std",
985
- conversationType: "standard",
986
- turnIndex: 1,
987
- interfaceId: null,
988
- channelId: null,
989
- clientMetadata: null,
990
- },
991
- {
992
- id: "evt-turn-background",
993
- createdAt: 1700000200000,
994
- conversationId: "conv-bg",
995
- conversationType: "background",
996
- turnIndex: 1,
997
- interfaceId: null,
998
- channelId: null,
999
- clientMetadata: null,
1000
- },
1001
- {
1002
- id: "evt-turn-scheduled",
1003
- createdAt: 1700000300000,
1004
- conversationId: "conv-sched",
1005
- conversationType: "scheduled",
1006
- turnIndex: 1,
1007
- interfaceId: null,
1008
- channelId: null,
1009
- clientMetadata: null,
1010
- },
1011
- ]);
1012
- mockFetch.mockImplementation(() =>
1013
- Promise.resolve(new Response('{"accepted":3}', { status: 200 })),
1014
- );
1015
-
1016
- const reporter = makeReporter();
1017
- await reporter.flush();
1018
-
1019
- expect(mockFetch).toHaveBeenCalledTimes(1);
1020
- const body = JSON.parse(
1021
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1022
- );
1023
-
1024
- const byId: Record<string, { conversation_type: string }> = {};
1025
- for (const e of body.events as Array<{
1026
- daemon_event_id: string;
1027
- conversation_type: string;
1028
- }>) {
1029
- byId[e.daemon_event_id] = e;
1030
- }
1031
-
1032
- expect(byId["evt-turn-standard"].conversation_type).toBe("standard");
1033
- expect(byId["evt-turn-background"].conversation_type).toBe("background");
1034
- expect(byId["evt-turn-scheduled"].conversation_type).toBe("scheduled");
1035
- });
1036
-
1037
- test("turn events carry interface_id, channel_id, and parsed client metadata", async () => {
1038
- // Four turns spanning the relevant cases:
1039
- // - macOS in-app turn with full client block (typical interactive user)
1040
- // - slack inbound turn with no client block (channel-based, no headers)
1041
- // - web turn with malformed client JSON (parsing guard: emit null,
1042
- // do not break the batch)
1043
- // - historical turn with no metadata at all (pre-rollout row)
1044
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1045
- mockQueryUnreportedTurnEvents.mockReturnValue([
1046
- {
1047
- id: "evt-turn-macos",
1048
- createdAt: 1700000400000,
1049
- conversationId: "conv-mac",
1050
- conversationType: "standard",
1051
- turnIndex: 1,
1052
- interfaceId: "macos",
1053
- channelId: "cadet",
1054
- clientMetadata: JSON.stringify({
1055
- browser_family: null,
1056
- os: "darwin",
1057
- interface_version: "0.8.2",
1058
- }),
1059
- },
1060
- {
1061
- id: "evt-turn-slack",
1062
- createdAt: 1700000500000,
1063
- conversationId: "conv-slack",
1064
- conversationType: "standard",
1065
- turnIndex: 1,
1066
- interfaceId: "slack",
1067
- channelId: "slack",
1068
- clientMetadata: null,
1069
- },
1070
- {
1071
- id: "evt-turn-web-broken",
1072
- createdAt: 1700000600000,
1073
- conversationId: "conv-web",
1074
- conversationType: "standard",
1075
- turnIndex: 1,
1076
- interfaceId: "web",
1077
- channelId: "cadet",
1078
- clientMetadata: "{not valid json",
1079
- },
1080
- {
1081
- id: "evt-turn-legacy",
1082
- createdAt: 1700000700000,
1083
- conversationId: "conv-legacy",
1084
- conversationType: "standard",
1085
- turnIndex: 1,
1086
- interfaceId: null,
1087
- channelId: null,
1088
- clientMetadata: null,
1089
- },
1090
- ]);
1091
- mockFetch.mockImplementation(() =>
1092
- Promise.resolve(new Response('{"accepted":4}', { status: 200 })),
1093
- );
1094
-
1095
- const reporter = makeReporter();
1096
- await reporter.flush();
1097
-
1098
- expect(mockFetch).toHaveBeenCalledTimes(1);
1099
- const body = JSON.parse(
1100
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1101
- );
1102
-
1103
- const byId: Record<
1104
- string,
1105
- {
1106
- interface_id: string | null;
1107
- channel_id: string | null;
1108
- client: Record<string, unknown> | null;
1109
- }
1110
- > = {};
1111
- for (const e of body.events as Array<{
1112
- daemon_event_id: string;
1113
- interface_id: string | null;
1114
- channel_id: string | null;
1115
- client: Record<string, unknown> | null;
1116
- }>) {
1117
- byId[e.daemon_event_id] = e;
1118
- }
1119
-
1120
- expect(byId["evt-turn-macos"]).toMatchObject({
1121
- interface_id: "macos",
1122
- channel_id: "cadet",
1123
- client: {
1124
- os: "darwin",
1125
- interface_version: "0.8.2",
1126
- },
1127
- });
1128
- expect(byId["evt-turn-slack"]).toMatchObject({
1129
- interface_id: "slack",
1130
- channel_id: "slack",
1131
- client: null,
1132
- });
1133
- // Malformed client JSON is downgraded to null without failing the
1134
- // batch — the interface_id/channel_id from the typed columns still
1135
- // ride through cleanly.
1136
- expect(byId["evt-turn-web-broken"]).toMatchObject({
1137
- interface_id: "web",
1138
- channel_id: "cadet",
1139
- client: null,
1140
- });
1141
- expect(byId["evt-turn-legacy"]).toMatchObject({
1142
- interface_id: null,
1143
- channel_id: null,
1144
- client: null,
1145
- });
1146
- });
1147
-
1148
- // -------------------------------------------------------------------------
1149
- // Per-turn trace collection (gated on share_diagnostics consent at an
1150
- // eligible accepted version)
1151
- // -------------------------------------------------------------------------
1152
-
1153
- function singleTurnEvent() {
1154
- return [
1155
- {
1156
- id: "evt-turn-trace",
1157
- createdAt: 1700000050000,
1158
- conversationId: "conv-trace",
1159
- conversationType: "standard",
1160
- turnIndex: 2,
1161
- interfaceId: "macos",
1162
- channelId: "cadet",
1163
- clientMetadata: null,
1164
- },
1165
- ];
1166
- }
1167
-
1168
- test("attaches the assembled trace when share_diagnostics and an eligible consent version are both true", async () => {
1169
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1170
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1171
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1172
- mockFetch.mockImplementation(() =>
1173
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1174
- );
1175
-
1176
- const reporter = makeReporter();
1177
- await reporter.flush();
1178
-
1179
- // The assembler is called with the turn's (conversationId, id, createdAt)
1180
- // boundary so the window lines up with the turn event.
1181
- expect(mockAssembleBoundedTurnTrace).toHaveBeenCalledTimes(1);
1182
- expect(mockAssembleBoundedTurnTrace.mock.calls[0][0]).toEqual({
1183
- conversationId: "conv-trace",
1184
- userMessageId: "evt-turn-trace",
1185
- userMessageCreatedAt: 1700000050000,
1186
- });
1187
-
1188
- expect(mockFetch).toHaveBeenCalledTimes(1);
1189
- const body = JSON.parse(
1190
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1191
- );
1192
- // Still exactly one event — the single turn event, now carrying `trace`.
1193
- expect(body.events.length).toBe(1);
1194
- const turn = body.events[0];
1195
- expect(turn.type).toBe("turn");
1196
- expect(turn.daemon_event_id).toBe("evt-turn-trace");
1197
- expect(turn.trace).toBeDefined();
1198
- expect(turn.trace.schema_version).toBe(3);
1199
- expect(turn.trace.messages[0].id).toBe("evt-turn-trace");
1200
- expect(turn.trace.messages[0].model).toBeNull();
1201
- expect(Array.isArray(turn.trace.tool_calls)).toBe(true);
1202
- });
1203
-
1204
- test("omits the trace when share_diagnostics is false (and still emits the turn event)", async () => {
1205
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1206
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1207
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1208
- mockFetch.mockImplementation(() =>
1209
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1210
- );
1211
-
1212
- const reporter = makeReporter();
1213
- await reporter.flush();
1214
-
1215
- // Gate off → no assembly at all (no PII touched).
1216
- expect(mockAssembleBoundedTurnTrace).not.toHaveBeenCalled();
1217
-
1218
- expect(mockFetch).toHaveBeenCalledTimes(1);
1219
- const body = JSON.parse(
1220
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1221
- );
1222
- // The single turn event still flushes — just without a trace.
1223
- expect(body.events.length).toBe(1);
1224
- const turn = body.events[0];
1225
- expect(turn.type).toBe("turn");
1226
- expect(turn.daemon_event_id).toBe("evt-turn-trace");
1227
- expect("trace" in turn).toBe(false);
1228
- });
1229
-
1230
- test("omits the trace when the accepted consent version predates the disclosure threshold (share_diagnostics on)", async () => {
1231
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1232
- // Consent recorded under an older version that never disclosed trace
1233
- // collection → gate closed, mirroring the cloud ingest gate.
1234
- mockGetCachedShareDiagnosticsVersion.mockReturnValue("2000-01-01");
1235
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1236
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1237
- mockFetch.mockImplementation(() =>
1238
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1239
- );
1240
-
1241
- const reporter = makeReporter();
1242
- await reporter.flush();
1243
-
1244
- // Version below threshold → no assembly at all (no PII touched).
1245
- expect(mockAssembleBoundedTurnTrace).not.toHaveBeenCalled();
1246
-
1247
- expect(mockFetch).toHaveBeenCalledTimes(1);
1248
- const body = JSON.parse(
1249
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1250
- );
1251
- expect(body.events.length).toBe(1);
1252
- expect("trace" in body.events[0]).toBe(false);
1253
- });
1254
-
1255
- test("omits the trace when the owner never accepted a versioned consent (empty version)", async () => {
1256
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1257
- // Empty version (never accepted / no-row default where share_diagnostics is
1258
- // true but unversioned) fails closed.
1259
- mockGetCachedShareDiagnosticsVersion.mockReturnValue("");
1260
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1261
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1262
- mockFetch.mockImplementation(() =>
1263
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1264
- );
1265
-
1266
- const reporter = makeReporter();
1267
- await reporter.flush();
1268
-
1269
- expect(mockAssembleBoundedTurnTrace).not.toHaveBeenCalled();
1270
- const body = JSON.parse(
1271
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1272
- );
1273
- expect(body.events.length).toBe(1);
1274
- expect("trace" in body.events[0]).toBe(false);
1275
- });
1276
-
1277
- test("omits the trace (key absent) when the assembler returns null (over-cap / failure) but still emits the turn event", async () => {
1278
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1279
- // Over-cap / assembly-failure path: the bounded assembler returns null.
1280
- mockAssembleBoundedTurnTrace.mockReturnValueOnce(null);
1281
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1282
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1283
- mockFetch.mockImplementation(() =>
1284
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1285
- );
1286
-
1287
- const reporter = makeReporter();
1288
- await reporter.flush();
1289
-
1290
- expect(mockAssembleBoundedTurnTrace).toHaveBeenCalledTimes(1);
1291
- const body = JSON.parse(
1292
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1293
- );
1294
- expect(body.events.length).toBe(1);
1295
- expect("trace" in body.events[0]).toBe(false);
1296
- });
1297
-
1298
- test("no trace is assembled or attached when the whole flush is gated off by share_analytics", async () => {
1299
- // The analytics gate short-circuits the entire flush; trace assembly must
1300
- // never run (and nothing is sent) even when the trace gate is fully on
1301
- // (share_diagnostics true at an eligible version).
1302
- mockGetRawShareAnalytics.mockReturnValue(false);
1303
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1304
- mockQueryUnreportedTurnEvents.mockReturnValue(singleTurnEvent());
1305
-
1306
- const reporter = makeReporter();
1307
- await reporter.flush();
1308
-
1309
- expect(mockAssembleBoundedTurnTrace).not.toHaveBeenCalled();
1310
- expect(mockFetch).not.toHaveBeenCalled();
1311
- });
1312
-
1313
- // -------------------------------------------------------------------------
1314
- // Trace completeness barrier — don't emit partial traces mid-turn
1315
- // -------------------------------------------------------------------------
1316
-
1317
- function turnEvent(
1318
- id: string,
1319
- createdAt: number,
1320
- conversationId: string,
1321
- overrides: {
1322
- outcome?: string | null;
1323
- batchedInto?: string | null;
1324
- failureCode?: string | null;
1325
- scripted?: boolean | null;
1326
- } = {},
1327
- ) {
1328
- return {
1329
- id,
1330
- createdAt,
1331
- conversationId,
1332
- conversationType: "standard",
1333
- turnIndex: 1,
1334
- interfaceId: "macos",
1335
- channelId: "cadet",
1336
- clientMetadata: null,
1337
- // Defaults to UNKNOWN so existing cases keep asserting the pre-marker
1338
- // wire shape; each scripted case opts in explicitly.
1339
- scripted: null,
1340
- ...overrides,
1341
- };
1342
- }
1343
-
1344
- test("a flush during an in-progress turn defers it (no partial trace, watermark held)", async () => {
1345
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1346
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1347
- const checkpoints = useStatefulCheckpoints();
1348
- mockQueryUnreportedTurnEvents.mockReturnValue([
1349
- turnEvent("evt-inflight", 1700000050000, "conv-1"),
1350
- ]);
1351
- // The turn's response is still streaming — not settled.
1352
- mockIsTurnSettled.mockReturnValue(false);
1353
-
1354
- const reporter = makeReporter();
1355
- await reporter.flush();
1356
-
1357
- // No trace assembled, and the turn is NOT sent (the only event was deferred).
1358
- expect(mockAssembleBoundedTurnTrace).not.toHaveBeenCalled();
1359
- expect(mockFetch).not.toHaveBeenCalled();
1360
- // The turn watermark must NOT advance past the deferred turn.
1361
- expect(checkpoints.get("telemetry:turns:last_reported_at")).toBeUndefined();
1362
- expect(checkpoints.get("telemetry:turns:last_reported_id")).toBeUndefined();
1363
- });
1364
-
1365
- test("a later flush emits the COMPLETE trace once the turn settles", async () => {
1366
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1367
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1368
- const checkpoints = useStatefulCheckpoints();
1369
- mockQueryUnreportedTurnEvents.mockReturnValue([
1370
- turnEvent("evt-inflight", 1700000050000, "conv-1"),
1371
- ]);
1372
-
1373
- // Flush 1: turn in progress -> deferred, nothing sent.
1374
- mockIsTurnSettled.mockReturnValue(false);
1375
- const reporter = makeReporter();
1376
- await reporter.flush();
1377
- expect(mockFetch).not.toHaveBeenCalled();
1378
-
1379
- // Flush 2: the response + tool results have landed; the turn is settled and
1380
- // the full trace assembles. The same (still-unreported) turn now ships.
1381
- mockIsTurnSettled.mockReturnValue(true);
1382
- mockAssembleBoundedTurnTrace.mockReturnValue({
1383
- schema_version: 3,
1384
- messages: [
1385
- {
1386
- id: "evt-inflight",
1387
- role: "user",
1388
- created_at: 1700000050000,
1389
- content: [{ type: "text", text: "do a thing" }],
1390
- model: null,
1391
- },
1392
- {
1393
- id: "asst-1",
1394
- role: "assistant",
1395
- created_at: 1700000051000,
1396
- content: [{ type: "text", text: "done" }],
1397
- model: "claude-fable-5",
1398
- },
1399
- ],
1400
- tool_calls: [{ id: "ti-1" }],
1401
- system_prompt: "You are a helpful assistant.",
1402
- tool_definitions: [],
1403
- });
1404
- await reporter.flush();
1405
-
1406
- expect(mockFetch).toHaveBeenCalledTimes(1);
1407
- const body = JSON.parse(
1408
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1409
- );
1410
- expect(body.events.length).toBe(1);
1411
- const turn = body.events[0];
1412
- expect(turn.daemon_event_id).toBe("evt-inflight");
1413
- // The COMPLETE transcript: user message + assistant response + tool call.
1414
- expect(turn.trace.messages.map((m: { id: string }) => m.id)).toEqual([
1415
- "evt-inflight",
1416
- "asst-1",
1417
- ]);
1418
- expect(turn.trace.tool_calls).toHaveLength(1);
1419
- // Watermark now advances to the (now-complete) turn.
1420
- expect(checkpoints.get("telemetry:turns:last_reported_at")).toBe(
1421
- String(1700000050000),
1422
- );
1423
- expect(checkpoints.get("telemetry:turns:last_reported_id")).toBe(
1424
- "evt-inflight",
1425
- );
1426
- });
1427
-
1428
- test("the final turn of a conversation still gets its complete trace once its own response finishes", async () => {
1429
- // No successor turn exists; `isTurnSettled` returns true purely because the
1430
- // conversation is no longer processing. The trace must still be sent (not
1431
- // deferred forever for lack of a next user turn).
1432
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1433
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1434
- mockQueryUnreportedTurnEvents.mockReturnValue([
1435
- turnEvent("evt-final", 1700000060000, "conv-final"),
1436
- ]);
1437
- mockIsTurnSettled.mockReturnValue(true);
1438
- mockFetch.mockImplementation(() =>
1439
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1440
- );
1441
-
1442
- const reporter = makeReporter();
1443
- await reporter.flush();
1444
-
1445
- expect(mockAssembleBoundedTurnTrace).toHaveBeenCalledTimes(1);
1446
- expect(mockFetch).toHaveBeenCalledTimes(1);
1447
- const body = JSON.parse(
1448
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1449
- );
1450
- expect(body.events.length).toBe(1);
1451
- expect(body.events[0].daemon_event_id).toBe("evt-final");
1452
- expect(body.events[0].trace).toBeDefined();
1453
- });
1454
-
1455
- test("barrier: a complete turn ordered AFTER an in-flight turn is also deferred (no watermark skip)", async () => {
1456
- // The turn watermark is a single monotonic cursor, so a complete turn that
1457
- // sorts after a deferred in-flight turn cannot be reported without skipping
1458
- // the deferred one. Both wait; the earlier complete turn (before the
1459
- // barrier) is reported.
1460
- mockGetCachedShareDiagnostics.mockReturnValue(true);
1461
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1462
- const checkpoints = useStatefulCheckpoints();
1463
- mockQueryUnreportedTurnEvents.mockReturnValue([
1464
- turnEvent("evt-a-complete", 1700000010000, "conv-a"),
1465
- turnEvent("evt-b-inflight", 1700000020000, "conv-b"),
1466
- turnEvent("evt-c-complete", 1700000030000, "conv-c"),
1467
- ]);
1468
- // Only the middle turn is in-flight.
1469
- mockIsTurnSettled.mockImplementation(
1470
- (b) => b.userMessageId !== "evt-b-inflight",
1471
- );
1472
- mockFetch.mockImplementation(() =>
1473
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1474
- );
1475
-
1476
- const reporter = makeReporter();
1477
- await reporter.flush();
1478
-
1479
- expect(mockFetch).toHaveBeenCalledTimes(1);
1480
- const body = JSON.parse(
1481
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1482
- );
1483
- // Only the turn BEFORE the in-flight barrier is reported.
1484
- expect(
1485
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
1486
- ).toEqual(["evt-a-complete"]);
1487
- // Watermark stops at the last reported turn, NOT the later complete turn —
1488
- // so the deferred middle turn is never skipped.
1489
- expect(checkpoints.get("telemetry:turns:last_reported_at")).toBe(
1490
- String(1700000010000),
1491
- );
1492
- expect(checkpoints.get("telemetry:turns:last_reported_id")).toBe(
1493
- "evt-a-complete",
1494
- );
1495
- });
1496
-
1497
- test("with tracing disabled, an in-progress turn is still deferred (outcome stamps must ship with the event)", async () => {
1498
- // The completeness barrier applies to every turn event, not just
1499
- // trace-eligible ones: the `outcome` stamp is written while the
1500
- // conversation is still processing, and the watermark advances on ship,
1501
- // so an in-flight turn reported early would be frozen without its stamp.
1502
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1503
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1504
- const checkpoints = useStatefulCheckpoints();
1505
- mockQueryUnreportedTurnEvents.mockReturnValue([
1506
- turnEvent("evt-inflight", 1700000050000, "conv-1"),
1507
- ]);
1508
- mockIsTurnSettled.mockReturnValue(false);
1509
- mockFetch.mockImplementation(() =>
1510
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1511
- );
1512
-
1513
- const reporter = makeReporter();
1514
- await reporter.flush();
1515
-
1516
- expect(mockIsTurnSettled).toHaveBeenCalled();
1517
- // Nothing ships and the watermark holds, so the turn is retried complete.
1518
- expect(mockFetch).not.toHaveBeenCalled();
1519
- expect(checkpoints.get("telemetry:turns:last_reported_at")).toBeUndefined();
1520
- });
1521
-
1522
- test("with tracing disabled, a settled turn ships trace-free and advances the watermark", async () => {
1523
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1524
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1525
- const checkpoints = useStatefulCheckpoints();
1526
- mockQueryUnreportedTurnEvents.mockReturnValue([
1527
- turnEvent("evt-settled", 1700000050000, "conv-1"),
1528
- ]);
1529
- mockIsTurnSettled.mockReturnValue(true);
1530
- mockFetch.mockImplementation(() =>
1531
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1532
- );
1533
-
1534
- const reporter = makeReporter();
1535
- await reporter.flush();
1536
-
1537
- expect(mockFetch).toHaveBeenCalledTimes(1);
1538
- const body = JSON.parse(
1539
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1540
- );
1541
- expect(body.events.length).toBe(1);
1542
- expect(body.events[0].daemon_event_id).toBe("evt-settled");
1543
- expect("trace" in body.events[0]).toBe(false);
1544
- // Watermark advances as usual.
1545
- expect(checkpoints.get("telemetry:turns:last_reported_at")).toBe(
1546
- String(1700000050000),
1547
- );
1548
- });
1549
-
1550
- test("outcome stamps ride the turn event: batched carries batched_into, failed carries failure_code", async () => {
1551
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1552
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1553
- useStatefulCheckpoints();
1554
- mockQueryUnreportedTurnEvents.mockReturnValue([
1555
- turnEvent("evt-batched-head", 1700000010000, "conv-1", {
1556
- outcome: "batched",
1557
- batchedInto: "evt-batch-final",
1558
- }),
1559
- turnEvent("evt-batch-final", 1700000011000, "conv-1"),
1560
- turnEvent("evt-failed", 1700000012000, "conv-2", {
1561
- outcome: "failed",
1562
- failureCode: "PROVIDER_RATE_LIMIT",
1563
- }),
1564
- turnEvent("evt-cancelled", 1700000013000, "conv-3", {
1565
- outcome: "cancelled",
1566
- }),
1567
- ]);
1568
- mockIsTurnSettled.mockReturnValue(true);
1569
- mockFetch.mockImplementation(() =>
1570
- Promise.resolve(new Response('{"accepted":4}', { status: 200 })),
1571
- );
1572
-
1573
- const reporter = makeReporter();
1574
- await reporter.flush();
1575
-
1576
- expect(mockFetch).toHaveBeenCalledTimes(1);
1577
- const body = JSON.parse(
1578
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1579
- );
1580
- const byId = Object.fromEntries(
1581
- body.events.map((e: { daemon_event_id: string }) => [
1582
- e.daemon_event_id,
1583
- e,
1584
- ]),
1585
- );
1586
- expect(byId["evt-batched-head"].outcome).toBe("batched");
1587
- expect(byId["evt-batched-head"].batched_into).toBe("evt-batch-final");
1588
- expect("failure_code" in byId["evt-batched-head"]).toBe(false);
1589
- // The batch-final turn replied normally: no outcome keys at all.
1590
- expect("outcome" in byId["evt-batch-final"]).toBe(false);
1591
- expect("batched_into" in byId["evt-batch-final"]).toBe(false);
1592
- expect(byId["evt-failed"].outcome).toBe("failed");
1593
- expect(byId["evt-failed"].failure_code).toBe("PROVIDER_RATE_LIMIT");
1594
- expect("batched_into" in byId["evt-failed"]).toBe(false);
1595
- expect(byId["evt-cancelled"].outcome).toBe("cancelled");
1596
- expect("failure_code" in byId["evt-cancelled"]).toBe(false);
1597
- });
1598
-
1599
- test("the scripted marker rides the turn event with all three states distinct", async () => {
1600
- // `scripted` is what lets activation metrics exclude auto-sent onboarding
1601
- // turns for EVERY owner rather than only diagnostics-consenting ones
1602
- // (ANT-10), so its states must stay distinguishable on the wire. Note
1603
- // diagnostics consent is OFF here: unlike `trace`, this field must ship
1604
- // regardless: that independence is the entire point of the field.
1605
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1606
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1607
- useStatefulCheckpoints();
1608
- mockQueryUnreportedTurnEvents.mockReturnValue([
1609
- turnEvent("evt-scripted", 1700000010000, "conv-1", { scripted: true }),
1610
- turnEvent("evt-typed", 1700000011000, "conv-1", { scripted: false }),
1611
- turnEvent("evt-unknown", 1700000012000, "conv-2", { scripted: null }),
1612
- ]);
1613
- mockIsTurnSettled.mockReturnValue(true);
1614
- mockFetch.mockImplementation(() =>
1615
- Promise.resolve(new Response('{"accepted":3}', { status: 200 })),
1616
- );
1617
-
1618
- const reporter = makeReporter();
1619
- await reporter.flush();
1620
-
1621
- const body = JSON.parse(
1622
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1623
- );
1624
- const byId = Object.fromEntries(
1625
- body.events.map((e: { daemon_event_id: string }) => [
1626
- e.daemon_event_id,
1627
- e,
1628
- ]),
1629
- );
1630
- expect(byId["evt-scripted"].scripted).toBe(true);
1631
- // The state most at risk: an omit-when-falsy spread would drop this and
1632
- // silently downgrade a turn we KNOW was typed into "unknown", handing
1633
- // activation back to the consent-gated classifier this field replaces.
1634
- expect("scripted" in byId["evt-typed"]).toBe(true);
1635
- expect(byId["evt-typed"].scripted).toBe(false);
1636
- // A row predating the marker stays UNKNOWN: the key is absent, so the
1637
- // wire shape is byte-identical to a pre-scripted turn event and
1638
- // downstream falls back to the trace classifier rather than being told
1639
- // something untrue about the turn.
1640
- expect("scripted" in byId["evt-unknown"]).toBe(false);
1641
- });
1642
-
1643
- test("an unrecognized outcome value in metadata is dropped from the wire", async () => {
1644
- mockGetCachedShareDiagnostics.mockReturnValue(false);
1645
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1646
- useStatefulCheckpoints();
1647
- mockQueryUnreportedTurnEvents.mockReturnValue([
1648
- turnEvent("evt-garbage", 1700000010000, "conv-1", {
1649
- outcome: "exploded",
1650
- batchedInto: "evt-other",
1651
- failureCode: "BOOM",
1652
- }),
1653
- ]);
1654
- mockIsTurnSettled.mockReturnValue(true);
1655
- mockFetch.mockImplementation(() =>
1656
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1657
- );
1658
-
1659
- const reporter = makeReporter();
1660
- await reporter.flush();
1661
-
1662
- const body = JSON.parse(
1663
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1664
- );
1665
- expect("outcome" in body.events[0]).toBe(false);
1666
- expect("batched_into" in body.events[0]).toBe(false);
1667
- expect("failure_code" in body.events[0]).toBe(false);
1668
- });
1669
-
1670
- test("llm_usage events carry conversation_id, conversation_type, turn_index, and parent linkage", async () => {
1671
- // Three LLM calls across the spectrum of the new fields:
1672
- // - tied to a conversation, mid-turn (typical foreground)
1673
- // - tied to a background conversation spawned by a parent turn
1674
- // (subagent: parent linkage populated)
1675
- // - untied (memory consolidation: no conversation, no turn)
1676
- mockQueryUnreportedUsageEvents.mockReturnValue([
1677
- makeUsageEvent({
1678
- id: "evt-fg-call",
1679
- conversationId: "conv-fg",
1680
- conversationType: "standard",
1681
- turnIndex: 4,
1682
- }),
1683
- makeUsageEvent({
1684
- id: "evt-bg-call",
1685
- conversationId: "conv-bg",
1686
- conversationType: "background",
1687
- turnIndex: 1,
1688
- parentConversationId: "conv-fg",
1689
- parentTurnIndex: 4,
1690
- }),
1691
- makeUsageEvent({
1692
- id: "evt-untied-call",
1693
- conversationId: null,
1694
- conversationType: null,
1695
- turnIndex: null,
1696
- }),
1697
- ]);
1698
- mockFetch.mockImplementation(() =>
1699
- Promise.resolve(new Response('{"accepted":3}', { status: 200 })),
1700
- );
1701
-
1702
- const reporter = makeReporter();
1703
- await reporter.flush();
1704
-
1705
- expect(mockFetch).toHaveBeenCalledTimes(1);
1706
- const body = JSON.parse(
1707
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1708
- );
1709
-
1710
- const byId: Record<
1711
- string,
1712
- {
1713
- type: string;
1714
- conversation_id: string | null;
1715
- conversation_type: string | null;
1716
- turn_index: number | null;
1717
- parent_conversation_id: string | null;
1718
- parent_turn_index: number | null;
1719
- }
1720
- > = {};
1721
- for (const e of body.events as Array<{
1722
- type: string;
1723
- daemon_event_id: string;
1724
- conversation_id: string | null;
1725
- conversation_type: string | null;
1726
- turn_index: number | null;
1727
- parent_conversation_id: string | null;
1728
- parent_turn_index: number | null;
1729
- }>) {
1730
- byId[e.daemon_event_id] = e;
1731
- }
1732
-
1733
- expect(byId["evt-fg-call"]).toMatchObject({
1734
- type: "llm_usage",
1735
- conversation_id: "conv-fg",
1736
- conversation_type: "standard",
1737
- turn_index: 4,
1738
- parent_conversation_id: null,
1739
- parent_turn_index: null,
1740
- });
1741
- expect(byId["evt-bg-call"]).toMatchObject({
1742
- type: "llm_usage",
1743
- conversation_id: "conv-bg",
1744
- conversation_type: "background",
1745
- turn_index: 1,
1746
- parent_conversation_id: "conv-fg",
1747
- parent_turn_index: 4,
1748
- });
1749
- // LLM calls without a parent conversation flush through with all
1750
- // conversation-level fields null — the serializer accepts
1751
- // allow_null and downstream SQL filters can `WHERE conversation_id
1752
- // IS NOT NULL` to scope to foreground analytics.
1753
- expect(byId["evt-untied-call"]).toMatchObject({
1754
- type: "llm_usage",
1755
- conversation_id: null,
1756
- conversation_type: null,
1757
- turn_index: null,
1758
- parent_conversation_id: null,
1759
- parent_turn_index: null,
1760
- });
1761
- });
1762
-
1763
- test("flush is skipped and watermarks advanced when share_analytics consent is off", async () => {
1764
- mockGetRawShareAnalytics.mockReturnValue(false);
1765
- const events = [makeUsageEvent()];
1766
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
1767
- mockFetch.mockImplementation(() =>
1768
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1769
- );
1770
-
1771
- const reporter = makeReporter();
1772
- // Construction initializes the absent tool_executed watermark; clear that
1773
- // call so the count below covers only the flush's advancement.
1774
- mockSetFlushCheckpoint.mockClear();
1775
- await reporter.flush();
1776
-
1777
- // No HTTP call should have been made
1778
- expect(mockFetch).not.toHaveBeenCalled();
1779
-
1780
- // All 3 watermark-mode timestamp watermarks should have been advanced,
1781
- // and their 3 ID watermarks pinned to the high-sorting sentinel (a truthy
1782
- // value keeps the compound-cursor branch active while closing its
1783
- // same-millisecond arm against opt-out rows). Ack-mode sources
1784
- // (lifecycle, onboarding, auth_fallback, skill_loaded, watchdog,
1785
- // config_setting) discard their pending outbox rows instead and never
1786
- // touch `flush_checkpoints`.
1787
- expect(mockSetFlushCheckpoint).toHaveBeenCalledTimes(6);
1788
-
1789
- const calls = mockSetFlushCheckpoint.mock.calls;
1790
- const keys = calls.map((c) => c[0]);
1791
- expect(keys.some((k) => k.includes("skill_loaded"))).toBe(false);
1792
- const eventTypes = ["usage", "turns", "tool_executed"];
1793
- for (const eventType of eventTypes) {
1794
- expect(keys).toContain(`telemetry:${eventType}:last_reported_at`);
1795
- const idCall = calls.find(
1796
- (c) => c[0] === `telemetry:${eventType}:last_reported_id`,
1797
- );
1798
- expect(idCall?.[1]).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff");
1799
- }
1800
- expect(keys).not.toContain("telemetry:auth_fallback:last_reported_at");
1801
- expect(keys).not.toContain("telemetry:auth_fallback:last_reported_id");
1802
- expect(keys.some((k) => k.includes(":lifecycle:"))).toBe(false);
1803
- expect(keys).not.toContain("telemetry:onboarding:last_reported_at");
1804
- expect(keys).not.toContain("telemetry:onboarding:last_reported_id");
1805
- });
1806
-
1807
- test("warm-up: unknown-consent flushes defer the backlog until consent resolves to true", async () => {
1808
- // An unresolved consent cache (boot before the first refresh, no platform
1809
- // session) is not an opt-out: unlike the confirmed-false branch, nothing
1810
- // is sent, nothing is purged, and no watermark moves, so the backlog
1811
- // survives the cold-cache window.
1812
- mockGetRawShareAnalytics.mockReturnValue("unknown");
1813
- const events = [makeUsageEvent({ id: "evt-warmup-backlog" })];
1814
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
1815
-
1816
- const reporter = makeReporter();
1817
- // Construction initializes the absent tool_executed watermark; clear that
1818
- // call so the assertions below cover only the flushes.
1819
- mockSetFlushCheckpoint.mockClear();
1820
- await reporter.flush();
1821
- expect(mockFetch).not.toHaveBeenCalled();
1822
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
1823
-
1824
- // The refresh loop resolves the cache to a confirmed opt-in: the same
1825
- // backlog now ships.
1826
- mockGetRawShareAnalytics.mockReturnValue(true);
1827
- await reporter.flush();
1828
-
1829
- expect(mockFetch).toHaveBeenCalledTimes(1);
1830
- const body = JSON.parse(
1831
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1832
- );
1833
- expect(
1834
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
1835
- ).toEqual(["evt-warmup-backlog"]);
1836
- });
1837
-
1838
- test("platform disabled takes precedence over consent off — watermarks NOT advanced", async () => {
1839
- // CADET_DISABLE_CLOUD keeps the consent cache false (the consent
1840
- // refresh can't create a cloud client), so both gates would fire. The
1841
- // platform-disabled gate runs first and returns without advancing
1842
- // watermarks, preserving the backlog until the flag is cleared — a
1843
- // deployment toggle must not be treated as a privacy opt-out.
1844
- process.env.CADET_DISABLE_CLOUD = "true";
1845
- mockGetRawShareAnalytics.mockReturnValue(false);
1846
- const events = [makeUsageEvent()];
1847
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
1848
-
1849
- const reporter = makeReporter();
1850
- // Construction initializes the absent tool_executed watermark; clear that
1851
- // call so the assertion below covers only the flush.
1852
- mockSetFlushCheckpoint.mockClear();
1853
- await reporter.flush();
1854
-
1855
- expect(mockFetch).not.toHaveBeenCalled();
1856
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
1857
- });
1858
-
1859
- test("flush skipped entirely when the flush-checkpoint store is unavailable", async () => {
1860
- // An unreadable checkpoint store must not be mistaken for cursor 0 —
1861
- // that would requery and re-ship history from the beginning. Nothing is
1862
- // sent and no watermark is touched (not even the opt-out sentinel);
1863
- // the cycle retries once the store is back.
1864
- mockIsFlushCheckpointStoreAvailable.mockReturnValue(false);
1865
- const events = [makeUsageEvent()];
1866
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
1867
-
1868
- const reporter = makeReporter();
1869
- // Construction initializes the absent tool_executed watermark; clear
1870
- // those calls so the assertions below cover only the flush.
1871
- mockGetFlushCheckpoint.mockClear();
1872
- mockSetFlushCheckpoint.mockClear();
1873
- await reporter.flush();
1874
-
1875
- expect(mockFetch).not.toHaveBeenCalled();
1876
- expect(mockGetFlushCheckpoint).not.toHaveBeenCalled();
1877
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
1878
- });
1879
-
1880
- test("events sent normally after re-granting share_analytics consent", async () => {
1881
- // First flush with opt-out — watermarks advance, nothing sent
1882
- mockGetRawShareAnalytics.mockReturnValue(false);
1883
- const reporter = makeReporter();
1884
- await reporter.flush();
1885
- expect(mockFetch).not.toHaveBeenCalled();
1886
- mockSetFlushCheckpoint.mockReset();
1887
-
1888
- // Re-grant consent and flush with new events
1889
- mockGetRawShareAnalytics.mockReturnValue(true);
1890
- const events = [makeUsageEvent({ id: "evt-after-reenable" })];
1891
- mockQueryUnreportedUsageEvents.mockReturnValue(events);
1892
- mockFetch.mockImplementation(() =>
1893
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1894
- );
1895
-
1896
- await reporter.flush();
1897
-
1898
- expect(mockFetch).toHaveBeenCalledTimes(1);
1899
- const body = JSON.parse(
1900
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1901
- );
1902
- expect(body.events[0].daemon_event_id).toBe("evt-after-reenable");
1903
- });
1904
-
1905
- // -------------------------------------------------------------------------
1906
- // Per-event `assistant_version` on the wire
1907
- //
1908
- // The envelope's `assistant_version` is upload-time (always the current
1909
- // binary). The per-event field is record-time (the binary that was running
1910
- // when the event was persisted to SQLite). `llm_usage` events and the
1911
- // outbox-backed types carry a true record-time value; turn events
1912
- // (not asserted here) stamp the running binary's
1913
- // `APP_VERSION` directly until their respective follow-ups land.
1914
- // Nullable llm_usage cases (legacy rows from before migration 267 ran)
1915
- // fall back to the running binary's `APP_VERSION` rather than emitting
1916
- // explicit `null` — under the cloud contract a present-but-null
1917
- // per-event value would override the envelope, and we'd rather have a
1918
- // concrete version than no version.
1919
- // -------------------------------------------------------------------------
1920
-
1921
- test("llm_usage event carries its recorded assistantVersion on the wire", async () => {
1922
- const event = makeUsageEvent({
1923
- id: "evt-version-llm",
1924
- assistantVersion: "0.8.4",
1925
- });
1926
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
1927
- mockFetch.mockImplementation(() =>
1928
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1929
- );
1930
-
1931
- const reporter = makeReporter();
1932
- await reporter.flush();
1933
-
1934
- const body = JSON.parse(
1935
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1936
- );
1937
- // Envelope reflects current binary (upload-time).
1938
- expect(body.assistant_version).toBe("1.2.3-test");
1939
- // Per-event reflects record-time — a different value here is the
1940
- // whole point: backlogged events keep the binary they were stamped
1941
- // with when they were originally recorded.
1942
- expect(body.events[0].assistant_version).toBe("0.8.4");
1943
- });
1944
-
1945
- test("llm_usage event with null assistantVersion falls back to APP_VERSION (pre-migration row)", async () => {
1946
- const event = makeUsageEvent({
1947
- id: "evt-version-llm-null",
1948
- assistantVersion: null,
1949
- });
1950
- mockQueryUnreportedUsageEvents.mockReturnValue([event]);
1951
- mockFetch.mockImplementation(() =>
1952
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1953
- );
1954
-
1955
- const reporter = makeReporter();
1956
- await reporter.flush();
1957
-
1958
- const body = JSON.parse(
1959
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1960
- );
1961
- // No explicit null on the wire — the running binary's APP_VERSION is
1962
- // stamped instead so a present-but-null value can't override the
1963
- // envelope under the cloud's per-event-wins contract.
1964
- expect(body.events[0].assistant_version).toBe("1.2.3-test");
1965
- });
1966
-
1967
- test("turn event emits assistant_version: APP_VERSION (running binary)", async () => {
1968
- // Turn events are derived from `messages` + `conversations`, which
1969
- // don't yet carry a per-event version. Until the follow-up migration
1970
- // adds a column to `messages`, we stamp the running binary's
1971
- // APP_VERSION instead of explicit null — matches what the envelope
1972
- // would have provided but per-event so it survives the cloud
1973
- // contract that treats present per-event values as winning over the
1974
- // envelope.
1975
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
1976
- mockQueryUnreportedTurnEvents.mockReturnValue([
1977
- {
1978
- id: "evt-turn-version",
1979
- createdAt: 1700000300000,
1980
- conversationId: "conv-1",
1981
- conversationType: "standard",
1982
- turnIndex: 1,
1983
- interfaceId: null,
1984
- channelId: null,
1985
- clientMetadata: null,
1986
- },
1987
- ]);
1988
- mockFetch.mockImplementation(() =>
1989
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
1990
- );
1991
-
1992
- const reporter = makeReporter();
1993
- await reporter.flush();
1994
-
1995
- const body = JSON.parse(
1996
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
1997
- );
1998
- const turn = body.events.find((e: { type: string }) => e.type === "turn");
1999
- expect(turn).toBeDefined();
2000
- expect(turn.assistant_version).toBe("1.2.3-test");
2001
- });
2002
-
2003
- test("a single batch can carry mixed per-event versions", async () => {
2004
- // The whole point of this migration: an old backlogged event keeps
2005
- // its old version while a freshly recorded event in the same flush
2006
- // carries the current version. Pre-migration the entire batch would
2007
- // collapse to the envelope value.
2008
- const oldEvent = makeUsageEvent({
2009
- id: "evt-old",
2010
- assistantVersion: "0.8.3",
2011
- });
2012
- const newEvent = makeUsageEvent({
2013
- id: "evt-new",
2014
- assistantVersion: "0.8.5",
2015
- });
2016
- mockQueryUnreportedUsageEvents.mockReturnValue([oldEvent, newEvent]);
2017
- mockFetch.mockImplementation(() =>
2018
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2019
- );
2020
-
2021
- const reporter = makeReporter();
2022
- await reporter.flush();
2023
-
2024
- const body = JSON.parse(
2025
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2026
- );
2027
- const versions = body.events.map(
2028
- (e: { assistant_version: string | null }) => e.assistant_version,
2029
- );
2030
- expect(versions.sort()).toEqual(["0.8.3", "0.8.5"]);
2031
- // Envelope still reflects the running binary, not either event.
2032
- expect(body.assistant_version).toBe("1.2.3-test");
2033
- });
2034
-
2035
- // -------------------------------------------------------------------------
2036
- // Auth-fallback events
2037
- // -------------------------------------------------------------------------
2038
-
2039
- test("auth_fallback events are included in the events array with type discriminator", async () => {
2040
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2041
- recordAuthFallbackCounts(1700000740000, 1700000800000, [
2042
- {
2043
- guard: "edge",
2044
- path: "/v1/messages",
2045
- failureKind: "missing_authorization",
2046
- count: 42,
2047
- },
2048
- ]);
2049
- mockFetch.mockImplementation(() =>
2050
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2051
- );
2052
-
2053
- const reporter = makeReporter();
2054
- await reporter.flush();
2055
-
2056
- expect(mockFetch).toHaveBeenCalledTimes(1);
2057
- const body = JSON.parse(
2058
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2059
- );
2060
- expect(body.events.length).toBe(1);
2061
- expect(body.events[0]).toMatchObject({
2062
- type: "auth_fallback",
2063
- guard: "edge",
2064
- path: "/v1/messages",
2065
- failure_kind: "missing_authorization",
2066
- count: 42,
2067
- window_start: 1700000740000,
2068
- window_end: 1700000800000,
2069
- assistant_version: "1.2.3-test",
2070
- });
2071
- // recorded_at is the row's createdAt (stamped at record time).
2072
- expect(typeof body.events[0].recorded_at).toBe("number");
2073
- expect(typeof body.events[0].daemon_event_id).toBe("string");
2074
- });
2075
-
2076
- test("auth_fallback rows are deleted on success and never watermarked (ack mode)", async () => {
2077
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2078
- recordAuthFallbackCounts(1700000000000, 1700000001000, [
2079
- {
2080
- guard: "edge-scoped",
2081
- path: "/v1/a",
2082
- failureKind: "insufficient_scope",
2083
- count: 1,
2084
- },
2085
- {
2086
- guard: "edge-guardian",
2087
- path: "/v1/b",
2088
- failureKind: "guardian_mismatch",
2089
- count: 3,
2090
- },
2091
- ]);
2092
- mockFetch.mockImplementation(() =>
2093
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2094
- );
2095
- expect(queryTelemetryOutboxBatch("auth_fallback", 10)).toHaveLength(2);
2096
-
2097
- const reporter = makeReporter();
2098
- await reporter.flush();
2099
-
2100
- // Both rows shipped, were acknowledged (deleted), and no auth_fallback
2101
- // watermark was ever written — ack-mode sources bypass flush_checkpoints.
2102
- const body = JSON.parse(
2103
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2104
- );
2105
- expect(
2106
- body.events.filter((e: { type: string }) => e.type === "auth_fallback"),
2107
- ).toHaveLength(2);
2108
- expect(queryTelemetryOutboxBatch("auth_fallback", 10)).toHaveLength(0);
2109
- const authFallbackKeys = mockSetFlushCheckpoint.mock.calls.filter((c) =>
2110
- String(c[0]).startsWith("telemetry:auth_fallback:"),
2111
- );
2112
- expect(authFallbackKeys).toHaveLength(0);
2113
- });
2114
-
2115
- test("auth_fallback rows survive a failed POST and re-ship on the next flush", async () => {
2116
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2117
- recordAuthFallbackCounts(1700000000000, 1700000001000, [
2118
- {
2119
- guard: "edge",
2120
- path: "/v1/messages",
2121
- failureKind: "missing_authorization",
2122
- count: 9,
2123
- },
2124
- ]);
2125
- mockFetch.mockImplementation(() =>
2126
- Promise.resolve(new Response("server error", { status: 500 })),
2127
- );
2128
-
2129
- const reporter = makeReporter();
2130
- await reporter.flush();
2131
- expect(queryTelemetryOutboxBatch("auth_fallback", 10)).toHaveLength(1);
2132
-
2133
- mockFetch.mockImplementation(() =>
2134
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2135
- );
2136
- await reporter.flush();
2137
- expect(queryTelemetryOutboxBatch("auth_fallback", 10)).toHaveLength(0);
2138
- });
2139
-
2140
- // -------------------------------------------------------------------------
2141
- // Onboarding / activation funnel events
2142
- // -------------------------------------------------------------------------
2143
-
2144
- test("activation onboarding row flushes with funnel fields + deterministic daemon_event_id, then acks", async () => {
2145
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2146
- const sessionId = "sess-activation-1";
2147
- const stepName = "activation_moment_1_complete";
2148
- const recorded = recordActivationEvent({ stepName, sessionId });
2149
- expect(recorded).not.toBeNull();
2150
- mockFetch.mockImplementation(() =>
2151
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2152
- );
2153
-
2154
- const reporter = makeReporter();
2155
- await reporter.flush();
2156
-
2157
- expect(mockFetch).toHaveBeenCalledTimes(1);
2158
- const body = JSON.parse(
2159
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2160
- );
2161
- expect(body.events.length).toBe(1);
2162
- expect(body.events[0]).toMatchObject({
2163
- type: "onboarding",
2164
- session_id: sessionId,
2165
- step_name: stepName,
2166
- step_index: 1,
2167
- funnel_version: ACTIVATION_FUNNEL_VERSION,
2168
- daemon_event_id: buildActivationDaemonEventId(sessionId, stepName),
2169
- });
2170
- // The wire id is the deterministic activation id, distinct from the
2171
- // outbox row id (a UUID) the ack deletes by.
2172
- expect(body.events[0].daemon_event_id).not.toBe(recorded!.id);
2173
- expect(queryTelemetryOutboxBatch("onboarding", 10)).toHaveLength(0);
2174
- });
2175
-
2176
- test("queued activation row recorded under an older funnel version ships its frozen payload verbatim", async () => {
2177
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2178
- // The payload (including the deterministic daemon_event_id keyed on the
2179
- // OLD funnel version) was frozen at record time; a flush after a version
2180
- // bump must not restamp it with the new binary's constant.
2181
- const oldVersion = "activation_v0_2026_05";
2182
- expect(oldVersion).not.toBe(ACTIVATION_FUNNEL_VERSION);
2183
- const frozen: TelemetryEvent = {
2184
- type: "onboarding",
2185
- daemon_event_id: `${oldVersion}:sess-activation-old:activation_moment_1_complete`,
2186
- recorded_at: 1700000100000,
2187
- screen: "activation_moment_1_complete",
2188
- ab_variant: "variant-a",
2189
- session_id: "sess-activation-old",
2190
- step_name: "activation_moment_1_complete",
2191
- step_index: 1,
2192
- completed_at: "2026-05-01T00:00:00.000Z",
2193
- funnel_version: oldVersion,
2194
- assistant_version: "0.9.0-old",
2195
- };
2196
- insertTelemetryOutboxEvent({
2197
- id: "row-activation-old",
2198
- name: "onboarding",
2199
- // Row age drives the flush-cycle prune; only the payload is frozen-old.
2200
- createdAt: Date.now(),
2201
- event: frozen,
2202
- });
2203
- mockFetch.mockImplementation(() =>
2204
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2205
- );
2206
-
2207
- const reporter = makeReporter();
2208
- await reporter.flush();
2209
-
2210
- const body = JSON.parse(
2211
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2212
- );
2213
- expect(body.events).toEqual([frozen]);
2214
- });
2215
-
2216
- test("legacy onboarding row keeps daemon_event_id = row id and omits funnel fields", async () => {
2217
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2218
- const recorded = recordOnboardingEvent({
2219
- screen: "tools",
2220
- tools: ["calendar"],
2221
- });
2222
- expect(recorded).not.toBeNull();
2223
- mockFetch.mockImplementation(() =>
2224
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2225
- );
2226
-
2227
- const reporter = makeReporter();
2228
- await reporter.flush();
2229
-
2230
- expect(mockFetch).toHaveBeenCalledTimes(1);
2231
- const body = JSON.parse(
2232
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2233
- );
2234
- expect(body.events.length).toBe(1);
2235
- const e = body.events[0];
2236
- expect(e.type).toBe("onboarding");
2237
- expect(e.daemon_event_id).toBe(recorded!.id);
2238
- expect(e.tools).toEqual(["calendar"]);
2239
- expect(e.session_id).toBeUndefined();
2240
- expect(e.step_name).toBeUndefined();
2241
- expect(e.step_index).toBeUndefined();
2242
- expect(e.completed_at).toBeUndefined();
2243
- expect(e.funnel_version).toBeUndefined();
2244
- });
2245
-
2246
- // -------------------------------------------------------------------------
2247
- // Tool-executed events
2248
- // -------------------------------------------------------------------------
2249
-
2250
- test("tool_executed events project decision to status and never carry raw args/results", async () => {
2251
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2252
- seedToolInvocation({
2253
- id: "ti-ok",
2254
- createdAt: 1700000001000,
2255
- toolName: "calendar_list_events",
2256
- decision: "allow",
2257
- durationMs: 12,
2258
- argBytes: 42,
2259
- resultBytes: 9001,
2260
- provider: "anthropic",
2261
- model: "model-a",
2262
- inferenceProfile: "balanced",
2263
- inferenceProfileSource: "active",
2264
- });
2265
- // Errored invocation without LLM attribution — the attribution columns
2266
- // are null and must pass through as null.
2267
- seedToolInvocation({
2268
- id: "ti-err",
2269
- createdAt: 1700000002000,
2270
- toolName: "web_search",
2271
- decision: "error",
2272
- durationMs: 7,
2273
- argBytes: 17,
2274
- resultBytes: 33,
2275
- });
2276
- // Permission-denied rows are filtered in the store and must never ship.
2277
- seedToolInvocation({
2278
- id: "ti-denied",
2279
- createdAt: 1700000003000,
2280
- decision: "denied",
2281
- });
2282
- mockFetch.mockImplementation(() =>
2283
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2284
- );
2285
-
2286
- const reporter = makeReporter();
2287
- await reporter.flush();
2288
-
2289
- expect(mockFetch).toHaveBeenCalledTimes(1);
2290
- const rawBody = (mockFetch.mock.calls[0] as [string, RequestInit])[1]
2291
- .body as string;
2292
- // ToS/PII contract: raw tool args/outputs never leave the device.
2293
- expect(rawBody).not.toContain(TOOL_PII_SENTINEL);
2294
-
2295
- const body = JSON.parse(rawBody);
2296
- expect(body.events.length).toBe(2);
2297
-
2298
- const byId: Record<string, Record<string, unknown>> = {};
2299
- for (const e of body.events as Array<{ daemon_event_id: string }>) {
2300
- byId[e.daemon_event_id] = e;
2301
- }
2302
-
2303
- expect(byId["ti-ok"]).toEqual({
2304
- type: "tool_executed",
2305
- daemon_event_id: "ti-ok",
2306
- recorded_at: 1700000001000,
2307
- tool_name: "calendar_list_events",
2308
- status: "fulfilled",
2309
- duration_ms: 12,
2310
- arg_bytes: 42,
2311
- result_bytes: 9001,
2312
- conversation_id: TOOL_CONVERSATION_ID,
2313
- provider: "anthropic",
2314
- model: "model-a",
2315
- inference_profile: "balanced",
2316
- inference_profile_source: "active",
2317
- assistant_version: "1.2.3-test",
2318
- });
2319
- expect(byId["ti-err"]).toMatchObject({
2320
- type: "tool_executed",
2321
- tool_name: "web_search",
2322
- status: "errored",
2323
- duration_ms: 7,
2324
- arg_bytes: 17,
2325
- result_bytes: 33,
2326
- provider: null,
2327
- model: null,
2328
- inference_profile: null,
2329
- inference_profile_source: null,
2330
- });
2331
- expect(byId["ti-denied"]).toBeUndefined();
2332
- });
2333
-
2334
- test("tool_executed watermark advances to the last reported row on success", async () => {
2335
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2336
- seedToolInvocation({ id: "ti-w1", createdAt: 1700000001000 });
2337
- seedToolInvocation({ id: "ti-w2", createdAt: 1700000002000 });
2338
- mockFetch.mockImplementation(() =>
2339
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2340
- );
2341
-
2342
- const reporter = makeReporter();
2343
- await reporter.flush();
2344
-
2345
- const watermarkCalls = mockSetFlushCheckpoint.mock.calls.filter(
2346
- (c) => c[0] === "telemetry:tool_executed:last_reported_at",
2347
- );
2348
- expect(watermarkCalls.length).toBeGreaterThanOrEqual(1);
2349
- expect(watermarkCalls[watermarkCalls.length - 1][1]).toBe(
2350
- String(1700000002000),
2351
- );
2352
-
2353
- const idCalls = mockSetFlushCheckpoint.mock.calls.filter(
2354
- (c) => c[0] === "telemetry:tool_executed:last_reported_id",
2355
- );
2356
- expect(idCalls.length).toBeGreaterThanOrEqual(1);
2357
- expect(idCalls[idCalls.length - 1][1]).toBe("ti-w2");
2358
- });
2359
-
2360
- test("tool_executed resumes from a stored watermark — reported rows are not re-shipped", async () => {
2361
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2362
- seedToolInvocation({ id: "ti-r1", createdAt: 1700000001000 });
2363
- seedToolInvocation({ id: "ti-r2", createdAt: 1700000002000 });
2364
- // A previous flush already reported ti-r1.
2365
- mockGetFlushCheckpoint.mockImplementation((key) => {
2366
- if (key === "telemetry:tool_executed:last_reported_at") {
2367
- return String(1700000001000);
2368
- }
2369
- if (key === "telemetry:tool_executed:last_reported_id") {
2370
- return "ti-r1";
2371
- }
2372
- return null;
2373
- });
2374
- mockFetch.mockImplementation(() =>
2375
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2376
- );
2377
-
2378
- const reporter = makeReporter();
2379
- await reporter.flush();
2380
-
2381
- expect(mockFetch).toHaveBeenCalledTimes(1);
2382
- const body = JSON.parse(
2383
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2384
- );
2385
- expect(
2386
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2387
- ).toEqual(["ti-r2"]);
2388
- });
2389
-
2390
- test("rows recorded after construction but before the first flush are shipped", async () => {
2391
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2392
- // Regression coverage for the review finding: the reporter delays its
2393
- // first flush by 30s+, so a tool used right after daemon startup is
2394
- // recorded before any flush has run. The construction-time watermark
2395
- // init must not drop it — only rows from before construction (rows
2396
- // predating the reporter) stay behind the watermark.
2397
- const checkpoints = useStatefulCheckpoints();
2398
-
2399
- const reporter = makeReporter();
2400
- const rowCreatedAt =
2401
- Number(checkpoints.get("telemetry:tool_executed:last_reported_at")) +
2402
- 1000;
2403
- seedToolInvocation({ id: "ti-pre-first-flush", createdAt: rowCreatedAt });
2404
-
2405
- await reporter.flush();
2406
-
2407
- expect(mockFetch).toHaveBeenCalledTimes(1);
2408
- const body = JSON.parse(
2409
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2410
- );
2411
- expect(body.events.length).toBe(1);
2412
- expect(body.events[0]).toMatchObject({
2413
- type: "tool_executed",
2414
- daemon_event_id: "ti-pre-first-flush",
2415
- recorded_at: rowCreatedAt,
2416
- });
2417
-
2418
- // The watermark advances to the shipped row, so the next flush resumes
2419
- // after it instead of re-shipping.
2420
- expect(checkpoints.get("telemetry:tool_executed:last_reported_at")).toBe(
2421
- String(rowCreatedAt),
2422
- );
2423
- });
2424
-
2425
- test("absent tool_executed checkpoint is initialized at construction — rows predating the reporter never ship", async () => {
2426
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2427
- const checkpoints = useStatefulCheckpoints();
2428
-
2429
- // Rows accumulated before any flush ever advanced the watermark — e.g.
2430
- // an opt-out period under an older build that gated reporter
2431
- // construction on the usage-data opt-out while the always-on audit
2432
- // listener kept writing.
2433
- seedToolInvocation({
2434
- id: "ti-opt-out-window",
2435
- createdAt: Date.now() - 60_000,
2436
- });
2437
-
2438
- const reporter = makeReporter();
2439
-
2440
- // The checkpoint is persisted immediately at construction so a crash
2441
- // before the first flush can't re-initialize later.
2442
- const initialized = checkpoints.get(
2443
- "telemetry:tool_executed:last_reported_at",
2444
- );
2445
- expect(initialized).toBeDefined();
2446
-
2447
- // A tool that runs after construction ships normally.
2448
- seedToolInvocation({
2449
- id: "ti-post-construction",
2450
- createdAt: Number(initialized) + 1000,
2451
- });
2452
-
2453
- await reporter.flush();
2454
-
2455
- expect(mockFetch).toHaveBeenCalledTimes(1);
2456
- const body = JSON.parse(
2457
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2458
- );
2459
- expect(
2460
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2461
- ).toEqual(["ti-post-construction"]);
2462
- });
2463
-
2464
- test("existing tool_executed checkpoint is respected — construction does not re-initialize", async () => {
2465
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2466
- const checkpoints = useStatefulCheckpoints({
2467
- "telemetry:tool_executed:last_reported_at": String(1700000001000),
2468
- "telemetry:tool_executed:last_reported_id": "ti-already-reported",
2469
- });
2470
-
2471
- seedToolInvocation({
2472
- id: "ti-already-reported",
2473
- createdAt: 1700000001000,
2474
- });
2475
- // Legitimate backlog past the stored watermark — a re-initialization to
2476
- // Date.now() at construction would silently drop it. Rows past the
2477
- // watermark are always legitimately shippable: opted-out sessions keep
2478
- // the watermark advancing via the opt-out flush branch (the reporter
2479
- // runs even when collection is disabled), so the backlog can only hold
2480
- // opted-in rows.
2481
- seedToolInvocation({ id: "ti-backlog", createdAt: 1700000002000 });
2482
-
2483
- const reporter = makeReporter();
2484
- expect(checkpoints.get("telemetry:tool_executed:last_reported_at")).toBe(
2485
- String(1700000001000),
2486
- );
2487
-
2488
- await reporter.flush();
2489
-
2490
- expect(mockFetch).toHaveBeenCalledTimes(1);
2491
- const body = JSON.parse(
2492
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2493
- );
2494
- expect(
2495
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2496
- ).toEqual(["ti-backlog"]);
2497
- });
2498
-
2499
- test("opt-out window never ships across restarts — opted-out flushes keep the watermark ahead of audit rows", async () => {
2500
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2501
- // A previous opted-in session shipped through this watermark.
2502
- const checkpoints = useStatefulCheckpoints({
2503
- "telemetry:tool_executed:last_reported_at": String(1700000001000),
2504
- "telemetry:tool_executed:last_reported_id": "ti-shipped-opted-in",
2505
- });
2506
-
2507
- // Session 1: the user opted out and restarted. The daemon still
2508
- // constructs and runs the reporter; the always-on audit listener keeps
2509
- // writing rows. Every opted-out flush (5-minute cycle plus the final
2510
- // flush in stop()) advances the watermark past them without sending.
2511
- mockGetRawShareAnalytics.mockReturnValue(false);
2512
- const optOutRowCreatedAt = Date.now() - 5_000;
2513
- seedToolInvocation({
2514
- id: "ti-opt-out-window",
2515
- createdAt: optOutRowCreatedAt,
2516
- });
2517
- const optedOutReporter = makeReporter();
2518
- await optedOutReporter.stop(); // shutdown path: runs the final flush
2519
- expect(mockFetch).not.toHaveBeenCalled();
2520
- const advanced = Number(
2521
- checkpoints.get("telemetry:tool_executed:last_reported_at"),
2522
- );
2523
- expect(advanced).toBeGreaterThan(optOutRowCreatedAt);
2524
-
2525
- // Session 2: the user opts back in and restarts. Only rows recorded
2526
- // after the opt-out epoch ship — the opt-out-window row never does.
2527
- mockGetRawShareAnalytics.mockReturnValue(true);
2528
- seedToolInvocation({ id: "ti-after-opt-in", createdAt: advanced + 1000 });
2529
- const reporter = makeReporter();
2530
- await reporter.flush();
2531
-
2532
- expect(mockFetch).toHaveBeenCalledTimes(1);
2533
- const body = JSON.parse(
2534
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2535
- );
2536
- expect(
2537
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2538
- ).toEqual(["ti-after-opt-in"]);
2539
- });
2540
-
2541
- test("opt-out row written in the same millisecond as the opt-out flush never ships after re-opt-in", async () => {
2542
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2543
- // A previous opted-in session left a low-sorting ID watermark behind.
2544
- const checkpoints = useStatefulCheckpoints({
2545
- "telemetry:tool_executed:last_reported_at": String(1700000001000),
2546
- "telemetry:tool_executed:last_reported_id":
2547
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
2548
- });
2549
-
2550
- // Opted-out flush: advances the timestamp watermark to Date.now() and
2551
- // must also pin the ID watermark to the high-sorting sentinel.
2552
- mockGetRawShareAnalytics.mockReturnValue(false);
2553
- const optedOutReporter = makeReporter();
2554
- await optedOutReporter.flush();
2555
- expect(mockFetch).not.toHaveBeenCalled();
2556
- const watermark = Number(
2557
- checkpoints.get("telemetry:tool_executed:last_reported_at"),
2558
- );
2559
-
2560
- // An audit row written in the SAME millisecond as the opt-out flush's
2561
- // Date.now(), with a UUID sorting above the stale pre-opt-out ID
2562
- // watermark. With only the timestamp watermark advanced, the compound
2563
- // cursor's `createdAt == watermark AND id > afterId` arm would match it.
2564
- seedToolInvocation({
2565
- id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
2566
- createdAt: watermark,
2567
- });
2568
-
2569
- // Re-opt-in: only rows strictly after the opt-out epoch ship.
2570
- mockGetRawShareAnalytics.mockReturnValue(true);
2571
- seedToolInvocation({ id: "ti-after-opt-in", createdAt: watermark + 1000 });
2572
- const reporter = makeReporter();
2573
- await reporter.flush();
2574
-
2575
- expect(mockFetch).toHaveBeenCalledTimes(1);
2576
- const body = JSON.parse(
2577
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2578
- );
2579
- expect(
2580
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2581
- ).toEqual(["ti-after-opt-in"]);
2582
- });
2583
-
2584
- test("a turn-only (daemon) reporter never touches the tool_executed watermark", () => {
2585
- new UsageTelemetryReporter(
2586
- DAEMON_TELEMETRY_EVENT_SOURCES,
2587
- fakeFlushCheckpointStore,
2588
- );
2589
- expect(mockGetFlushCheckpoint).not.toHaveBeenCalled();
2590
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
2591
- });
2592
-
2593
- test("a monitor reporter re-runs the guarded tool_executed watermark init as a backstop", () => {
2594
- new UsageTelemetryReporter(
2595
- MONITOR_TELEMETRY_EVENT_SOURCES,
2596
- fakeFlushCheckpointStore,
2597
- );
2598
- expect(mockGetFlushCheckpoint).toHaveBeenCalledWith(
2599
- "telemetry:tool_executed:last_reported_at",
2600
- );
2601
- expect(mockSetFlushCheckpoint).toHaveBeenCalledTimes(1);
2602
- });
2603
-
2604
- test("initToolExecutedWatermarkIfAbsent sets the epoch once and never overwrites", () => {
2605
- const checkpoints = useStatefulCheckpoints();
2606
- initToolExecutedWatermarkIfAbsent(fakeFlushCheckpointStore);
2607
- const epoch = checkpoints.get("telemetry:tool_executed:last_reported_at");
2608
- expect(epoch).toBeString();
2609
-
2610
- initToolExecutedWatermarkIfAbsent(fakeFlushCheckpointStore);
2611
- expect(checkpoints.get("telemetry:tool_executed:last_reported_at")).toBe(
2612
- epoch!,
2613
- );
2614
-
2615
- // A store failure is non-fatal (degraded-DB daemons still start).
2616
- mockGetFlushCheckpoint.mockImplementation(() => {
2617
- throw new Error("database disk image is malformed");
2618
- });
2619
- expect(() =>
2620
- initToolExecutedWatermarkIfAbsent(fakeFlushCheckpointStore),
2621
- ).not.toThrow();
2622
- });
2623
-
2624
- test("checkpoint store failure during construction is non-fatal — degraded-DB daemons still start", () => {
2625
- // initializeDb() failures are tolerated at daemon startup (degraded
2626
- // mode), so the constructor's checkpoint init must never throw out of
2627
- // the constructor and abort the daemon.
2628
- mockGetFlushCheckpoint.mockImplementation(() => {
2629
- throw new Error("database disk image is malformed");
2630
- });
2631
- expect(() => makeReporter()).not.toThrow();
2632
-
2633
- // The write path failing is equally non-fatal.
2634
- mockGetFlushCheckpoint.mockImplementation(() => null);
2635
- mockSetFlushCheckpoint.mockImplementation(() => {
2636
- throw new Error("database disk image is malformed");
2637
- });
2638
- expect(() => makeReporter()).not.toThrow();
2639
- });
2640
-
2641
- // -------------------------------------------------------------------------
2642
- // Skill-loaded events
2643
- // -------------------------------------------------------------------------
2644
-
2645
- test("skill_loaded events ship metadata with attribution and null passthrough", async () => {
2646
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2647
- recordSkillLoadedEvent({
2648
- conversationId: "conv-skill",
2649
- skillName: "web-research",
2650
- skillUpdatedAt: "2026-06-01T00:00:00.000Z",
2651
- provider: "anthropic",
2652
- model: "model-a",
2653
- inferenceProfile: "balanced",
2654
- inferenceProfileSource: "active",
2655
- });
2656
- // Minimal record — optional fields persist as null and ship as null.
2657
- recordSkillLoadedEvent({ skillName: "tasks" });
2658
- mockFetch.mockImplementation(() =>
2659
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2660
- );
2661
-
2662
- const reporter = makeReporter();
2663
- await reporter.flush();
2664
-
2665
- expect(mockFetch).toHaveBeenCalledTimes(1);
2666
- const body = JSON.parse(
2667
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2668
- );
2669
- expect(body.events.length).toBe(2);
2670
-
2671
- const byName: Record<string, Record<string, unknown>> = {};
2672
- for (const e of body.events as Array<{ skill_name: string }>) {
2673
- byName[e.skill_name] = e;
2674
- }
2675
-
2676
- expect(byName["web-research"]).toMatchObject({
2677
- type: "skill_loaded",
2678
- skill_name: "web-research",
2679
- skill_updated_at: "2026-06-01T00:00:00.000Z",
2680
- conversation_id: "conv-skill",
2681
- provider: "anthropic",
2682
- model: "model-a",
2683
- inference_profile: "balanced",
2684
- inference_profile_source: "active",
2685
- assistant_version: "1.2.3-test",
2686
- });
2687
- expect(typeof byName["web-research"].daemon_event_id).toBe("string");
2688
- expect(typeof byName["web-research"].recorded_at).toBe("number");
2689
- expect(byName["tasks"]).toMatchObject({
2690
- type: "skill_loaded",
2691
- skill_name: "tasks",
2692
- skill_updated_at: null,
2693
- conversation_id: null,
2694
- provider: null,
2695
- model: null,
2696
- inference_profile: null,
2697
- inference_profile_source: null,
2698
- });
2699
- });
2700
-
2701
- test("skill_loaded rows are deleted from the outbox after a 2xx, never via watermarks", async () => {
2702
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2703
- recordSkillLoadedEvent({ skillName: "web-research" });
2704
- recordSkillLoadedEvent({ skillName: "tasks" });
2705
- expect(queryTelemetryOutboxBatch("skill_loaded", 10)).toHaveLength(2);
2706
- mockFetch.mockImplementation(() =>
2707
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2708
- );
2709
-
2710
- const reporter = makeReporter();
2711
- await reporter.flush();
2712
-
2713
- expect(mockFetch).toHaveBeenCalledTimes(1);
2714
- expect(queryTelemetryOutboxBatch("skill_loaded", 10)).toHaveLength(0);
2715
- // Ack-mode: the skill_loaded source never touches flush_checkpoints.
2716
- expect(
2717
- mockSetFlushCheckpoint.mock.calls.some((c) =>
2718
- c[0].includes("skill_loaded"),
2719
- ),
2720
- ).toBe(false);
2721
- });
2722
-
2723
- test("a full skill_loaded batch drives recursion until the outbox drains", async () => {
2724
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2725
- // BATCH_SIZE (500) + 1 rows: the first flush ships and deletes a full
2726
- // batch, and the fullBatch recursion ships the remaining row.
2727
- for (let i = 0; i < 501; i++) {
2728
- recordSkillLoadedEvent({ skillName: `skill-${i}` });
2729
- }
2730
- mockFetch.mockImplementation(() =>
2731
- Promise.resolve(new Response('{"accepted":500}', { status: 200 })),
2732
- );
2733
-
2734
- const reporter = makeReporter();
2735
- await reporter.flush();
2736
-
2737
- expect(mockFetch).toHaveBeenCalledTimes(2);
2738
- expect(queryTelemetryOutboxBatch("skill_loaded", 1000)).toHaveLength(0);
2739
- });
2740
-
2741
- // -------------------------------------------------------------------------
2742
- // Config-setting events
2743
- // -------------------------------------------------------------------------
2744
-
2745
- test("config_setting events ship the key/value pair on the standard envelope", async () => {
2746
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2747
- recordConfigSettingEvent({
2748
- configKey: "memory.enabled",
2749
- configValue: "true",
2750
- });
2751
- recordConfigSettingEvent({
2752
- configKey: "memory.v2.enabled",
2753
- configValue: "false",
2754
- });
2755
- mockFetch.mockImplementation(() =>
2756
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2757
- );
2758
-
2759
- const reporter = makeReporter();
2760
- await reporter.flush();
2761
-
2762
- expect(mockFetch).toHaveBeenCalledTimes(1);
2763
- const body = JSON.parse(
2764
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2765
- );
2766
- expect(body.events.length).toBe(2);
2767
-
2768
- const byKey: Record<string, Record<string, unknown>> = {};
2769
- for (const e of body.events as Array<{ config_key: string }>) {
2770
- byKey[e.config_key] = e;
2771
- }
2772
-
2773
- expect(byKey["memory.enabled"]).toMatchObject({
2774
- type: "config_setting",
2775
- config_key: "memory.enabled",
2776
- config_value: "true",
2777
- assistant_version: "1.2.3-test",
2778
- });
2779
- expect(typeof byKey["memory.enabled"].daemon_event_id).toBe("string");
2780
- expect(typeof byKey["memory.enabled"].recorded_at).toBe("number");
2781
- expect(byKey["memory.v2.enabled"]).toMatchObject({
2782
- type: "config_setting",
2783
- config_key: "memory.v2.enabled",
2784
- config_value: "false",
2785
- });
2786
- });
2787
-
2788
- test("config_setting rows are deleted on success and no watermark is written", async () => {
2789
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2790
- recordConfigSettingEvent({
2791
- configKey: "memory.enabled",
2792
- configValue: "true",
2793
- });
2794
- recordConfigSettingEvent({
2795
- configKey: "memory.v2.enabled",
2796
- configValue: "true",
2797
- });
2798
- expect(queryTelemetryOutboxBatch("config_setting", 10)).toHaveLength(2);
2799
- mockFetch.mockImplementation(() =>
2800
- Promise.resolve(new Response('{"accepted":2}', { status: 200 })),
2801
- );
2802
-
2803
- const reporter = makeReporter();
2804
- await reporter.flush();
2805
-
2806
- // Ack-mode: acknowledged rows are deleted from the outbox instead of
2807
- // advancing a `flush_checkpoints` watermark.
2808
- expect(queryTelemetryOutboxBatch("config_setting", 10)).toHaveLength(0);
2809
- const watermarkKeys = mockSetFlushCheckpoint.mock.calls
2810
- .map((c) => c[0] as string)
2811
- .filter((key) => key.startsWith("telemetry:config_setting:"));
2812
- expect(watermarkKeys).toHaveLength(0);
2813
- });
2814
-
2815
- test("config_setting rows survive a failed POST for the next flush", async () => {
2816
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2817
- recordConfigSettingEvent({
2818
- configKey: "memory.enabled",
2819
- configValue: "true",
2820
- });
2821
- mockFetch.mockImplementation(() =>
2822
- Promise.resolve(new Response("error", { status: 500 })),
2823
- );
2824
-
2825
- const reporter = makeReporter();
2826
- await reporter.flush();
2827
-
2828
- expect(queryTelemetryOutboxBatch("config_setting", 10)).toHaveLength(1);
2829
- });
2830
-
2831
- // -------------------------------------------------------------------------
2832
- // Watchdog events
2833
- // -------------------------------------------------------------------------
2834
-
2835
- test("watchdog events ship the record-time payload with nested detail, then delete", async () => {
2836
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
2837
- recordWatchdogEvent({
2838
- checkName: "event_loop_blocked",
2839
- value: 60000,
2840
- detail: { reason: "no_bytes_60s", threshold_ms: 5000 },
2841
- });
2842
- mockFetch.mockImplementation(() =>
2843
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
2844
- );
2845
-
2846
- const reporter = makeReporter();
2847
- await reporter.flush();
2848
-
2849
- expect(mockFetch).toHaveBeenCalledTimes(1);
2850
- const body = JSON.parse(
2851
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2852
- );
2853
- expect(body.events).toHaveLength(1);
2854
- expect(body.events[0]).toMatchObject({
2855
- type: "watchdog",
2856
- check_name: "event_loop_blocked",
2857
- value: 60000,
2858
- detail: { reason: "no_bytes_60s", threshold_ms: 5000 },
2859
- assistant_version: "1.2.3-test",
2860
- });
2861
- expect(typeof body.events[0].daemon_event_id).toBe("string");
2862
- expect(typeof body.events[0].recorded_at).toBe("number");
2863
- expect(queryTelemetryOutboxBatch("watchdog", 10)).toHaveLength(0);
2864
- });
2865
-
2866
- // -------------------------------------------------------------------------
2867
- // Ack-mode sources (grouped `ack` field: acknowledge + discardPending)
2868
- // -------------------------------------------------------------------------
2869
-
2870
- // Wire id deliberately differs from the row id: acknowledge must receive
2871
- // ROW ids, never wire daemon_event_ids.
2872
- function fakeWireEvent(rowId: string, createdAt: number): TelemetryEvent {
2873
- return {
2874
- type: "lifecycle",
2875
- daemon_event_id: `wire-${rowId}`,
2876
- event_name: "fake_event",
2877
- recorded_at: createdAt,
2878
- assistant_version: "1.2.3-test",
2879
- };
2880
- }
2881
-
2882
- /**
2883
- * Ack-mode fake backed by a mutable in-memory row array: `collect` ignores
2884
- * the cursor args, `acknowledge` deletes the acknowledged rows, and
2885
- * `discardPending` drops everything — mirroring the outbox-store contract.
2886
- */
2887
- function makeAckSource(id: string, rowIds: string[] = []) {
2888
- let rows = rowIds.map((rowId, i) => ({
2889
- rowId,
2890
- createdAt: 1700000000000 + i,
2891
- }));
2892
- const acknowledge = mock((acked: string[]) => {
2893
- rows = rows.filter((r) => !acked.includes(r.rowId));
2894
- });
2895
- const discardPending = mock(() => {
2896
- rows = [];
2897
- });
2898
- const source: TelemetryEventSource = {
2899
- id,
2900
- collect(_afterCreatedAt, _afterId, limit) {
2901
- const batch = rows.slice(0, limit);
2902
- return {
2903
- events: batch.map((r) => fakeWireEvent(r.rowId, r.createdAt)),
2904
- rowIds: batch.map((r) => r.rowId),
2905
- lastCursor: null,
2906
- fullBatch: batch.length === limit,
2907
- };
2908
- },
2909
- ack: { acknowledge, discardPending },
2910
- };
2911
- return { source, acknowledge, discardPending, pending: () => rows.length };
2912
- }
2913
-
2914
- function makeWatermarkSource(
2915
- id: string,
2916
- rowIds: string[] = [],
2917
- ): TelemetryEventSource {
2918
- const rows = rowIds.map((rowId, i) => ({
2919
- rowId,
2920
- createdAt: 1700000000000 + i,
2921
- }));
2922
- return {
2923
- id,
2924
- collect(_afterCreatedAt, _afterId, limit) {
2925
- const batch = rows.slice(0, limit);
2926
- const last = batch.length > 0 ? batch[batch.length - 1] : null;
2927
- return {
2928
- events: batch.map((r) => fakeWireEvent(r.rowId, r.createdAt)),
2929
- lastCursor: last
2930
- ? { createdAt: last.createdAt, id: last.rowId }
2931
- : null,
2932
- fullBatch: batch.length === limit,
2933
- };
2934
- },
2935
- };
2936
- }
2937
-
2938
- test("ack-mode source: acknowledge receives ROW ids after a 2xx and no watermark keys are written", async () => {
2939
- const { source, acknowledge } = makeAckSource("fake_ack", [
2940
- "row-1",
2941
- "row-2",
2942
- ]);
2943
- const reporter = new UsageTelemetryReporter(
2944
- [source],
2945
- fakeFlushCheckpointStore,
2946
- );
2947
- await reporter.flush();
2948
-
2949
- expect(mockFetch).toHaveBeenCalledTimes(1);
2950
- const body = JSON.parse(
2951
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
2952
- );
2953
- expect(
2954
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
2955
- ).toEqual(["wire-row-1", "wire-row-2"]);
2956
- expect(acknowledge).toHaveBeenCalledTimes(1);
2957
- expect(acknowledge.mock.calls[0][0]).toEqual(["row-1", "row-2"]);
2958
- // Ack-mode sources never touch flush_checkpoints.
2959
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
2960
- });
2961
-
2962
- test("acknowledge is NOT called when the POST fails", async () => {
2963
- mockFetch.mockImplementation(() =>
2964
- Promise.resolve(new Response("error", { status: 500 })),
2965
- );
2966
- const { source, acknowledge, pending } = makeAckSource("fake_ack", [
2967
- "row-1",
2968
- ]);
2969
- const reporter = new UsageTelemetryReporter(
2970
- [source],
2971
- fakeFlushCheckpointStore,
2972
- );
2973
- await reporter.flush();
2974
-
2975
- expect(acknowledge).not.toHaveBeenCalled();
2976
- expect(pending()).toBe(1);
2977
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
2978
- });
2979
-
2980
- test("acknowledge is NOT called when platform credentials are missing", async () => {
2981
- mockCloudClient = null;
2982
- const { source, acknowledge, pending } = makeAckSource("fake_ack", [
2983
- "row-1",
2984
- ]);
2985
- const reporter = new UsageTelemetryReporter(
2986
- [source],
2987
- fakeFlushCheckpointStore,
2988
- );
2989
- await reporter.flush();
2990
-
2991
- expect(mockFetch).not.toHaveBeenCalled();
2992
- expect(acknowledge).not.toHaveBeenCalled();
2993
- expect(pending()).toBe(1);
2994
- });
2995
-
2996
- test("acknowledge is skipped for an empty ack batch shipped alongside a watermark source", async () => {
2997
- const { source: ackSource, acknowledge } = makeAckSource("fake_ack");
2998
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1"]);
2999
- const reporter = new UsageTelemetryReporter(
3000
- [wmSource, ackSource],
3001
- fakeFlushCheckpointStore,
3002
- );
3003
- await reporter.flush();
3004
-
3005
- expect(mockFetch).toHaveBeenCalledTimes(1);
3006
- expect(acknowledge).not.toHaveBeenCalled();
3007
- });
3008
-
3009
- test("opt-out: discardPending drops ack-mode rows while watermark sources get the sentinel pin", async () => {
3010
- mockGetRawShareAnalytics.mockReturnValue(false);
3011
- const {
3012
- source: ackSource,
3013
- discardPending,
3014
- pending,
3015
- } = makeAckSource("fake_ack", ["row-1"]);
3016
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1"]);
3017
- const reporter = new UsageTelemetryReporter(
3018
- [wmSource, ackSource],
3019
- fakeFlushCheckpointStore,
3020
- );
3021
- await reporter.flush();
3022
-
3023
- expect(mockFetch).not.toHaveBeenCalled();
3024
- expect(discardPending).toHaveBeenCalledTimes(1);
3025
- expect(pending()).toBe(0);
3026
-
3027
- const keys = mockSetFlushCheckpoint.mock.calls.map((c) => c[0]);
3028
- expect(keys).toContain("telemetry:fake_wm:last_reported_at");
3029
- const wmIdCall = mockSetFlushCheckpoint.mock.calls.find(
3030
- (c) => c[0] === "telemetry:fake_wm:last_reported_id",
3031
- );
3032
- expect(wmIdCall?.[1]).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff");
3033
- expect(keys.some((k) => k.includes("fake_ack"))).toBe(false);
3034
- });
3035
-
3036
- test("unknown consent: nothing is discarded, acked, or advanced — rows wait for a confirmed state", async () => {
3037
- mockGetRawShareAnalytics.mockReturnValue("unknown");
3038
- const {
3039
- source: ackSource,
3040
- acknowledge,
3041
- discardPending,
3042
- pending,
3043
- } = makeAckSource("fake_ack", ["row-1"]);
3044
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1"]);
3045
- const reporter = new UsageTelemetryReporter(
3046
- [wmSource, ackSource],
3047
- fakeFlushCheckpointStore,
3048
- );
3049
- await reporter.flush();
3050
-
3051
- expect(mockFetch).not.toHaveBeenCalled();
3052
- expect(discardPending).not.toHaveBeenCalled();
3053
- expect(acknowledge).not.toHaveBeenCalled();
3054
- expect(pending()).toBe(1);
3055
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3056
- });
3057
-
3058
- test("mixed watermark+ack sources flush in construction order, each acknowledged in its own mode", async () => {
3059
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1", "wm-2"]);
3060
- const { source: ackSource, acknowledge } = makeAckSource("fake_ack", [
3061
- "row-1",
3062
- ]);
3063
- const reporter = new UsageTelemetryReporter(
3064
- [wmSource, ackSource],
3065
- fakeFlushCheckpointStore,
3066
- );
3067
- await reporter.flush();
3068
-
3069
- expect(mockFetch).toHaveBeenCalledTimes(1);
3070
- const body = JSON.parse(
3071
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3072
- );
3073
- expect(
3074
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3075
- ).toEqual(["wire-wm-1", "wire-wm-2", "wire-row-1"]);
3076
-
3077
- expect(acknowledge).toHaveBeenCalledTimes(1);
3078
- expect(acknowledge.mock.calls[0][0]).toEqual(["row-1"]);
3079
-
3080
- const wmAtCalls = mockSetFlushCheckpoint.mock.calls.filter(
3081
- (c) => c[0] === "telemetry:fake_wm:last_reported_at",
3082
- );
3083
- expect(wmAtCalls[wmAtCalls.length - 1][1]).toBe(String(1700000000001));
3084
- const wmIdCalls = mockSetFlushCheckpoint.mock.calls.filter(
3085
- (c) => c[0] === "telemetry:fake_wm:last_reported_id",
3086
- );
3087
- expect(wmIdCalls[wmIdCalls.length - 1][1]).toBe("wm-2");
3088
- expect(
3089
- mockSetFlushCheckpoint.mock.calls.some((c) => c[0].includes("fake_ack")),
3090
- ).toBe(false);
3091
- });
3092
-
3093
- test("an ack-mode full batch drives recursion until the backlog drains", async () => {
3094
- // 501 rows: the first collect fills BATCH_SIZE (500), acknowledge drains
3095
- // them, and the fullBatch recursion ships the remaining row.
3096
- const rowIds = Array.from({ length: 501 }, (_, i) => `row-${i}`);
3097
- const { source, acknowledge, pending } = makeAckSource("fake_ack", rowIds);
3098
- const reporter = new UsageTelemetryReporter(
3099
- [source],
3100
- fakeFlushCheckpointStore,
3101
- );
3102
- await reporter.flush();
3103
-
3104
- expect(mockFetch).toHaveBeenCalledTimes(2);
3105
- expect(acknowledge).toHaveBeenCalledTimes(2);
3106
- expect(acknowledge.mock.calls[0][0]).toHaveLength(500);
3107
- expect(acknowledge.mock.calls[1][0]).toEqual(["row-500"]);
3108
- expect(pending()).toBe(0);
3109
- });
3110
-
3111
- /** Ack source whose collect returns events with broken rowIds. */
3112
- function makeBrokenAckSource(id: string, rowIds: string[] | undefined) {
3113
- const acknowledge = mock((_acked: string[]) => {});
3114
- const discardPending = mock(() => {});
3115
- const source: TelemetryEventSource = {
3116
- id,
3117
- collect() {
3118
- return {
3119
- events: [
3120
- fakeWireEvent("row-1", 1700000000000),
3121
- fakeWireEvent("row-2", 1700000000001),
3122
- ],
3123
- ...(rowIds ? { rowIds } : {}),
3124
- lastCursor: null,
3125
- fullBatch: false,
3126
- };
3127
- },
3128
- ack: { acknowledge, discardPending },
3129
- };
3130
- return { source, acknowledge };
3131
- }
3132
-
3133
- test("an ack batch omitting rowIds ships nothing while other sources still ship", async () => {
3134
- const { source: brokenSource, acknowledge } = makeBrokenAckSource(
3135
- "fake_broken_ack",
3136
- undefined,
3137
- );
3138
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1"]);
3139
- const reporter = new UsageTelemetryReporter(
3140
- [brokenSource, wmSource],
3141
- fakeFlushCheckpointStore,
3142
- );
3143
- await reporter.flush();
3144
-
3145
- expect(mockFetch).toHaveBeenCalledTimes(1);
3146
- const body = JSON.parse(
3147
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3148
- );
3149
- expect(
3150
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3151
- ).toEqual(["wire-wm-1"]);
3152
- expect(acknowledge).not.toHaveBeenCalled();
3153
- expect(
3154
- mockSetFlushCheckpoint.mock.calls.some((c) =>
3155
- c[0].includes("fake_broken_ack"),
3156
- ),
3157
- ).toBe(false);
3158
- });
3159
-
3160
- test("an ack batch with mismatched rowIds ships nothing while other sources still ship", async () => {
3161
- const { source: brokenSource, acknowledge } = makeBrokenAckSource(
3162
- "fake_broken_ack",
3163
- ["row-1"],
3164
- );
3165
- const wmSource = makeWatermarkSource("fake_wm", ["wm-1"]);
3166
- const reporter = new UsageTelemetryReporter(
3167
- [brokenSource, wmSource],
3168
- fakeFlushCheckpointStore,
3169
- );
3170
- await reporter.flush();
3171
-
3172
- expect(mockFetch).toHaveBeenCalledTimes(1);
3173
- const body = JSON.parse(
3174
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3175
- );
3176
- expect(
3177
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3178
- ).toEqual(["wire-wm-1"]);
3179
- expect(acknowledge).not.toHaveBeenCalled();
3180
- });
3181
-
3182
- // -------------------------------------------------------------------------
3183
- // outboxSource (real telemetry_events outbox behind an ack-mode source)
3184
- // -------------------------------------------------------------------------
3185
-
3186
- // Synthetic name: these tests exercise the generic outbox machinery, not a
3187
- // shipped event type.
3188
- const OUTBOX_NAME = "test_outbox" as OutboxTelemetryEventName;
3189
-
3190
- // Recent base for row timestamps: the flush cycle prunes outbox rows older
3191
- // than OUTBOX_MAX_ROW_AGE_MS, so rows that must ship are seeded fresh.
3192
- const OUTBOX_BASE = Date.now() - 60_000;
3193
-
3194
- function seedOutboxRow(id: string, createdAt: number): void {
3195
- insertTelemetryOutboxEvent({
3196
- id,
3197
- name: OUTBOX_NAME,
3198
- createdAt,
3199
- event: fakeWireEvent(id, createdAt),
3200
- });
3201
- }
3202
-
3203
- function pendingOutboxIds(): string[] {
3204
- return queryTelemetryOutboxBatch(OUTBOX_NAME, 1000).map((r) => r.id);
3205
- }
3206
-
3207
- test("outboxSource ships pending rows and deletes them after a 2xx", async () => {
3208
- seedOutboxRow("ob-1", OUTBOX_BASE + 1000);
3209
- seedOutboxRow("ob-2", OUTBOX_BASE + 2000);
3210
- const reporter = new UsageTelemetryReporter(
3211
- [outboxSource(OUTBOX_NAME)],
3212
- fakeFlushCheckpointStore,
3213
- );
3214
- await reporter.flush();
3215
-
3216
- expect(mockFetch).toHaveBeenCalledTimes(1);
3217
- const body = JSON.parse(
3218
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3219
- );
3220
- expect(
3221
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3222
- ).toEqual(["wire-ob-1", "wire-ob-2"]);
3223
- expect(pendingOutboxIds()).toEqual([]);
3224
- // Ack-mode: flush_checkpoints untouched.
3225
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3226
- });
3227
-
3228
- test("outboxSource leaves rows intact when the POST fails", async () => {
3229
- mockFetch.mockImplementation(() =>
3230
- Promise.resolve(new Response("error", { status: 500 })),
3231
- );
3232
- seedOutboxRow("ob-1", OUTBOX_BASE + 1000);
3233
- const reporter = new UsageTelemetryReporter(
3234
- [outboxSource(OUTBOX_NAME)],
3235
- fakeFlushCheckpointStore,
3236
- );
3237
- await reporter.flush();
3238
-
3239
- expect(pendingOutboxIds()).toEqual(["ob-1"]);
3240
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3241
- });
3242
-
3243
- test("outboxSource opt-out discards all pending rows for the name", async () => {
3244
- mockGetRawShareAnalytics.mockReturnValue(false);
3245
- seedOutboxRow("ob-1", OUTBOX_BASE + 1000);
3246
- seedOutboxRow("ob-2", OUTBOX_BASE + 2000);
3247
- const reporter = new UsageTelemetryReporter(
3248
- [outboxSource(OUTBOX_NAME)],
3249
- fakeFlushCheckpointStore,
3250
- );
3251
- await reporter.flush();
3252
-
3253
- expect(mockFetch).not.toHaveBeenCalled();
3254
- expect(pendingOutboxIds()).toEqual([]);
3255
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3256
- });
3257
-
3258
- test("outboxSource unknown consent retains all pending rows", async () => {
3259
- mockGetRawShareAnalytics.mockReturnValue("unknown");
3260
- seedOutboxRow("ob-1", OUTBOX_BASE + 1000);
3261
- seedOutboxRow("ob-2", OUTBOX_BASE + 2000);
3262
- const reporter = new UsageTelemetryReporter(
3263
- [outboxSource(OUTBOX_NAME)],
3264
- fakeFlushCheckpointStore,
3265
- );
3266
- await reporter.flush();
3267
-
3268
- expect(mockFetch).not.toHaveBeenCalled();
3269
- expect(pendingOutboxIds()).toEqual(["ob-1", "ob-2"]);
3270
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3271
- });
3272
-
3273
- // -------------------------------------------------------------------------
3274
- // Flush-cycle age prune (bounds unknown-consent buffering)
3275
- // -------------------------------------------------------------------------
3276
-
3277
- function seedExpiredAndFreshRows(): void {
3278
- seedOutboxRow("ob-expired", Date.now() - OUTBOX_MAX_ROW_AGE_MS - 60_000);
3279
- seedOutboxRow("ob-fresh", OUTBOX_BASE);
3280
- }
3281
-
3282
- test("opted in: expired rows are pruned rather than shipped; fresh rows ship", async () => {
3283
- seedExpiredAndFreshRows();
3284
- const reporter = new UsageTelemetryReporter(
3285
- [outboxSource(OUTBOX_NAME)],
3286
- fakeFlushCheckpointStore,
3287
- );
3288
- await reporter.flush();
3289
-
3290
- expect(mockFetch).toHaveBeenCalledTimes(1);
3291
- const body = JSON.parse(
3292
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3293
- );
3294
- expect(
3295
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3296
- ).toEqual(["wire-ob-fresh"]);
3297
- expect(pendingOutboxIds()).toEqual([]);
3298
- });
3299
-
3300
- test("unknown consent: expired rows are pruned before the deferral; fresh rows are retained", async () => {
3301
- mockGetRawShareAnalytics.mockReturnValue("unknown");
3302
- seedExpiredAndFreshRows();
3303
- const reporter = new UsageTelemetryReporter(
3304
- [outboxSource(OUTBOX_NAME)],
3305
- fakeFlushCheckpointStore,
3306
- );
3307
- await reporter.flush();
3308
-
3309
- // The unknown-defer branch still sends and acks nothing, but the age
3310
- // prune has already bounded the backlog — a permanently-unknown state
3311
- // cannot accumulate rows forever.
3312
- expect(mockFetch).not.toHaveBeenCalled();
3313
- expect(pendingOutboxIds()).toEqual(["ob-fresh"]);
3314
- expect(mockSetFlushCheckpoint).not.toHaveBeenCalled();
3315
- });
3316
-
3317
- test("expired rows are pruned even when the flush skips for platform-disabled", async () => {
3318
- process.env.CADET_DISABLE_CLOUD = "true";
3319
- seedExpiredAndFreshRows();
3320
- const reporter = new UsageTelemetryReporter(
3321
- [outboxSource(OUTBOX_NAME)],
3322
- fakeFlushCheckpointStore,
3323
- );
3324
- await reporter.flush();
3325
-
3326
- expect(mockFetch).not.toHaveBeenCalled();
3327
- expect(pendingOutboxIds()).toEqual(["ob-fresh"]);
3328
- });
3329
-
3330
- test("a reporter without ack-mode sources never prunes the outbox", async () => {
3331
- // The daemon partition (watermark-only) does not own the outbox; the
3332
- // prune belongs to the reporter instance that flushes ack-mode sources.
3333
- seedExpiredAndFreshRows();
3334
- const reporter = new UsageTelemetryReporter(
3335
- [makeWatermarkSource("fake_wm")],
3336
- fakeFlushCheckpointStore,
3337
- );
3338
- await reporter.flush();
3339
-
3340
- expect(pendingOutboxIds()).toEqual(["ob-expired", "ob-fresh"]);
3341
- });
3342
-
3343
- test("outboxSource purges corrupt-payload rows at collect while valid siblings ship", async () => {
3344
- // Raw inserts: the store API only writes JSON.stringify-ed events, so
3345
- // corrupt payloads (invalid JSON, non-object JSON) go in directly.
3346
- getTelemetryDb()!
3347
- .insert(telemetryEvents)
3348
- .values([
3349
- {
3350
- id: "ob-corrupt-json",
3351
- name: OUTBOX_NAME,
3352
- createdAt: OUTBOX_BASE + 1000,
3353
- conversationId: null,
3354
- payload: "{not json",
3355
- },
3356
- {
3357
- id: "ob-non-object",
3358
- name: OUTBOX_NAME,
3359
- createdAt: OUTBOX_BASE + 2000,
3360
- conversationId: null,
3361
- payload: "42",
3362
- },
3363
- ])
3364
- .run();
3365
- seedOutboxRow("ob-valid", OUTBOX_BASE + 3000);
3366
- const reporter = new UsageTelemetryReporter(
3367
- [outboxSource(OUTBOX_NAME)],
3368
- fakeFlushCheckpointStore,
3369
- );
3370
- await reporter.flush();
3371
-
3372
- expect(mockFetch).toHaveBeenCalledTimes(1);
3373
- const body = JSON.parse(
3374
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3375
- );
3376
- expect(
3377
- body.events.map((e: { daemon_event_id: string }) => e.daemon_event_id),
3378
- ).toEqual(["wire-ob-valid"]);
3379
- // Corrupt rows are gone (purged at collect), the valid row via ack.
3380
- expect(pendingOutboxIds()).toEqual([]);
3381
- });
3382
-
3383
- test("a full outbox batch drives recursion until the backlog drains", async () => {
3384
- // 501 rows: the first collect fills BATCH_SIZE (500) and the fullBatch
3385
- // recursion ships the remaining row.
3386
- for (let i = 0; i < 501; i++) {
3387
- seedOutboxRow(`ob-${String(i).padStart(3, "0")}`, OUTBOX_BASE + i);
3388
- }
3389
- const reporter = new UsageTelemetryReporter(
3390
- [outboxSource(OUTBOX_NAME)],
3391
- fakeFlushCheckpointStore,
3392
- );
3393
- await reporter.flush();
3394
-
3395
- expect(mockFetch).toHaveBeenCalledTimes(2);
3396
- const secondBody = JSON.parse(
3397
- (mockFetch.mock.calls[1] as [string, RequestInit])[1].body as string,
3398
- );
3399
- expect(secondBody.events).toHaveLength(1);
3400
- expect(pendingOutboxIds()).toEqual([]);
3401
- });
3402
-
3403
- // -------------------------------------------------------------------------
3404
- // Lifecycle events (outbox-backed)
3405
- // -------------------------------------------------------------------------
3406
-
3407
- test("a recorded lifecycle event flushes once and its outbox row is deleted", async () => {
3408
- mockQueryUnreportedUsageEvents.mockReturnValue([]);
3409
- const recorded = recordLifecycleEvent("app_open");
3410
- expect(recorded).not.toBeNull();
3411
- mockFetch.mockImplementation(() =>
3412
- Promise.resolve(new Response('{"accepted":1}', { status: 200 })),
3413
- );
3414
-
3415
- const reporter = makeReporter();
3416
- await reporter.flush();
3417
-
3418
- expect(mockFetch).toHaveBeenCalledTimes(1);
3419
- const body = JSON.parse(
3420
- (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
3421
- );
3422
- expect(body.events).toHaveLength(1);
3423
- // The stored payload IS the wire event — record-time version included.
3424
- expect(body.events[0]).toEqual({
3425
- type: "lifecycle",
3426
- daemon_event_id: recorded!.id,
3427
- event_name: "app_open",
3428
- recorded_at: recorded!.createdAt,
3429
- assistant_version: "1.2.3-test",
3430
- });
3431
- // Delete-on-flush: the outbox row is gone, and lifecycle (ack-mode)
3432
- // never touches flush_checkpoints.
3433
- expect(queryTelemetryOutboxBatch("lifecycle", 10)).toEqual([]);
3434
- expect(
3435
- mockSetFlushCheckpoint.mock.calls.some((c) =>
3436
- c[0].includes(":lifecycle:"),
3437
- ),
3438
- ).toBe(false);
3439
-
3440
- // A second flush finds nothing pending — the event never re-ships.
3441
- mockFetch.mockClear();
3442
- await reporter.flush();
3443
- expect(mockFetch).not.toHaveBeenCalled();
3444
- });
3445
- });