@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.
- package/Dockerfile +0 -8
- package/eslint-rules/__tests__/cli-no-daemon-internals.test.ts +1 -1
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/openapi.yaml +62 -910
- package/package.json +3 -7
- package/scripts/build-test-fixtures.ts +1 -1
- package/scripts/generate-openapi.ts +22 -6
- package/scripts/postinstall.ts +4 -10
- package/scripts/run-tests.ts +0 -21
- package/src/__tests__/adaptive-thinking-repair.test.ts +19 -57
- package/src/__tests__/agent-loop.test.ts +0 -76
- package/src/__tests__/anthropic-provider.test.ts +6 -6
- package/src/__tests__/assert-not-live-db.ts +1 -1
- package/src/__tests__/assistant-id-boundary-guard.test.ts +3 -3
- package/src/__tests__/attachments-store-heic-normalize.test.ts +1 -1
- package/src/__tests__/attachments-store.test.ts +1 -1
- package/src/__tests__/audit-log-rotation.test.ts +1 -1
- package/src/__tests__/btw-routes.test.ts +1 -1
- package/src/__tests__/byok-default-profile-ensure.test.ts +21 -27
- package/src/__tests__/call-conversation-messages.test.ts +0 -5
- package/src/__tests__/call-recovery.test.ts +2 -7
- package/src/__tests__/call-routes-http.test.ts +0 -12
- package/src/__tests__/call-store.test.ts +2 -7
- package/src/__tests__/catalog-files.test.ts +7 -5
- package/src/__tests__/checker.test.ts +1 -1
- package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +4 -4
- package/src/__tests__/cloud-client-verify-credential.test.ts +0 -4
- package/src/__tests__/cloud-credential-catalog-cli.test.ts +14 -14
- package/src/__tests__/cloud-profile-guard.test.ts +87 -404
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/config-loader-backfill.test.ts +74 -163
- package/src/__tests__/config-loader-cloud-defaults.test.ts +11 -11
- package/src/__tests__/config-loader-corrupt.test.ts +10 -10
- package/src/__tests__/config-loader-quarantine-notice.test.ts +7 -7
- package/src/__tests__/config-loader-validation-reset-notice.test.ts +11 -11
- package/src/__tests__/config-schema.test.ts +26 -27
- package/src/__tests__/config-set-route.test.ts +2 -6
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +28 -300
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +39 -246
- package/src/__tests__/conversation-agent-loop.test.ts +2 -58
- package/src/__tests__/conversation-clear-safety.test.ts +0 -30
- package/src/__tests__/conversation-inference-profile-route.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +1 -1
- package/src/__tests__/conversation-skill-tools.test.ts +4 -444
- package/src/__tests__/conversation-slash-commands.test.ts +1 -1
- package/src/__tests__/conversation-store.test.ts +1 -84
- package/src/__tests__/conversation-surface-state.test.ts +0 -16
- package/src/__tests__/credential-health-service.test.ts +14 -1
- package/src/__tests__/credential-record-retire.test.ts +1 -1
- package/src/__tests__/credential-record-write-through.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +0 -145
- package/src/__tests__/custom-profile-ensure.test.ts +1 -1
- package/src/__tests__/default-profile-catalog-fallback.test.ts +5 -15
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -2
- package/src/__tests__/dictation-profile-store.test.ts +6 -6
- package/src/__tests__/disk-usage.test.ts +28 -164
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +1 -1
- package/src/__tests__/embedding-cloud-proxy-selection.test.ts +1 -1
- package/src/__tests__/emit-event-signal.test.ts +1 -1
- package/src/__tests__/gateway-client-cloud-outbound.test.ts +3 -3
- package/src/__tests__/gateway-only-enforcement.test.ts +1 -1
- package/src/__tests__/guardian-dispatch.test.ts +0 -13
- package/src/__tests__/helpers/seed-app.ts +1 -1
- package/src/__tests__/host-cu-routes-targeted.test.ts +1 -1
- package/src/__tests__/host-shell-tool.test.ts +1 -1
- package/src/__tests__/identity-routes.test.ts +1 -1
- package/src/__tests__/inference-profile-session-handler.test.ts +1 -1
- package/src/__tests__/inference-profile-session-ipc.test.ts +1 -1
- package/src/__tests__/injector-chain.test.ts +1 -1
- package/src/__tests__/injector-config-quarantine-notice.test.ts +1 -1
- package/src/__tests__/injector-config-validation-reset-notice.test.ts +1 -1
- package/src/__tests__/injector-pkb-v2-silenced.test.ts +1 -1
- package/src/__tests__/{platform.test.ts → layout.test.ts} +1 -1
- package/src/__tests__/list-all-apps.test.ts +1 -1
- package/src/__tests__/live-workspace-guard.test.ts +2 -2
- package/src/__tests__/llm-resolver-override-or-default.test.ts +5 -6
- package/src/__tests__/llm-resolver.test.ts +7 -7
- package/src/__tests__/llm-schema-fallback-profile.test.ts +9 -10
- package/src/__tests__/llm-usage-store.test.ts +0 -495
- package/src/__tests__/log-export-routes.test.ts +1 -1
- package/src/__tests__/mcp-config-secret-boundary.test.ts +12 -53
- package/src/__tests__/mcp-list-plugin-servers.test.ts +1 -1
- package/src/__tests__/memory-config-file-parity.test.ts +1 -1
- package/src/__tests__/memory-v2-static-injector.test.ts +1 -1
- package/src/__tests__/messages-read-boundary-guard.test.ts +0 -2
- package/src/__tests__/notification-guardian-path.test.ts +0 -7
- package/src/__tests__/onboarding-persona-write.test.ts +1 -1
- package/src/__tests__/openai-provider.test.ts +8 -8
- package/src/__tests__/path-policy.test.ts +1 -1
- package/src/__tests__/persona-resolver.test.ts +1 -1
- package/src/__tests__/plugin-app-serve-routes.test.ts +1 -1
- package/src/__tests__/plugin-effective-enabled-set.test.ts +1 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +4 -6
- package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -3
- package/src/__tests__/provider-cloud-proxy-integration.test.ts +1 -5
- package/src/__tests__/provider-connections-backfill.test.ts +10 -10
- package/src/__tests__/prune-old-conversations-job.test.ts +6 -31
- package/src/__tests__/resolve-app.test.ts +1 -1
- package/src/__tests__/run-due-schedules.test.ts +1 -1
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +0 -53
- package/src/__tests__/schedule-routes.test.ts +1 -1
- package/src/__tests__/schedule-store.test.ts +1 -1
- package/src/__tests__/secret-routes-cloud-proxy.test.ts +1 -1
- package/src/__tests__/skill-load-plugin-scope.test.ts +1 -1
- package/src/__tests__/surface-completion-compaction-boundary.test.ts +1 -1
- package/src/__tests__/tool-approval-handler.test.ts +1 -1
- package/src/__tests__/tool-audit.test.ts +5 -235
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +54 -114
- package/src/__tests__/tools-get-route.test.ts +1 -1
- package/src/__tests__/turn-outcome-read.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +0 -1
- package/src/__tests__/usage-attribution.test.ts +0 -31
- package/src/__tests__/verification-control-plane-policy.test.ts +3 -10
- package/src/__tests__/visible-app-context.test.ts +1 -1
- package/src/__tests__/voice-ingress-preflight.test.ts +1 -1
- package/src/__tests__/workspace-git-service.test.ts +1 -1
- package/src/__tests__/workspace-greetings.test.ts +1 -1
- package/src/acp/__tests__/helpers/acp-config-stub.ts +1 -1
- package/src/activation/progress-store.ts +1 -1
- package/src/activation/turn-hooks.ts +1 -1
- package/src/agent/loop.ts +0 -51
- package/src/apps/app-store.ts +1 -1
- package/src/avatar/__tests__/avatar-store.test.ts +2 -172
- package/src/avatar/accent-backfill.ts +1 -1
- package/src/avatar/avatar-manifest.ts +1 -1
- package/src/avatar/avatar-store.ts +10 -69
- package/src/avatar/ensure-raster.ts +1 -1
- package/src/avatar/identity-avatar.ts +1 -1
- package/src/avatar/sync-avatar.ts +1 -1
- package/src/avatar/traits-png-sync.ts +1 -1
- package/src/bundler/compiler-tools.ts +1 -1
- package/src/bundler/package-resolver.ts +1 -1
- package/src/calls/__tests__/call-controller.test.ts +25 -27
- package/src/calls/active-call-lease.ts +1 -1
- package/src/calls/call-controller.ts +3 -3
- package/src/calls/call-domain.ts +4 -8
- package/src/calls/call-store.ts +2 -76
- package/src/calls/resolve-call-tts-provider.ts +3 -3
- package/src/calls/twilio-config.ts +3 -3
- package/src/calls/twilio-rest.ts +2 -2
- package/src/calls/voice-ingress-preflight.ts +2 -2
- package/src/cli/bundled-modules.ts +0 -2
- package/src/cli/commands/__tests__/apps.test.ts +1 -1
- package/src/cli/commands/__tests__/plugins.test.ts +9 -66
- package/src/cli/commands/__tests__/ps.test.ts +67 -0
- package/src/cli/commands/auth.help.ts +2 -2
- package/src/cli/commands/avatar.ts +1 -1
- package/src/cli/commands/changelog.ts +1 -1
- package/src/cli/commands/cloud/__tests__/subscription.test.ts +2 -2
- package/src/cli/commands/cloud/index.help.ts +1 -1
- package/src/cli/commands/cloud/index.ts +2 -2
- package/src/cli/commands/db/refresh.ts +1 -1
- package/src/cli/commands/db/repair-step-conversation-backfill.ts +1 -1
- package/src/cli/commands/db/repair.ts +1 -1
- package/src/cli/commands/db/status.ts +1 -1
- package/src/cli/commands/inference-profiles.ts +1 -12
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +1 -116
- package/src/cli/commands/memory/index.help.ts +1 -43
- package/src/cli/commands/memory/memory-v3.ts +0 -64
- package/src/cli/commands/plugins.ts +54 -205
- package/src/cli/commands/ps.ts +5 -2
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/index.help.ts +0 -2
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +36 -66
- package/src/cli/lib/__tests__/install-from-cloud.test.ts +1 -1
- package/src/cli/lib/__tests__/install-from-github.test.ts +41 -117
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +2 -185
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +36 -66
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +4 -6
- package/src/cli/lib/__tests__/plugin-details.test.ts +113 -96
- package/src/cli/lib/__tests__/search-plugins.test.ts +9 -16
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +1 -1
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +38 -15
- package/src/cli/lib/diff-plugin.ts +1 -1
- package/src/cli/lib/inspect-plugin.ts +13 -97
- package/src/cli/lib/install-from-cloud.ts +1 -1
- package/src/cli/lib/install-from-github.ts +75 -154
- package/src/cli/lib/install-plugin-dependencies.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +1 -1
- package/src/cli/lib/open-browser.ts +1 -1
- package/src/cli/lib/plugin-catalog-cache.ts +8 -51
- package/src/cli/lib/plugin-catalog-resolve.ts +3 -22
- package/src/cli/lib/plugin-details.ts +5 -27
- package/src/cli/lib/plugin-marketplace.ts +18 -266
- package/src/cli/lib/search-plugins.ts +23 -43
- package/src/cli/lib/toggle-plugin.ts +1 -1
- package/src/cli/lib/uninstall-plugin.ts +1 -1
- package/src/cli/lib/upgrade-plugin.ts +3 -49
- package/src/cli/program.ts +1 -3
- package/src/cli.ts +1 -1
- package/src/cloud/client.ts +0 -6
- package/src/cloud/consent-cache.test.ts +22 -201
- package/src/cloud/consent-cache.ts +16 -111
- package/src/cloud/sync-identity.ts +1 -1
- package/src/config/__tests__/chat-settings.test.ts +1 -1
- package/src/config/__tests__/cloud-identity.test.ts +15 -2
- package/src/config/__tests__/default-plugin-skill-discovery.test.ts +1 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +12 -51
- package/src/config/__tests__/deployment-context-defaults.test.ts +13 -13
- package/src/config/__tests__/loader-callsite-strip-fallback.test.ts +4 -4
- package/src/config/__tests__/loader-last-known-good.test.ts +9 -9
- package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +7 -7
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +1 -1
- package/src/config/__tests__/profile-materialization.test.ts +2 -4
- package/src/config/__tests__/sync-gated-profiles.test.ts +1 -106
- package/src/config/acp-agent-write.ts +1 -1
- package/src/config/bundled-skills/settings/tools/open-system-settings.test.ts +1 -1
- package/src/config/bundled-skills/settings/tools/open-system-settings.ts +1 -1
- package/src/config/call-site-defaults.ts +0 -6
- package/src/config/cloud-identity.ts +12 -5
- package/src/config/composer-preferences.ts +1 -1
- package/src/config/default-profile-catalog.ts +8 -75
- package/src/config/default-profile-names.ts +2 -25
- package/src/config/env-registry.ts +1 -14
- package/src/config/env.ts +8 -6
- package/src/config/llm-resolver.ts +1 -2
- package/src/config/loader.ts +13 -17
- package/src/config/profile-materialization.ts +1 -1
- package/src/config/schema.ts +1 -7
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/llm.ts +25 -32
- package/src/config/seed-inference-profiles.ts +16 -17
- package/src/config/skills.ts +2 -2
- package/src/config/sync-gated-profiles.ts +6 -24
- package/src/credential-health/credential-health-service.ts +10 -3
- package/src/daemon/approval-generators.ts +3 -3
- package/src/daemon/config-watcher.ts +2 -2
- package/src/daemon/conversation-agent-loop-handlers.ts +0 -18
- package/src/daemon/conversation-agent-loop.ts +6 -97
- package/src/daemon/conversation-process.ts +1 -1
- package/src/daemon/conversation-skill-tools.ts +4 -72
- package/src/daemon/conversation-store.ts +1 -1
- package/src/daemon/conversation-surface-state.ts +0 -33
- package/src/daemon/conversation-surfaces.ts +1 -86
- package/src/daemon/conversation-tool-setup.test.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +0 -23
- package/src/daemon/conversation.ts +3 -5
- package/src/daemon/daemon-control.ts +3 -3
- package/src/daemon/dictation-profile-store.ts +3 -3
- package/src/daemon/event-loop-watchdog.test.ts +2 -198
- package/src/daemon/event-loop-watchdog.ts +0 -185
- package/src/daemon/external-plugins-bootstrap.ts +1 -1
- package/src/daemon/first-greeting.ts +1 -1
- package/src/daemon/handlers/skills.ts +1 -1
- package/src/daemon/host-app-control-proxy.ts +1 -1
- package/src/daemon/host-cu-proxy.ts +2 -2
- package/src/daemon/identity-helpers.ts +1 -1
- package/src/daemon/lifecycle.ts +11 -15
- package/src/daemon/main.ts +1 -1
- package/src/daemon/now-scratchpad.ts +1 -1
- package/src/daemon/process-message.ts +4 -4
- package/src/daemon/profiler-run-store.ts +2 -2
- package/src/daemon/providers-setup.ts +2 -2
- package/src/daemon/shutdown-handlers.ts +0 -10
- package/src/daemon/sqlite-corruption-watchdog.test.ts +2 -142
- package/src/daemon/sqlite-corruption-watchdog.ts +17 -81
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/desktop/desktop-session-manager.ts +1 -1
- package/src/desktop/desktop-wallpaper-renderer.ts +1 -1
- package/src/heartbeat/heartbeat-service.ts +1 -1
- package/src/home/feed-writer.ts +1 -1
- package/src/home/home-greeting-cache.ts +1 -1
- package/src/home/relationship-state-writer.ts +2 -2
- package/src/hooks/__tests__/hook-live-reload.test.ts +1 -1
- package/src/hooks/hook-loader.ts +2 -2
- package/src/inbound/__tests__/oauth-callback-url.test.ts +2 -2
- package/src/inbound/oauth-callback-url.ts +3 -3
- package/src/ipc/__tests__/attachment-ipc.test.ts +1 -1
- package/src/ipc/socket-path.ts +1 -1
- package/src/live-voice/__tests__/live-voice-archive.test.ts +1 -1
- package/src/live-voice/__tests__/live-voice-vad.test.ts +1 -1
- package/src/live-voice/live-voice-session.ts +3 -66
- package/src/mcp/__tests__/effective-config.test.ts +1 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +2 -2
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -3
- package/src/mcp/__tests__/reload-signal-emission.test.ts +1 -1
- package/src/mcp/__tests__/reload-signal.test.ts +1 -1
- package/src/mcp/__tests__/workspace-mcp-config.test.ts +19 -21
- package/src/mcp/reload-signal.ts +1 -1
- package/src/mcp/workspace-mcp-config.ts +1 -23
- package/src/messaging/draft-store.ts +1 -1
- package/src/monitoring/__tests__/active-conversations.test.ts +1 -1
- package/src/monitoring/__tests__/page-cache.test.ts +1 -1
- package/src/monitoring/__tests__/stall-capture.test.ts +1 -1
- package/src/monitoring/__tests__/workspace-git-heartbeat.test.ts +1 -1
- package/src/monitoring/active-conversations.ts +1 -1
- package/src/monitoring/control.ts +1 -1
- package/src/monitoring/daemon-boot-time.ts +1 -1
- package/src/monitoring/daemon-heartbeat.ts +1 -1
- package/src/monitoring/page-cache.ts +1 -1
- package/src/monitoring/plugin-auto-update.ts +1 -7
- package/src/monitoring/plugin-source-watch.ts +1 -1
- package/src/monitoring/recovery/db.ts +1 -1
- package/src/monitoring/recovery/inflight-content.ts +1 -1
- package/src/monitoring/resource-sampler.ts +3 -14
- package/src/monitoring/stall-capture.ts +1 -1
- package/src/monitoring/worker.ts +9 -76
- package/src/monitoring/workspace-git-heartbeat.ts +1 -1
- package/src/oauth/connect-orchestrator.ts +4 -4
- package/src/permissions/checker.ts +1 -1
- package/src/permissions/workspace-policy.ts +1 -1
- package/src/persistence/__tests__/main-db-readonly-probe.ts +27 -52
- package/src/persistence/__tests__/message-content-file.test.ts +1 -1
- package/src/persistence/__tests__/slow-sync-log.test.ts +0 -2
- package/src/persistence/attachments-store.ts +1 -1
- package/src/persistence/conversation-crud.ts +1 -38
- package/src/persistence/conversation-directories.ts +1 -1
- package/src/persistence/conversation-types.ts +0 -8
- package/src/persistence/db-async-query.ts +1 -1
- package/src/persistence/db-connection.ts +11 -1
- package/src/persistence/db-init.ts +1 -1
- package/src/persistence/db-maintenance.ts +1 -1
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +1 -1
- package/src/persistence/embeddings/embedding-local.ts +4 -4
- package/src/persistence/embeddings/embedding-runtime-manager.ts +1 -1
- package/src/persistence/embeddings/qdrant-client.ts +1 -1
- package/src/persistence/embeddings/qdrant-manager.ts +1 -1
- package/src/persistence/job-handlers/__tests__/message-lexical-backfill.test.ts +1 -1
- package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +1 -1
- package/src/persistence/job-handlers/cleanup.ts +8 -17
- package/src/persistence/llm-usage-store.ts +2 -229
- package/src/persistence/message-content-file.ts +1 -1
- package/src/persistence/migrations/209-strip-thinking-from-consolidated.ts +1 -1
- package/src/persistence/migrations/222-strip-placeholder-sentinels-from-messages.ts +1 -1
- package/src/persistence/migrations/296-rewrite-balanced-economy-profile-pins.test.ts +1 -1
- package/src/persistence/migrations/306-rewrite-frontier-profile-pins.test.ts +1 -1
- package/src/persistence/migrations/329-move-onboarding-events-to-telemetry-db.test.ts +13 -0
- package/src/persistence/migrations/330-move-auth-fallback-events-to-telemetry-db.test.ts +13 -0
- package/src/persistence/migrations/331-move-lifecycle-events-to-telemetry-db.test.ts +13 -0
- package/src/persistence/migrations/332-move-skill-loaded-events-to-telemetry-db.test.ts +13 -0
- package/src/persistence/migrations/333-create-telemetry-events-table.test.ts +7 -4
- package/src/persistence/migrations/334-backfill-telemetry-events-outbox.test.ts +13 -0
- package/src/persistence/migrations/387-drop-telemetry-tables.ts +51 -0
- package/src/persistence/migrations/__tests__/327-create-flush-checkpoints.test.ts +9 -1
- package/src/persistence/migrations/__tests__/387-drop-telemetry-tables.test.ts +79 -0
- package/src/persistence/migrations/helpers/relocation.ts +1 -1
- package/src/persistence/migrations/run-migrations.ts +1 -1
- package/src/persistence/migrations/validate-migration-state.ts +1 -1
- package/src/persistence/raw-query.ts +1 -29
- package/src/persistence/slow-sync-log.ts +0 -23
- package/src/persistence/steps.ts +2 -0
- package/src/{telemetry → persistence}/tool-audit.ts +6 -121
- package/src/plugin-api/index.ts +1 -1
- package/src/plugin-api/plugin-name-env.ts +1 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +1 -1
- package/src/plugins/collect-source-versions.ts +1 -1
- package/src/plugins/defaults/channel/package.json +1 -1
- package/src/plugins/defaults/cloud-hosted/package.json +1 -1
- package/src/plugins/defaults/compaction/package.json +1 -1
- package/src/plugins/defaults/documents/package.json +1 -1
- package/src/plugins/defaults/empty-response/package.json +1 -1
- package/src/plugins/defaults/exploration-drift/package.json +1 -1
- package/src/plugins/defaults/image-fallback/package.json +1 -1
- package/src/plugins/defaults/image-recovery/package.json +1 -1
- package/src/plugins/defaults/max-tokens-continue/package.json +1 -1
- package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +1 -1
- package/src/plugins/defaults/memory/__tests__/db-maintenance.test.ts +1 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -82
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +0 -41
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +0 -2
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +3 -52
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +0 -18
- package/src/plugins/defaults/memory/package.json +1 -1
- package/src/plugins/defaults/memory/paths.ts +4 -4
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +1 -1
- package/src/plugins/defaults/memory/src/memory-v2-routes.ts +5 -5
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +0 -207
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +1 -1
- package/src/plugins/defaults/memory/startup.ts +1 -22
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +2 -253
- package/src/plugins/defaults/memory/v3/orchestrate.ts +5 -129
- package/src/plugins/defaults/memory/worker-control.ts +1 -1
- package/src/plugins/defaults/memory/worker.ts +2 -2
- package/src/plugins/defaults/session/package.json +1 -1
- package/src/plugins/defaults/surface-completion-nudge/package.json +1 -1
- package/src/plugins/defaults/task-progress/package.json +1 -1
- package/src/plugins/defaults/task-progress-nudge/package.json +1 -1
- package/src/plugins/defaults/title-generate/package.json +1 -1
- package/src/plugins/defaults/tool-error/package.json +1 -1
- package/src/plugins/defaults/tool-result-truncate/package.json +1 -1
- package/src/plugins/defaults/turn-context/package.json +1 -1
- package/src/plugins/defaults/workspace/injectors.ts +1 -1
- package/src/plugins/defaults/workspace/package.json +1 -1
- package/src/plugins/disabled-state.ts +1 -1
- package/src/plugins/ensure-plugin-api-shim.ts +1 -1
- package/src/plugins/ensure-shared-dep-links.ts +1 -1
- package/src/plugins/installed-plugin-dirs.ts +1 -1
- package/src/plugins/mcp-servers.ts +7 -9
- package/src/plugins/mtime-cache.ts +2 -2
- package/src/plugins/source-versions.ts +1 -1
- package/src/prompts/bootstrap-cleanup.ts +1 -1
- package/src/prompts/persona-resolver.ts +1 -1
- package/src/prompts/sections.ts +2 -2
- package/src/prompts/system-prompt.ts +10 -42
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +0 -37
- package/src/providers/__tests__/cadet-connection-routing.test.ts +2 -2
- package/src/providers/cadet/client.ts +1 -1
- package/src/providers/inference/__tests__/base-url-route-validation.test.ts +1 -1
- package/src/providers/inference/adapter-factory.ts +1 -1
- package/src/providers/inference/backfill.ts +3 -10
- package/src/providers/inference/profile-probe.ts +1 -1
- package/src/providers/retry.ts +0 -2
- package/src/providers/types.ts +0 -7
- package/src/providers/usage-tracking.ts +1 -1
- package/src/routes/__tests__/route-host.test.ts +1 -1
- package/src/routes/control.ts +1 -1
- package/src/routes/route-host-client.ts +1 -1
- package/src/routes/worker.ts +2 -2
- package/src/runtime/agent-wake.ts +1 -1
- package/src/runtime/assistant-stream-state.ts +1 -1
- package/src/runtime/auth/token-service.ts +1 -1
- package/src/runtime/background-job-runner.ts +1 -1
- package/src/runtime/middleware/twilio-validation.ts +2 -2
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +1 -1
- package/src/runtime/routes/__tests__/avatar-state-routes.test.ts +3 -33
- package/src/runtime/routes/__tests__/cloud-credits-routes.test.ts +5 -4
- package/src/runtime/routes/__tests__/cloud-invoice-routes.test.ts +4 -3
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +72 -298
- package/src/runtime/routes/__tests__/default-plugin-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/deprecated-route-context.test.ts +15 -62
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +105 -12
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/monitoring-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -211
- package/src/runtime/routes/__tests__/route-host-worker-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +1 -1
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-route-dispatcher-host.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-routes-cli.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +1 -1
- package/src/runtime/routes/__tests__/user-routes-request.test.ts +1 -1
- package/src/runtime/routes/app-management-routes.ts +1 -1
- package/src/runtime/routes/attachment-routes.ts +1 -1
- package/src/runtime/routes/audit-routes.ts +1 -1
- package/src/runtime/routes/avatar-routes.ts +3 -10
- package/src/runtime/routes/cloud-routes.ts +3 -3
- package/src/runtime/routes/content-source-routes.ts +1 -1
- package/src/runtime/routes/conversation-query-routes.ts +49 -706
- package/src/runtime/routes/conversation-routes.ts +1 -38
- package/src/runtime/routes/debug-routes.ts +1 -1
- package/src/runtime/routes/deprecated-route-context.ts +5 -61
- package/src/runtime/routes/heartbeat-routes.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +1 -1
- package/src/runtime/routes/index.ts +0 -6
- package/src/runtime/routes/inference-profile-session-handler.ts +3 -3
- package/src/runtime/routes/inference-profiles-routes.ts +48 -21
- package/src/runtime/routes/inference-provider-connection-routes.ts +0 -1
- package/src/runtime/routes/llm-call-sites-routes.ts +3 -3
- package/src/runtime/routes/log-export/__tests__/installed-inventory.test.ts +1 -1
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist-error-contract.test.ts +1 -1
- package/src/runtime/routes/log-export/__tests__/workspace-allowlist.test.ts +1 -1
- package/src/runtime/routes/log-export/workspace-allowlist.ts +1 -1
- package/src/runtime/routes/log-export-routes.ts +17 -15
- package/src/runtime/routes/migration-rollback-routes.ts +1 -1
- package/src/runtime/routes/migration-routes.ts +2 -3
- package/src/runtime/routes/monitoring-routes.ts +2 -2
- package/src/runtime/routes/oauth-providers.ts +5 -5
- package/src/runtime/routes/plugins-routes.ts +38 -163
- package/src/runtime/routes/profiler-routes.ts +1 -1
- package/src/runtime/routes/route-host-worker-routes.ts +1 -1
- package/src/runtime/routes/sanity-routes.ts +1 -1
- package/src/runtime/routes/schedule-routes.ts +1 -1
- package/src/runtime/routes/schedule-worker-routes.ts +1 -1
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/runtime/routes/sounds-config-routes.ts +1 -1
- package/src/runtime/routes/surface-conversation-resolver.ts +1 -7
- package/src/runtime/routes/user-route-dispatcher.ts +1 -3
- package/src/runtime/routes/user-route-import.ts +1 -1
- package/src/runtime/routes/user-route-resolution.ts +1 -1
- package/src/runtime/routes/user-routes-cli.ts +1 -1
- package/src/runtime/routes/workspace-commit-routes.ts +1 -1
- package/src/runtime/routes/workspace-greetings.ts +1 -1
- package/src/runtime/routes/workspace-routes.ts +1 -1
- package/src/runtime/routes/workspace-utils.ts +1 -1
- package/src/runtime/sync/resource-sync-events.ts +1 -1
- package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +1 -1
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -1
- package/src/schedule/plugin-schedule-availability.ts +1 -1
- package/src/schedule/plugin-schedule-declarations.ts +1 -1
- package/src/schedule/run-script.ts +1 -1
- package/src/schedule/scheduler.ts +1 -11
- package/src/schedule/worker-control.ts +1 -1
- package/src/schedule/worker.ts +3 -3
- package/src/security/oauth2.ts +2 -2
- package/src/security/secret-allowlist.ts +1 -1
- package/src/signals/cancel.ts +1 -1
- package/src/signals/conversation-undo.ts +1 -1
- package/src/signals/emit-event.ts +1 -1
- package/src/signals/event-stream.ts +1 -1
- package/src/signals/user-message.ts +1 -1
- package/src/skills/catalog-install.ts +2 -1
- package/src/skills/clawhub.ts +1 -1
- package/src/skills/cloud-store.ts +1 -1
- package/src/skills/path-classifier.ts +1 -1
- package/src/skills/skill-history.ts +1 -1
- package/src/skills/skillssh-registry.ts +1 -1
- package/src/skills/uninstall.ts +1 -1
- package/src/skills/workspace-skill-md-signature.ts +1 -1
- package/src/subagent/manager.ts +1 -1
- package/src/theme/workspace-theme.ts +1 -1
- package/src/tools/browser/auth-cache.ts +1 -1
- package/src/tools/browser/browser-manager.ts +1 -1
- package/src/tools/browser/runtime-check.ts +1 -1
- package/src/tools/executor.ts +4 -4
- package/src/tools/filesystem/write.ts +1 -1
- package/src/tools/permission-checker.ts +2 -51
- package/src/tools/run-standalone.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +1 -1
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/skills/scaffold-cloud.ts +0 -32
- package/src/tools/terminal/safe-env.ts +1 -2
- package/src/tools/terminal/sanitized-bash.ts +1 -1
- package/src/tools/terminal/shell.ts +1 -1
- package/src/tools/tool-approval-handler.ts +4 -1
- package/src/tools/workflows/manage-workflows.ts +2 -2
- package/src/tools/workflows/run-workflow.test.ts +1 -1
- package/src/tools/workspace-tools/loader.ts +1 -1
- package/src/tts/synthesis-stream.ts +2 -2
- package/src/usage/attribution.ts +1 -11
- package/src/util/browser.ts +1 -1
- package/src/util/bun-runtime.ts +2 -1
- package/src/util/device-id.ts +1 -1
- package/src/util/disk-usage.ts +4 -139
- package/src/util/layout.ts +3 -3
- package/src/util/logger.ts +1 -1
- package/src/util/logs-db-path.ts +1 -1
- package/src/util/memory-db-path.ts +1 -1
- package/src/util/{platform.ts → os.ts} +5 -30
- package/src/util/process-tree.ts +19 -0
- package/src/util/sqlite3-runtime.ts +1 -1
- package/src/util/telemetry-db-path.ts +1 -1
- package/src/watcher/__tests__/engine.test.ts +0 -23
- package/src/watcher/engine.ts +0 -7
- package/src/windows-compiled-cli.ts +0 -4
- package/src/windows-compiled-worker-entry.ts +0 -6
- package/src/workflows/leaf-runner.test.ts +1 -1
- package/src/workflows/leaf-runner.ts +1 -1
- package/src/workflows/library.ts +1 -1
- package/src/workspace/adaptive-thinking-repair.ts +5 -24
- package/src/workspace/byok-default-profile-ensure.ts +24 -26
- package/src/workspace/custom-profile-ensure.ts +2 -2
- package/src/workspace/git-service.ts +1 -1
- package/src/workspace/hatched-date.ts +1 -1
- package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +2 -2
- package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +1 -1
- package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +1 -1
- package/src/workspace/migrations/077-seed-memory-router-callsite.ts +1 -1
- package/src/workspace/migrations/registry.ts +0 -4
- package/src/workspace/turn-commit.ts +1 -1
- package/docs/activation-funnel-telemetry.md +0 -324
- package/scripts/bundled-plugin-packages.ts +0 -154
- package/scripts/generate-bundled-plugin-packages.ts +0 -29
- package/src/__tests__/activation-early-marking.test.ts +0 -114
- package/src/__tests__/auth-fallback-events-store.test.ts +0 -99
- package/src/__tests__/config-get-vision-flag.test.ts +0 -139
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +0 -400
- package/src/__tests__/internal-telemetry-routes.test.ts +0 -213
- package/src/__tests__/telemetry-routes.test.ts +0 -182
- package/src/__tests__/turn-events-store.test.ts +0 -376
- package/src/__tests__/ui-channel-variants.test.ts +0 -53
- package/src/avatar/__tests__/avatar-changed-telemetry.test.ts +0 -217
- package/src/avatar/avatar-changed-telemetry.ts +0 -128
- package/src/calls/__tests__/call-funnel-telemetry.test.ts +0 -204
- package/src/cli/commands/telemetry.help.ts +0 -27
- package/src/cli/commands/telemetry.ts +0 -83
- package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +0 -251
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +0 -113
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +0 -384
- package/src/cli/lib/__tests__/plugin-pin-history.test.ts +0 -164
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +0 -228
- package/src/cli/lib/bundled-marketplace.json +0 -1575
- package/src/cli/lib/bundled-plugin-packages.json +0 -827
- package/src/cli/lib/bundled-plugin-packages.ts +0 -87
- package/src/cli/lib/plugin-catalog-local.ts +0 -94
- package/src/cli/lib/plugin-pin-history.ts +0 -276
- package/src/cloud/feature-gate.ts +0 -8
- package/src/config/__tests__/cloud-rehydration.test.ts +0 -180
- package/src/config/cloud-rehydration.ts +0 -35
- package/src/daemon/__tests__/auto-profile-router.test.ts +0 -380
- package/src/daemon/auto-profile-router.ts +0 -428
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +0 -391
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +0 -113
- package/src/monitoring/__tests__/oom-kill-reporter.test.ts +0 -374
- package/src/monitoring/db-integrity-check.ts +0 -163
- package/src/monitoring/db-integrity-sample.ts +0 -176
- package/src/monitoring/oom-kill-reporter.ts +0 -469
- package/src/onboarding/__tests__/onboarding-events-store.test.ts +0 -158
- package/src/onboarding/onboarding-events-store.ts +0 -296
- package/src/persistence/__tests__/raw-query-telemetry.test.ts +0 -76
- package/src/persistence/lifecycle-events-store.test.ts +0 -134
- package/src/persistence/lifecycle-events-store.ts +0 -110
- package/src/plugins/defaults/memory/__tests__/activation-session-store.test.ts +0 -76
- package/src/plugins/defaults/memory/activation-session-store.ts +0 -50
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +0 -281
- package/src/runtime/routes/auto-profile-routes.ts +0 -83
- package/src/runtime/routes/internal-telemetry-routes.ts +0 -142
- package/src/runtime/routes/telemetry-routes.ts +0 -274
- package/src/security/auth-fallback-events-store.ts +0 -57
- package/src/telemetry/__tests__/activation-funnel.test.ts +0 -95
- package/src/telemetry/__tests__/config-setting-events-store.test.ts +0 -63
- package/src/telemetry/__tests__/config-setting-snapshot.test.ts +0 -143
- package/src/telemetry/__tests__/flush-checkpoints.test.ts +0 -56
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +0 -174
- package/src/telemetry/__tests__/outbox-test-harness.ts +0 -85
- package/src/telemetry/__tests__/phone-call-funnel.test.ts +0 -72
- package/src/telemetry/__tests__/telemetry-event-fixtures.ts +0 -282
- package/src/telemetry/__tests__/watchdog-events-store.test.ts +0 -71
- package/src/telemetry/activation-funnel.ts +0 -172
- package/src/telemetry/config-setting-events-store.ts +0 -40
- package/src/telemetry/config-setting-snapshot.ts +0 -170
- package/src/telemetry/flush-checkpoints.ts +0 -70
- package/src/telemetry/live-voice-funnel.ts +0 -185
- package/src/telemetry/memory-corpus-size.test.ts +0 -120
- package/src/telemetry/memory-corpus-size.ts +0 -184
- package/src/telemetry/memory-tier-reporter.test.ts +0 -195
- package/src/telemetry/memory-tier-reporter.ts +0 -130
- package/src/telemetry/phone-call-funnel.ts +0 -144
- package/src/telemetry/skill-loaded-events-store.test.ts +0 -93
- package/src/telemetry/skill-loaded-events-store.ts +0 -39
- package/src/telemetry/telemetry-event-sources.test.ts +0 -223
- package/src/telemetry/telemetry-event-sources.ts +0 -597
- package/src/telemetry/telemetry-events-outbox.test.ts +0 -375
- package/src/telemetry/telemetry-events-outbox.ts +0 -237
- package/src/telemetry/telemetry-main-db.ts +0 -48
- package/src/telemetry/telemetry-wire-source.json +0 -4
- package/src/telemetry/telemetry-wire-validation.test.ts +0 -157
- package/src/telemetry/telemetry-wire-validation.ts +0 -162
- package/src/telemetry/telemetry-wire.generated.ts +0 -483
- package/src/telemetry/tool-executed-events-store.test.ts +0 -210
- package/src/telemetry/tool-executed-events-store.ts +0 -102
- package/src/telemetry/trace-collection-policy.test.ts +0 -28
- package/src/telemetry/trace-collection-policy.ts +0 -30
- package/src/telemetry/turn-events-store.ts +0 -216
- package/src/telemetry/turn-trace-store.test.ts +0 -884
- package/src/telemetry/turn-trace-store.ts +0 -399
- package/src/telemetry/types.test.ts +0 -38
- package/src/telemetry/types.ts +0 -780
- package/src/telemetry/usage-telemetry-reporter.test.ts +0 -3445
- package/src/telemetry/usage-telemetry-reporter.ts +0 -566
- package/src/telemetry/watchdog-direct-emit.test.ts +0 -112
- package/src/telemetry/watchdog-direct-emit.ts +0 -104
- package/src/telemetry/watchdog-events-store.ts +0 -31
- package/src/tools/ui-surface/channel-variants.ts +0 -52
- package/src/watcher/__tests__/telemetry.test.ts +0 -178
- package/src/watcher/telemetry.ts +0 -93
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +0 -165
- package/src/workspace/migrations/104-recheck-adaptive-thinking-model-implied-anthropic.ts +0 -151
- /package/src/{telemetry → persistence}/tool-usage-store.ts +0 -0
- /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
|
-
});
|