@vellumai/assistant 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (443) hide show
  1. package/AGENTS.md +9 -1
  2. package/ARCHITECTURE.md +15 -17
  3. package/Dockerfile +6 -4
  4. package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
  5. package/docs/architecture/integrations.md +32 -39
  6. package/docs/architecture/memory.md +25 -30
  7. package/docs/architecture/security.md +7 -6
  8. package/docs/browser-use-architecture-phase2.md +63 -20
  9. package/docs/plugins.md +761 -0
  10. package/examples/plugins/echo/README.md +132 -0
  11. package/examples/plugins/echo/package.json +17 -0
  12. package/examples/plugins/echo/register.ts +187 -0
  13. package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
  14. package/openapi.yaml +212 -68
  15. package/package.json +1 -1
  16. package/src/__tests__/app-compiler.test.ts +57 -0
  17. package/src/__tests__/approval-cascade.test.ts +7 -2
  18. package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
  19. package/src/__tests__/avatar-generator.test.ts +4 -2
  20. package/src/__tests__/bundled-asset.test.ts +6 -6
  21. package/src/__tests__/catalog-cache.test.ts +69 -0
  22. package/src/__tests__/checker.test.ts +459 -171
  23. package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
  24. package/src/__tests__/compaction-events.test.ts +501 -0
  25. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  26. package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
  27. package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
  28. package/src/__tests__/config-model-image-provider.test.ts +110 -0
  29. package/src/__tests__/config-schema.test.ts +22 -9
  30. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
  31. package/src/__tests__/contacts-tools.test.ts +26 -0
  32. package/src/__tests__/context-overflow-policy.test.ts +7 -7
  33. package/src/__tests__/context-window-manager.test.ts +355 -4
  34. package/src/__tests__/conversation-abort-tool-results.test.ts +4 -1
  35. package/src/__tests__/conversation-agent-loop-overflow.test.ts +26 -30
  36. package/src/__tests__/conversation-agent-loop.test.ts +30 -141
  37. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -1
  38. package/src/__tests__/conversation-history-web-search.test.ts +1 -0
  39. package/src/__tests__/conversation-init.benchmark.test.ts +2 -16
  40. package/src/__tests__/conversation-pairing.test.ts +174 -10
  41. package/src/__tests__/conversation-pre-run-repair.test.ts +4 -1
  42. package/src/__tests__/conversation-process-callsite.test.ts +3 -0
  43. package/src/__tests__/conversation-provider-retry-repair.test.ts +16 -7
  44. package/src/__tests__/conversation-queue.test.ts +29 -14
  45. package/src/__tests__/conversation-routes-disk-view.test.ts +7 -6
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +155 -110
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +23 -38
  48. package/src/__tests__/conversation-seed-composer.test.ts +2 -2
  49. package/src/__tests__/conversation-slash-queue.test.ts +7 -2
  50. package/src/__tests__/conversation-slash-unknown.test.ts +25 -2
  51. package/src/__tests__/conversation-speed-override.test.ts +6 -1
  52. package/src/__tests__/conversation-title-service.test.ts +116 -0
  53. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
  54. package/src/__tests__/conversation-usage.test.ts +1 -1
  55. package/src/__tests__/conversation-workspace-cache-state.test.ts +4 -1
  56. package/src/__tests__/conversation-workspace-injection.test.ts +3 -0
  57. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +4 -1
  58. package/src/__tests__/credential-health-service.test.ts +78 -9
  59. package/src/__tests__/credential-security-invariants.test.ts +2 -2
  60. package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
  61. package/src/__tests__/empty-response-pipeline.test.ts +305 -0
  62. package/src/__tests__/extension-id-sync-guard.test.ts +3 -3
  63. package/src/__tests__/first-greeting.test.ts +247 -5
  64. package/src/__tests__/headless-browser-mode.test.ts +57 -0
  65. package/src/__tests__/history-repair-pipeline.test.ts +399 -0
  66. package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
  67. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
  68. package/src/__tests__/host-proxy-interface.test.ts +36 -2
  69. package/src/__tests__/image-credentials.test.ts +137 -0
  70. package/src/__tests__/image-service-dispatcher.test.ts +186 -0
  71. package/src/__tests__/injector-chain.test.ts +526 -0
  72. package/src/__tests__/intent-routing.test.ts +0 -26
  73. package/src/__tests__/llm-call-pipeline.test.ts +285 -0
  74. package/src/__tests__/llm-schema.test.ts +1 -1
  75. package/src/__tests__/media-generate-image.test.ts +119 -13
  76. package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
  77. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
  78. package/src/__tests__/migration-import-from-url.test.ts +5 -68
  79. package/src/__tests__/model-intents.test.ts +4 -2
  80. package/src/__tests__/notification-broadcaster.test.ts +3 -3
  81. package/src/__tests__/notification-decision-strategy.test.ts +0 -11
  82. package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
  83. package/src/__tests__/oauth-apps-routes.test.ts +1 -1
  84. package/src/__tests__/oauth-cli.test.ts +14 -12
  85. package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
  86. package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
  87. package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
  88. package/src/__tests__/oauth-providers-routes.test.ts +3 -2
  89. package/src/__tests__/oauth-store.test.ts +41 -76
  90. package/src/__tests__/onboarding-template-contract.test.ts +16 -64
  91. package/src/__tests__/openai-image-service.test.ts +368 -0
  92. package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
  93. package/src/__tests__/permission-checker-host-gate.test.ts +0 -24
  94. package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
  95. package/src/__tests__/persistence-pipeline.test.ts +377 -0
  96. package/src/__tests__/pipeline-runner.test.ts +565 -0
  97. package/src/__tests__/platform.test.ts +5 -2
  98. package/src/__tests__/plugin-bootstrap.test.ts +483 -0
  99. package/src/__tests__/plugin-registry.test.ts +273 -0
  100. package/src/__tests__/plugin-route-contribution.test.ts +288 -0
  101. package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
  102. package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
  103. package/src/__tests__/plugin-types.test.ts +320 -0
  104. package/src/__tests__/pricing.test.ts +44 -12
  105. package/src/__tests__/proxy-approval-callback.test.ts +69 -8
  106. package/src/__tests__/reaction-persistence.test.ts +1 -0
  107. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
  108. package/src/__tests__/registry.test.ts +0 -2
  109. package/src/__tests__/schedule-routes.test.ts +131 -1
  110. package/src/__tests__/scheduler-recurrence.test.ts +14 -70
  111. package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
  112. package/src/__tests__/secret-detection-handler.test.ts +0 -10
  113. package/src/__tests__/shell-identity.test.ts +0 -134
  114. package/src/__tests__/suggestion-routes.test.ts +103 -4
  115. package/src/__tests__/task-memory-cleanup.test.ts +1 -0
  116. package/src/__tests__/task-scheduler.test.ts +3 -15
  117. package/src/__tests__/test-preload.ts +11 -0
  118. package/src/__tests__/title-generate-pipeline.test.ts +224 -0
  119. package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
  120. package/src/__tests__/tool-error-pipeline.test.ts +244 -0
  121. package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
  122. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -6
  123. package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
  124. package/src/__tests__/tool-executor.test.ts +141 -0
  125. package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
  126. package/src/__tests__/tool-result-truncation.test.ts +0 -110
  127. package/src/__tests__/user-plugin-loader.test.ts +191 -0
  128. package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
  129. package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
  130. package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
  131. package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
  132. package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
  133. package/src/__tests__/workspace-policy.test.ts +21 -3
  134. package/src/agent/loop.ts +340 -102
  135. package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
  136. package/src/approvals/guardian-request-resolvers.ts +80 -0
  137. package/src/backup/__tests__/backup-worker.test.ts +2 -13
  138. package/src/backup/backup-worker.ts +3 -15
  139. package/src/bundler/app-compiler.ts +84 -1
  140. package/src/calls/call-state.ts +2 -2
  141. package/src/channels/__tests__/types.test.ts +3 -3
  142. package/src/channels/types.ts +6 -4
  143. package/src/cli/__tests__/notifications.test.ts +87 -211
  144. package/src/cli/commands/__tests__/backup.test.ts +1 -1
  145. package/src/cli/commands/__tests__/image-generation.test.ts +255 -35
  146. package/src/cli/commands/__tests__/inference-send.test.ts +12 -0
  147. package/src/cli/commands/__tests__/tts-synthesize.test.ts +12 -0
  148. package/src/cli/commands/backup.ts +2 -2
  149. package/src/cli/commands/clients.ts +138 -0
  150. package/src/cli/commands/completions.ts +2 -9
  151. package/src/cli/commands/conversations.ts +55 -7
  152. package/src/cli/commands/image-generation.ts +33 -34
  153. package/src/cli/commands/notifications.ts +68 -103
  154. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
  155. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  156. package/src/cli/commands/oauth/connect.ts +2 -2
  157. package/src/cli/commands/oauth/providers.ts +176 -8
  158. package/src/cli/commands/oauth/status.ts +46 -36
  159. package/src/cli/commands/skills.ts +3 -4
  160. package/src/cli/program.ts +25 -29
  161. package/src/config/__tests__/backup-schema.test.ts +7 -2
  162. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  163. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
  164. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
  165. package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
  166. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
  167. package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
  168. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
  169. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
  170. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  171. package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
  172. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +12 -0
  173. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +58 -0
  174. package/src/config/bundled-skills/schedule/SKILL.md +8 -3
  175. package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
  176. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
  177. package/src/config/bundled-tool-registry.ts +0 -15
  178. package/src/config/feature-flag-registry.json +17 -1
  179. package/src/config/schema.ts +19 -0
  180. package/src/config/schemas/backup.ts +1 -1
  181. package/src/config/schemas/conversations.ts +16 -0
  182. package/src/config/schemas/llm.ts +2 -3
  183. package/src/config/schemas/security.ts +6 -6
  184. package/src/config/schemas/tts.ts +11 -0
  185. package/src/config/skill-state.ts +6 -2
  186. package/src/config/skills.ts +94 -5
  187. package/src/context/__tests__/compact-prompt.test.ts +27 -9
  188. package/src/context/prompts/compact.md +26 -12
  189. package/src/context/tool-result-truncation.ts +3 -63
  190. package/src/context/window-manager.ts +190 -16
  191. package/src/credential-health/credential-health-service.ts +19 -6
  192. package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
  193. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
  194. package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
  195. package/src/daemon/config-watcher.ts +0 -2
  196. package/src/daemon/context-overflow-policy.ts +4 -13
  197. package/src/daemon/conversation-agent-loop-handlers.ts +83 -22
  198. package/src/daemon/conversation-agent-loop.ts +984 -683
  199. package/src/daemon/conversation-history.ts +10 -19
  200. package/src/daemon/conversation-lifecycle.ts +37 -19
  201. package/src/daemon/conversation-notifiers.ts +2 -110
  202. package/src/daemon/conversation-process.ts +14 -7
  203. package/src/daemon/conversation-runtime-assembly.ts +532 -411
  204. package/src/daemon/conversation-tool-setup.ts +41 -4
  205. package/src/daemon/conversation.ts +80 -35
  206. package/src/daemon/external-plugins-bootstrap.ts +478 -0
  207. package/src/daemon/first-greeting.ts +191 -14
  208. package/src/daemon/handlers/config-model.ts +11 -0
  209. package/src/daemon/handlers/skills.ts +5 -1
  210. package/src/daemon/lifecycle.ts +33 -68
  211. package/src/daemon/message-types/computer-use.ts +2 -34
  212. package/src/daemon/message-types/conversations.ts +49 -0
  213. package/src/daemon/message-types/messages.ts +12 -0
  214. package/src/daemon/server.ts +5 -3
  215. package/src/daemon/shutdown-handlers.ts +2 -12
  216. package/src/daemon/tool-side-effects.ts +14 -56
  217. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
  218. package/src/heartbeat/heartbeat-service.ts +24 -1
  219. package/src/home/__tests__/feed-population-integration.test.ts +312 -0
  220. package/src/home/emit-feed-event.ts +7 -0
  221. package/src/home/feed-types.ts +41 -2
  222. package/src/home/rewrite-command-preview.ts +66 -0
  223. package/src/ipc/__tests__/socket-path.test.ts +11 -50
  224. package/src/ipc/cli-client.ts +1 -1
  225. package/src/ipc/cli-server.ts +3 -3
  226. package/src/ipc/gateway-client.ts +4 -1
  227. package/src/ipc/routes/browser-context.ts +2 -0
  228. package/src/ipc/routes/browser.ts +1 -0
  229. package/src/ipc/routes/get-contact.ts +16 -0
  230. package/src/ipc/routes/index.ts +14 -0
  231. package/src/ipc/routes/list-clients.ts +31 -0
  232. package/src/ipc/routes/merge-contacts.ts +17 -0
  233. package/src/ipc/routes/notification.ts +133 -0
  234. package/src/ipc/routes/rename-conversation.ts +59 -0
  235. package/src/ipc/routes/search-contacts.ts +19 -0
  236. package/src/ipc/routes/upsert-contact.ts +25 -0
  237. package/src/ipc/socket-path.ts +14 -38
  238. package/src/media/app-icon-generator.ts +23 -46
  239. package/src/media/avatar-router.ts +26 -41
  240. package/src/media/gemini-image-service.ts +8 -41
  241. package/src/media/image-credentials.ts +73 -0
  242. package/src/media/image-service.ts +85 -0
  243. package/src/media/openai-image-service.ts +131 -0
  244. package/src/media/types.ts +46 -0
  245. package/src/memory/conversation-crud.ts +48 -18
  246. package/src/memory/conversation-queries.ts +57 -4
  247. package/src/memory/conversation-title-service.ts +25 -0
  248. package/src/memory/db-init.ts +8 -0
  249. package/src/memory/embedding-gemini.test.ts +41 -2
  250. package/src/memory/embedding-gemini.ts +6 -1
  251. package/src/memory/graph/bootstrap.test.ts +282 -0
  252. package/src/memory/graph/bootstrap.ts +8 -5
  253. package/src/memory/graph/extraction.ts +10 -2
  254. package/src/memory/graph/graph-search.test.ts +1 -0
  255. package/src/memory/graph/inspect.ts +2 -2
  256. package/src/memory/graph/retriever.ts +10 -3
  257. package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
  258. package/src/memory/migrations/149-oauth-tables.ts +1 -0
  259. package/src/memory/migrations/223-schedule-script-column.ts +11 -0
  260. package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
  261. package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
  262. package/src/memory/migrations/index.ts +4 -0
  263. package/src/memory/pkb/pkb-index.test.ts +1 -0
  264. package/src/memory/pkb/pkb-reconcile.test.ts +1 -0
  265. package/src/memory/pkb/pkb-search.test.ts +65 -4
  266. package/src/memory/pkb/pkb-search.ts +40 -18
  267. package/src/memory/qdrant-client.test.ts +60 -0
  268. package/src/memory/qdrant-client.ts +25 -0
  269. package/src/memory/schema/infrastructure.ts +1 -0
  270. package/src/memory/schema/oauth.ts +4 -1
  271. package/src/messaging/providers/slack/render-transcript.test.ts +77 -29
  272. package/src/messaging/providers/slack/render-transcript.ts +58 -0
  273. package/src/notifications/conversation-pairing.ts +78 -19
  274. package/src/notifications/copy-composer.ts +0 -5
  275. package/src/notifications/emit-signal.ts +1 -1
  276. package/src/notifications/signal.ts +1 -2
  277. package/src/oauth/AGENTS.md +1 -1
  278. package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
  279. package/src/oauth/connect-orchestrator.ts +8 -34
  280. package/src/oauth/connect-types.ts +6 -10
  281. package/src/oauth/manual-token-connection.ts +23 -0
  282. package/src/oauth/oauth-store.ts +30 -14
  283. package/src/oauth/provider-serializer.ts +6 -1
  284. package/src/oauth/seed-providers.ts +56 -108
  285. package/src/outbound-proxy/http-forwarder.ts +9 -0
  286. package/src/permissions/approval-policy.test.ts +293 -18
  287. package/src/permissions/approval-policy.ts +110 -58
  288. package/src/permissions/arg-parser.test.ts +161 -0
  289. package/src/permissions/arg-parser.ts +141 -0
  290. package/src/permissions/bash-risk-classifier.test.ts +414 -2
  291. package/src/permissions/bash-risk-classifier.ts +303 -60
  292. package/src/permissions/checker.ts +157 -29
  293. package/src/permissions/command-registry.test.ts +239 -0
  294. package/src/permissions/command-registry.ts +234 -54
  295. package/src/permissions/defaults.ts +5 -4
  296. package/src/permissions/gateway-threshold-reader.ts +196 -0
  297. package/src/permissions/prompter.ts +4 -0
  298. package/src/permissions/risk-types.ts +61 -4
  299. package/src/permissions/schedule-risk-classifier.test.ts +129 -0
  300. package/src/permissions/schedule-risk-classifier.ts +85 -0
  301. package/src/permissions/shell-identity.ts +2 -42
  302. package/src/permissions/types.ts +2 -0
  303. package/src/permissions/workspace-policy.ts +8 -3
  304. package/src/plugins/defaults/circuit-breaker.ts +146 -0
  305. package/src/plugins/defaults/compaction.ts +145 -0
  306. package/src/plugins/defaults/empty-response.ts +126 -0
  307. package/src/plugins/defaults/history-repair.ts +85 -0
  308. package/src/plugins/defaults/index.ts +116 -0
  309. package/src/plugins/defaults/injectors.ts +491 -0
  310. package/src/plugins/defaults/llm-call.ts +82 -0
  311. package/src/plugins/defaults/memory-retrieval.ts +226 -0
  312. package/src/plugins/defaults/overflow-reduce.ts +181 -0
  313. package/src/plugins/defaults/persistence.ts +129 -0
  314. package/src/plugins/defaults/title-generate.ts +95 -0
  315. package/src/plugins/defaults/token-estimate.ts +104 -0
  316. package/src/plugins/defaults/tool-error.ts +126 -0
  317. package/src/plugins/defaults/tool-execute.ts +89 -0
  318. package/src/plugins/defaults/tool-result-truncate.ts +88 -0
  319. package/src/plugins/pipeline.ts +316 -0
  320. package/src/plugins/plugin-skill-contributions.ts +292 -0
  321. package/src/plugins/registry.ts +241 -0
  322. package/src/plugins/types.ts +1134 -0
  323. package/src/plugins/user-loader.ts +177 -0
  324. package/src/prompts/templates/BOOTSTRAP.md +27 -77
  325. package/src/providers/model-catalog.ts +52 -29
  326. package/src/providers/model-intents.ts +1 -1
  327. package/src/providers/openrouter/client.ts +5 -1
  328. package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
  329. package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
  330. package/src/providers/speech-to-text/xai-realtime.test.ts +72 -4
  331. package/src/providers/speech-to-text/xai-realtime.ts +39 -14
  332. package/src/runtime/AGENTS.md +25 -16
  333. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
  334. package/src/runtime/__tests__/client-registry.test.ts +293 -0
  335. package/src/runtime/client-registry.ts +261 -0
  336. package/src/runtime/http-server.ts +77 -8
  337. package/src/runtime/http-types.ts +0 -2
  338. package/src/runtime/migrations/vbundle-builder.ts +1 -22
  339. package/src/runtime/routes/approval-prompt-ts-tracker.ts +51 -31
  340. package/src/runtime/routes/approval-routes.ts +17 -0
  341. package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
  342. package/src/runtime/routes/conversation-routes.ts +223 -116
  343. package/src/runtime/routes/inbound-message-handler.ts +88 -13
  344. package/src/runtime/routes/memory-item-routes.test.ts +1 -0
  345. package/src/runtime/routes/migration-routes.ts +0 -3
  346. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
  347. package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
  348. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
  349. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
  350. package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
  351. package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
  352. package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
  353. package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
  354. package/src/runtime/routes/playground/deps.ts +56 -0
  355. package/src/runtime/routes/playground/force-compact.ts +73 -0
  356. package/src/runtime/routes/playground/guard.ts +37 -0
  357. package/src/runtime/routes/playground/index.ts +28 -0
  358. package/src/runtime/routes/playground/inject-failures.ts +159 -0
  359. package/src/runtime/routes/playground/reset-circuit.ts +115 -0
  360. package/src/runtime/routes/playground/seed-conversation.ts +139 -0
  361. package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
  362. package/src/runtime/routes/playground/state.ts +78 -0
  363. package/src/runtime/routes/schedule-routes.ts +89 -8
  364. package/src/runtime/skill-route-registry.ts +75 -15
  365. package/src/schedule/run-script.ts +68 -0
  366. package/src/schedule/schedule-store.ts +7 -1
  367. package/src/schedule/scheduler.ts +48 -8
  368. package/src/skills/catalog-cache.ts +12 -5
  369. package/src/tools/browser/__tests__/browser-status.test.ts +189 -0
  370. package/src/tools/browser/browser-execution.ts +88 -19
  371. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
  372. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
  373. package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
  374. package/src/tools/browser/cdp-client/factory.ts +15 -4
  375. package/src/tools/executor.ts +126 -74
  376. package/src/tools/network/script-proxy/session-manager.ts +37 -1
  377. package/src/tools/permission-checker.ts +98 -49
  378. package/src/tools/policy-context.ts +4 -0
  379. package/src/tools/registry.ts +140 -3
  380. package/src/tools/schedule/create.ts +23 -8
  381. package/src/tools/schedule/update.ts +3 -1
  382. package/src/tools/secret-detection-handler.ts +0 -51
  383. package/src/tools/system/avatar-generator.ts +6 -2
  384. package/src/tools/types.ts +28 -2
  385. package/src/util/platform.ts +7 -2
  386. package/src/util/pricing.ts +26 -3
  387. package/src/workspace/migrations/006-services-config.ts +2 -4
  388. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
  389. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +3 -4
  390. package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
  391. package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
  392. package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
  393. package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
  394. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
  395. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
  396. package/src/workspace/migrations/registry.ts +12 -0
  397. package/tsconfig.json +1 -1
  398. package/hook-templates/debug-prompt-logger/hook.json +0 -7
  399. package/hook-templates/debug-prompt-logger/run.sh +0 -66
  400. package/src/__tests__/compaction-circuit-breaker.test.ts +0 -336
  401. package/src/__tests__/context-overflow-approval.test.ts +0 -156
  402. package/src/__tests__/hooks-blocking.test.ts +0 -178
  403. package/src/__tests__/hooks-cli.test.ts +0 -182
  404. package/src/__tests__/hooks-config.test.ts +0 -108
  405. package/src/__tests__/hooks-discovery.test.ts +0 -211
  406. package/src/__tests__/hooks-integration.test.ts +0 -196
  407. package/src/__tests__/hooks-manager.test.ts +0 -226
  408. package/src/__tests__/hooks-runner.test.ts +0 -175
  409. package/src/__tests__/hooks-settings.test.ts +0 -160
  410. package/src/__tests__/hooks-templates.test.ts +0 -169
  411. package/src/__tests__/hooks-ts-runner.test.ts +0 -170
  412. package/src/__tests__/hooks-watch.test.ts +0 -112
  413. package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
  414. package/src/__tests__/oauth-scope-policy.test.ts +0 -180
  415. package/src/__tests__/send-notification-tool.test.ts +0 -83
  416. package/src/cli/commands/shotgun.ts +0 -266
  417. package/src/config/bundled-skills/conversations/SKILL.md +0 -20
  418. package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
  419. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -88
  420. package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
  421. package/src/config/bundled-skills/notifications/SKILL.md +0 -40
  422. package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
  423. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
  424. package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
  425. package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
  426. package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
  427. package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
  428. package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
  429. package/src/daemon/context-overflow-approval.ts +0 -52
  430. package/src/daemon/watch-handler.ts +0 -399
  431. package/src/hooks/cli.ts +0 -253
  432. package/src/hooks/config.ts +0 -100
  433. package/src/hooks/discovery.ts +0 -135
  434. package/src/hooks/manager.ts +0 -179
  435. package/src/hooks/runner.ts +0 -117
  436. package/src/hooks/templates.ts +0 -77
  437. package/src/hooks/types.ts +0 -75
  438. package/src/oauth/scope-policy.ts +0 -89
  439. package/src/runtime/gateway-internal-client.ts +0 -94
  440. package/src/runtime/routes/watch-routes.ts +0 -156
  441. package/src/signals/shotgun.ts +0 -203
  442. package/src/tools/watch/screen-watch.ts +0 -144
  443. package/src/tools/watch/watch-state.ts +0 -142
@@ -0,0 +1,284 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { ContextWindowResult } from "../../../../context/window-manager.js";
4
+ import type { Conversation } from "../../../../daemon/conversation.js";
5
+ import type { Message } from "../../../../providers/types.js";
6
+ import type { RouteContext } from "../../../http-router.js";
7
+ import type { PlaygroundRouteDeps } from "../deps.js";
8
+ import { forceCompactRouteDefinitions } from "../force-compact.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ interface FakeConversationOptions {
15
+ messagesBefore?: Message[];
16
+ messagesAfter?: Message[];
17
+ result?: Partial<ContextWindowResult>;
18
+ processing?: boolean;
19
+ }
20
+
21
+ interface FakeConversation {
22
+ readonly conversation: Conversation;
23
+ readonly forceCompactCallCount: () => number;
24
+ }
25
+
26
+ function makeFakeConversation(
27
+ options: FakeConversationOptions = {},
28
+ ): FakeConversation {
29
+ const messagesBefore = options.messagesBefore ?? [];
30
+ const messagesAfter = options.messagesAfter ?? messagesBefore;
31
+ let calls = 0;
32
+ let returnedAfter = false;
33
+
34
+ const baseResult: ContextWindowResult = {
35
+ messages: messagesAfter,
36
+ compacted: true,
37
+ previousEstimatedInputTokens: 0,
38
+ estimatedInputTokens: 0,
39
+ maxInputTokens: 100_000,
40
+ thresholdTokens: 80_000,
41
+ compactedMessages: 0,
42
+ compactedPersistedMessages: 0,
43
+ summaryCalls: 0,
44
+ summaryInputTokens: 0,
45
+ summaryOutputTokens: 0,
46
+ summaryModel: "",
47
+ summaryText: "",
48
+ ...options.result,
49
+ };
50
+
51
+ const fake = {
52
+ processing: options.processing ?? false,
53
+ getMessages(): Message[] {
54
+ // First call returns the pre-compaction messages; subsequent calls
55
+ // return the post-compaction messages. This mirrors how the route
56
+ // reads the state twice (before/after `forceCompact()`).
57
+ if (!returnedAfter && calls === 0) return messagesBefore;
58
+ return messagesAfter;
59
+ },
60
+ async forceCompact(): Promise<ContextWindowResult> {
61
+ calls += 1;
62
+ returnedAfter = true;
63
+ return baseResult;
64
+ },
65
+ };
66
+
67
+ return {
68
+ conversation: fake as unknown as Conversation,
69
+ forceCompactCallCount: () => calls,
70
+ };
71
+ }
72
+
73
+ function makeDeps(
74
+ overrides: Partial<PlaygroundRouteDeps> = {},
75
+ ): PlaygroundRouteDeps {
76
+ return {
77
+ getConversationById: async () => undefined,
78
+ isPlaygroundEnabled: () => true,
79
+ listConversationsByTitlePrefix: () => [],
80
+ deleteConversationById: () => false,
81
+ createConversation: async () => ({ id: "conv-test" }),
82
+ addMessage: async () => ({ id: "msg-test" }),
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ function makeRouteContext(id: string): RouteContext {
88
+ const url = new URL(
89
+ `http://localhost/v1/conversations/${id}/playground/compact`,
90
+ );
91
+ return {
92
+ req: new Request(url, { method: "POST" }),
93
+ url,
94
+ server: {} as RouteContext["server"],
95
+ authContext: {
96
+ subject: "test-user",
97
+ principalType: "local",
98
+ assistantId: "self",
99
+ scopeProfile: "local_v1",
100
+ scopes: new Set(["local.all" as const]),
101
+ policyEpoch: 0,
102
+ },
103
+ params: { id },
104
+ } as unknown as RouteContext;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Tests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("forceCompactRouteDefinitions", () => {
112
+ test("exposes a single POST route with the expected endpoint + policy key", () => {
113
+ const routes = forceCompactRouteDefinitions(makeDeps());
114
+ expect(routes).toHaveLength(1);
115
+ expect(routes[0].endpoint).toBe("conversations/:id/playground/compact");
116
+ expect(routes[0].method).toBe("POST");
117
+ expect(routes[0].policyKey).toBe("conversations/playground/compact");
118
+ });
119
+
120
+ test("returns 404 with playground_disabled code when the playground flag is disabled", async () => {
121
+ const deps = makeDeps({ isPlaygroundEnabled: () => false });
122
+ const [route] = forceCompactRouteDefinitions(deps);
123
+
124
+ const res = await route.handler(makeRouteContext("conv-abc"));
125
+ expect(res.status).toBe(404);
126
+
127
+ const body = (await res.json()) as {
128
+ error: { code: string; message: string };
129
+ };
130
+ // Distinct from `conversation_not_found` so the Swift client can
131
+ // surface the right toast text without sniffing the URL path.
132
+ expect(body.error.code).toBe("playground_disabled");
133
+ });
134
+
135
+ test("returns 404 with conversation_not_found code when the conversation is missing", async () => {
136
+ const deps = makeDeps({
137
+ isPlaygroundEnabled: () => true,
138
+ getConversationById: async () => undefined,
139
+ });
140
+ const [route] = forceCompactRouteDefinitions(deps);
141
+
142
+ const res = await route.handler(makeRouteContext("conv-missing"));
143
+ expect(res.status).toBe(404);
144
+
145
+ const body = (await res.json()) as {
146
+ error: { code: string; message: string };
147
+ };
148
+ expect(body.error.code).toBe("conversation_not_found");
149
+ expect(body.error.message).toContain("conv-missing");
150
+ });
151
+
152
+ test("forces compaction and returns before/after tokens + summary metadata", async () => {
153
+ const messagesBefore: Message[] = [
154
+ { role: "user", content: [{ type: "text", text: "hello world" }] },
155
+ {
156
+ role: "assistant",
157
+ content: [{ type: "text", text: "hi there from the assistant" }],
158
+ },
159
+ ];
160
+ const messagesAfter: Message[] = [
161
+ {
162
+ role: "user",
163
+ content: [{ type: "text", text: "hello" }],
164
+ },
165
+ ];
166
+
167
+ const fake = makeFakeConversation({
168
+ messagesBefore,
169
+ messagesAfter,
170
+ result: {
171
+ compacted: true,
172
+ summaryText: "one-line summary of the earlier turns",
173
+ compactedPersistedMessages: 7,
174
+ summaryFailed: false,
175
+ },
176
+ });
177
+
178
+ const deps = makeDeps({
179
+ isPlaygroundEnabled: () => true,
180
+ getConversationById: async () => fake.conversation,
181
+ });
182
+ const [route] = forceCompactRouteDefinitions(deps);
183
+
184
+ const res = await route.handler(makeRouteContext("conv-ok"));
185
+ expect(res.status).toBe(200);
186
+
187
+ const body = (await res.json()) as {
188
+ compacted: boolean;
189
+ previousTokens: number;
190
+ newTokens: number;
191
+ summaryText: string | null;
192
+ messagesRemoved: number;
193
+ summaryFailed: boolean | null;
194
+ };
195
+
196
+ expect(body.compacted).toBe(true);
197
+ expect(body.summaryText).toBe("one-line summary of the earlier turns");
198
+ expect(body.messagesRemoved).toBe(7);
199
+ expect(body.summaryFailed).toBe(false);
200
+ expect(body.previousTokens).toBeGreaterThan(0);
201
+ expect(body.newTokens).toBeGreaterThan(0);
202
+ // The post-compaction message set is strictly smaller, so the
203
+ // reported token count should fall.
204
+ expect(body.newTokens).toBeLessThan(body.previousTokens);
205
+
206
+ expect(fake.forceCompactCallCount()).toBe(1);
207
+ });
208
+
209
+ test("returns 409 and skips forceCompact when conversation is already processing", async () => {
210
+ // Simulate a turn (or a concurrent /compact) already in flight against
211
+ // this conversation. A second playground POST landing in this window
212
+ // would otherwise race with the first call: duplicate
213
+ // `contextCompactedMessageCount` increments, duplicate
214
+ // `context_compacted` SSE events, and double usage recording. Easy to
215
+ // trigger by double-clicking the playground "Force Compact" button.
216
+ const fake = makeFakeConversation({
217
+ messagesBefore: [
218
+ { role: "user", content: [{ type: "text", text: "hi" }] },
219
+ ],
220
+ processing: true,
221
+ });
222
+
223
+ const deps = makeDeps({
224
+ isPlaygroundEnabled: () => true,
225
+ getConversationById: async () => fake.conversation,
226
+ });
227
+ const [route] = forceCompactRouteDefinitions(deps);
228
+
229
+ const res = await route.handler(makeRouteContext("conv-busy"));
230
+ expect(res.status).toBe(409);
231
+
232
+ const body = (await res.json()) as {
233
+ error: { code: string; message: string };
234
+ };
235
+ expect(body.error.code).toBe("CONFLICT");
236
+ expect(body.error.message).toContain("already in progress");
237
+
238
+ // Critical: we must NOT have invoked forceCompact a second time while
239
+ // an existing call was in flight.
240
+ expect(fake.forceCompactCallCount()).toBe(0);
241
+ });
242
+
243
+ test("defaults summaryText/summaryFailed to null when forceCompact omits them", async () => {
244
+ const fake = makeFakeConversation({
245
+ messagesBefore: [
246
+ { role: "user", content: [{ type: "text", text: "hi" }] },
247
+ ],
248
+ messagesAfter: [
249
+ { role: "user", content: [{ type: "text", text: "hi" }] },
250
+ ],
251
+ result: {
252
+ compacted: false,
253
+ // Intentionally leave summaryText as "" and summaryFailed undefined
254
+ // so the route's ?? coalescing is exercised.
255
+ summaryText: "",
256
+ summaryFailed: undefined,
257
+ compactedPersistedMessages: 0,
258
+ },
259
+ });
260
+
261
+ const deps = makeDeps({
262
+ isPlaygroundEnabled: () => true,
263
+ getConversationById: async () => fake.conversation,
264
+ });
265
+ const [route] = forceCompactRouteDefinitions(deps);
266
+
267
+ const res = await route.handler(makeRouteContext("conv-noop"));
268
+ expect(res.status).toBe(200);
269
+
270
+ const body = (await res.json()) as {
271
+ compacted: boolean;
272
+ summaryText: string | null;
273
+ messagesRemoved: number;
274
+ summaryFailed: boolean | null;
275
+ };
276
+
277
+ expect(body.compacted).toBe(false);
278
+ // summaryText is "" (falsy) so `??` keeps it as "" — not null. We only
279
+ // substitute null when the field is nullish, matching the handler.
280
+ expect(body.summaryText).toBe("");
281
+ expect(body.summaryFailed).toBeNull();
282
+ expect(body.messagesRemoved).toBe(0);
283
+ });
284
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { Conversation } from "../../../../daemon/conversation.js";
4
+ import type { PlaygroundRouteDeps } from "../deps.js";
5
+ import { assertPlaygroundEnabled } from "../guard.js";
6
+ import { playgroundRouteDefinitions } from "../index.js";
7
+
8
+ function makeDeps(enabled: boolean): PlaygroundRouteDeps {
9
+ return {
10
+ getConversationById: async (
11
+ _id: string,
12
+ ): Promise<Conversation | undefined> => undefined,
13
+ isPlaygroundEnabled: () => enabled,
14
+ listConversationsByTitlePrefix: () => [],
15
+ deleteConversationById: () => false,
16
+ createConversation: async (_title: string) => ({ id: "conv-test" }),
17
+ addMessage: async (
18
+ _conversationId: string,
19
+ _role: "user" | "assistant",
20
+ _contentJson: string,
21
+ ) => ({ id: "msg-test" }),
22
+ };
23
+ }
24
+
25
+ describe("assertPlaygroundEnabled", () => {
26
+ test("returns a 404 Response when the flag is disabled", async () => {
27
+ const result = assertPlaygroundEnabled(makeDeps(false));
28
+
29
+ expect(result).toBeInstanceOf(Response);
30
+ expect(result?.status).toBe(404);
31
+
32
+ const body = (await result?.json()) as {
33
+ error: { code: string; message: string };
34
+ };
35
+ // The body code must be `playground_disabled` (not the generic
36
+ // `NOT_FOUND`) so the Swift `CompactionPlaygroundClient` can route
37
+ // this to `.notAvailable` rather than `.notFound`. The two cases
38
+ // collide on conv-scoped routes because this guard runs *before*
39
+ // the conversation lookup — the URL alone cannot tell them apart.
40
+ expect(body.error.code).toBe("playground_disabled");
41
+ expect(body.error.message).toBe("Compaction playground is not enabled");
42
+ });
43
+
44
+ test("returns null when the flag is enabled", () => {
45
+ expect(assertPlaygroundEnabled(makeDeps(true))).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe("playgroundRouteDefinitions", () => {
50
+ test("returns route definitions regardless of flag state (guard runs per-request)", () => {
51
+ // The flag check happens inside each route's handler via
52
+ // `assertPlaygroundEnabled`, not at registration time. The aggregator
53
+ // always returns every registered route; each handler returns 404 when
54
+ // the flag is disabled.
55
+ expect(playgroundRouteDefinitions(makeDeps(true)).length).toBeGreaterThan(
56
+ 0,
57
+ );
58
+ expect(playgroundRouteDefinitions(makeDeps(false)).length).toBeGreaterThan(
59
+ 0,
60
+ );
61
+ });
62
+
63
+ test("registers the inject-failures playground route", () => {
64
+ const routes = playgroundRouteDefinitions(makeDeps(true));
65
+ expect(
66
+ routes.some(
67
+ (r) =>
68
+ r.endpoint ===
69
+ "conversations/:id/playground/inject-compaction-failures" &&
70
+ r.method === "POST",
71
+ ),
72
+ ).toBe(true);
73
+ });
74
+
75
+ test("registers the seed-conversation endpoint", () => {
76
+ const routes = playgroundRouteDefinitions(makeDeps(true));
77
+ const endpoints = routes.map((r) => `${r.method} ${r.endpoint}`);
78
+ expect(endpoints).toContain("POST playground/seed-conversation");
79
+ });
80
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Tests for the inject-compaction-failures playground endpoint.
3
+ *
4
+ * This endpoint is dev-only (gated by the `compaction-playground` feature
5
+ * flag) and directly mutates `consecutiveCompactionFailures` and/or
6
+ * `compactionCircuitOpenUntil` on a live `Conversation`. It is used by the
7
+ * macOS playground UI and integration tests to drive the circuit breaker
8
+ * into interesting states without having to wait for three real summary
9
+ * LLM failures.
10
+ */
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ import type { Conversation } from "../../../../daemon/conversation.js";
14
+ import type { ServerMessage } from "../../../../daemon/message-protocol.js";
15
+ import type { RouteContext } from "../../../http-router.js";
16
+ import type { PlaygroundRouteDeps } from "../deps.js";
17
+ import { injectFailuresRouteDefinitions } from "../inject-failures.js";
18
+
19
+ interface MockConversation {
20
+ readonly conversationId: string;
21
+ consecutiveCompactionFailures: number;
22
+ compactionCircuitOpenUntil: number | null;
23
+ contextCompactedMessageCount: number;
24
+ contextCompactedAt: number | null;
25
+ sentMessages: ServerMessage[];
26
+ sendToClient: (msg: ServerMessage) => void;
27
+ getMessages: () => unknown[];
28
+ }
29
+
30
+ function makeConversation(id = "conv-playground-test"): MockConversation {
31
+ const sentMessages: ServerMessage[] = [];
32
+ return {
33
+ conversationId: id,
34
+ consecutiveCompactionFailures: 0,
35
+ compactionCircuitOpenUntil: null,
36
+ contextCompactedMessageCount: 0,
37
+ contextCompactedAt: null,
38
+ sentMessages,
39
+ sendToClient: (msg) => sentMessages.push(msg),
40
+ getMessages: () => [],
41
+ };
42
+ }
43
+
44
+ function makeDeps(
45
+ opts: {
46
+ enabled?: boolean;
47
+ conversation?: MockConversation | undefined;
48
+ } = {},
49
+ ): PlaygroundRouteDeps {
50
+ const enabled = opts.enabled ?? true;
51
+ const conversation = opts.conversation;
52
+ return {
53
+ isPlaygroundEnabled: () => enabled,
54
+ getConversationById: async (id) => {
55
+ if (!conversation) return undefined;
56
+ if (conversation.conversationId !== id) return undefined;
57
+ return conversation as unknown as Conversation;
58
+ },
59
+ listConversationsByTitlePrefix: () => [],
60
+ deleteConversationById: () => false,
61
+ createConversation: async () => ({ id: "conv-test" }),
62
+ addMessage: async () => ({ id: "msg-test" }),
63
+ };
64
+ }
65
+
66
+ function getInjectRoute(deps: PlaygroundRouteDeps) {
67
+ const routes = injectFailuresRouteDefinitions(deps);
68
+ const route = routes.find(
69
+ (r) =>
70
+ r.endpoint ===
71
+ "conversations/:id/playground/inject-compaction-failures" &&
72
+ r.method === "POST",
73
+ );
74
+ if (!route) {
75
+ throw new Error("inject-failures route not registered");
76
+ }
77
+ return route;
78
+ }
79
+
80
+ async function invoke(
81
+ route: ReturnType<typeof getInjectRoute>,
82
+ conversationId: string,
83
+ body: unknown,
84
+ ): Promise<Response> {
85
+ const url = `http://localhost/v1/conversations/${conversationId}/playground/inject-compaction-failures`;
86
+ const req = new Request(url, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify(body),
90
+ });
91
+ return Promise.resolve(
92
+ route.handler({
93
+ req,
94
+ url: new URL(url),
95
+ params: { id: conversationId },
96
+ } as unknown as RouteContext),
97
+ );
98
+ }
99
+
100
+ describe("POST /v1/conversations/:id/playground/inject-compaction-failures", () => {
101
+ test("returns 404 with playground_disabled code when the compaction-playground flag is disabled", async () => {
102
+ const conversation = makeConversation();
103
+ const deps = makeDeps({ enabled: false, conversation });
104
+ const route = getInjectRoute(deps);
105
+
106
+ const res = await invoke(route, conversation.conversationId, {});
107
+ expect(res.status).toBe(404);
108
+
109
+ const body = (await res.json()) as {
110
+ error: { code: string; message: string };
111
+ };
112
+ // Distinct from `conversation_not_found` so the Swift client can
113
+ // surface the right toast text without sniffing the URL path.
114
+ expect(body.error.code).toBe("playground_disabled");
115
+
116
+ // Flag-gated — the handler must not mutate conversation state or emit
117
+ // events when the playground is disabled.
118
+ expect(conversation.sentMessages).toHaveLength(0);
119
+ });
120
+
121
+ test("returns 404 with conversation_not_found code when the conversation is missing", async () => {
122
+ const deps = makeDeps({ enabled: true, conversation: undefined });
123
+ const route = getInjectRoute(deps);
124
+
125
+ const res = await invoke(route, "missing-conv-id", {});
126
+ expect(res.status).toBe(404);
127
+
128
+ const body = (await res.json()) as {
129
+ error: { code: string; message: string };
130
+ };
131
+ expect(body.error.code).toBe("conversation_not_found");
132
+ expect(body.error.message).toContain("missing-conv-id");
133
+ });
134
+
135
+ test("mutates both fields and emits compaction_circuit_open when both provided", async () => {
136
+ const conversation = makeConversation("conv-open");
137
+ const deps = makeDeps({ enabled: true, conversation });
138
+ const route = getInjectRoute(deps);
139
+
140
+ const beforeNow = Date.now();
141
+ const res = await invoke(route, conversation.conversationId, {
142
+ consecutiveFailures: 3,
143
+ circuitOpenForMs: 60_000,
144
+ });
145
+ const afterNow = Date.now();
146
+ expect(res.status).toBe(200);
147
+
148
+ expect(conversation.consecutiveCompactionFailures).toBe(3);
149
+ expect(conversation.compactionCircuitOpenUntil).not.toBeNull();
150
+ const openUntil = conversation.compactionCircuitOpenUntil!;
151
+ expect(openUntil).toBeGreaterThanOrEqual(beforeNow + 60_000);
152
+ expect(openUntil).toBeLessThanOrEqual(afterNow + 60_000);
153
+
154
+ // Exactly one event emitted with the expected shape.
155
+ expect(conversation.sentMessages).toHaveLength(1);
156
+ expect(conversation.sentMessages[0]).toEqual({
157
+ type: "compaction_circuit_open",
158
+ conversationId: conversation.conversationId,
159
+ reason: "3_consecutive_failures",
160
+ openUntil,
161
+ });
162
+ });
163
+
164
+ test("clears the circuit and emits compaction_circuit_closed on circuitOpenForMs: 0", async () => {
165
+ const conversation = makeConversation("conv-close");
166
+ // Start with an open breaker so the endpoint can clear it.
167
+ conversation.compactionCircuitOpenUntil = Date.now() + 10_000;
168
+ conversation.consecutiveCompactionFailures = 3;
169
+
170
+ const deps = makeDeps({ enabled: true, conversation });
171
+ const route = getInjectRoute(deps);
172
+
173
+ const res = await invoke(route, conversation.conversationId, {
174
+ circuitOpenForMs: 0,
175
+ });
176
+ expect(res.status).toBe(200);
177
+
178
+ expect(conversation.compactionCircuitOpenUntil).toBeNull();
179
+ // consecutiveFailures was not specified in the body, so it must be
180
+ // unchanged (the endpoint only mutates fields that are explicitly set).
181
+ expect(conversation.consecutiveCompactionFailures).toBe(3);
182
+
183
+ expect(conversation.sentMessages).toHaveLength(1);
184
+ expect(conversation.sentMessages[0]).toEqual({
185
+ type: "compaction_circuit_closed",
186
+ conversationId: conversation.conversationId,
187
+ });
188
+ });
189
+
190
+ test("is a no-op on the event channel when circuitOpenForMs: 0 but the breaker is already closed", async () => {
191
+ const conversation = makeConversation("conv-already-closed");
192
+ // Breaker is already closed before the request.
193
+ expect(conversation.compactionCircuitOpenUntil).toBeNull();
194
+
195
+ const deps = makeDeps({ enabled: true, conversation });
196
+ const route = getInjectRoute(deps);
197
+
198
+ const res = await invoke(route, conversation.conversationId, {
199
+ circuitOpenForMs: 0,
200
+ });
201
+ expect(res.status).toBe(200);
202
+
203
+ // Still null after the request.
204
+ expect(conversation.compactionCircuitOpenUntil).toBeNull();
205
+ // Critically: no `compaction_circuit_closed` event is emitted, since
206
+ // there was no open→closed transition. Clients must not see a spurious
207
+ // close event.
208
+ expect(conversation.sentMessages).toHaveLength(0);
209
+
210
+ // Response body still reflects the expected shape.
211
+ const body = (await res.json()) as Record<string, unknown>;
212
+ expect(body.compactionCircuitOpenUntil).toBeNull();
213
+ expect(body.isCircuitOpen).toBe(false);
214
+ });
215
+
216
+ test("rejects out-of-range consecutiveFailures with 400", async () => {
217
+ const conversation = makeConversation();
218
+ const deps = makeDeps({ enabled: true, conversation });
219
+ const route = getInjectRoute(deps);
220
+
221
+ const res = await invoke(route, conversation.conversationId, {
222
+ consecutiveFailures: 99, // above max (10)
223
+ });
224
+ expect(res.status).toBe(400);
225
+
226
+ // No mutation, no event.
227
+ expect(conversation.consecutiveCompactionFailures).toBe(0);
228
+ expect(conversation.sentMessages).toHaveLength(0);
229
+ });
230
+
231
+ test("rejects out-of-range circuitOpenForMs with 400", async () => {
232
+ const conversation = makeConversation();
233
+ const deps = makeDeps({ enabled: true, conversation });
234
+ const route = getInjectRoute(deps);
235
+
236
+ const res = await invoke(route, conversation.conversationId, {
237
+ circuitOpenForMs: 25 * 60 * 60 * 1000, // 25h, above the 24h cap
238
+ });
239
+ expect(res.status).toBe(400);
240
+
241
+ expect(conversation.compactionCircuitOpenUntil).toBeNull();
242
+ expect(conversation.sentMessages).toHaveLength(0);
243
+ });
244
+
245
+ test("rejects negative consecutiveFailures with 400", async () => {
246
+ const conversation = makeConversation();
247
+ const deps = makeDeps({ enabled: true, conversation });
248
+ const route = getInjectRoute(deps);
249
+
250
+ const res = await invoke(route, conversation.conversationId, {
251
+ consecutiveFailures: -1,
252
+ });
253
+ expect(res.status).toBe(400);
254
+ expect(conversation.consecutiveCompactionFailures).toBe(0);
255
+ });
256
+
257
+ test("response body includes the full CompactionStateResponse shape", async () => {
258
+ const conversation = makeConversation("conv-shape");
259
+ const deps = makeDeps({ enabled: true, conversation });
260
+ const route = getInjectRoute(deps);
261
+
262
+ const res = await invoke(route, conversation.conversationId, {
263
+ consecutiveFailures: 2,
264
+ });
265
+ expect(res.status).toBe(200);
266
+
267
+ const body = (await res.json()) as Record<string, unknown>;
268
+ const requiredKeys = [
269
+ "estimatedInputTokens",
270
+ "maxInputTokens",
271
+ "compactThresholdRatio",
272
+ "thresholdTokens",
273
+ "messageCount",
274
+ "contextCompactedMessageCount",
275
+ "contextCompactedAt",
276
+ "consecutiveCompactionFailures",
277
+ "compactionCircuitOpenUntil",
278
+ "isCircuitOpen",
279
+ "isCompactionEnabled",
280
+ ];
281
+ for (const key of requiredKeys) {
282
+ expect(body).toHaveProperty(key);
283
+ }
284
+ expect(body.consecutiveCompactionFailures).toBe(2);
285
+ expect(body.isCircuitOpen).toBe(false);
286
+ expect(body.compactionCircuitOpenUntil).toBeNull();
287
+ expect(typeof body.estimatedInputTokens).toBe("number");
288
+ expect(typeof body.maxInputTokens).toBe("number");
289
+ expect(typeof body.compactThresholdRatio).toBe("number");
290
+ expect(typeof body.thresholdTokens).toBe("number");
291
+ expect(typeof body.messageCount).toBe("number");
292
+ expect(typeof body.isCompactionEnabled).toBe("boolean");
293
+ });
294
+ });