@vellumai/assistant 0.8.7 → 0.8.8

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 (387) hide show
  1. package/Dockerfile +20 -4
  2. package/docker-entrypoint.sh +4 -2
  3. package/docker-init-apt-root.sh +3 -1
  4. package/docker-kata-apt-env.sh +3 -1
  5. package/docker-kata-runtime-family.sh +12 -0
  6. package/docs/architecture/memory.md +1 -1
  7. package/docs/plugins.md +75 -79
  8. package/examples/plugins/echo/README.md +6 -12
  9. package/examples/plugins/echo/register.ts +0 -41
  10. package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +3 -3
  11. package/openapi.yaml +3381 -348
  12. package/package.json +1 -1
  13. package/scripts/generate-openapi.ts +68 -41
  14. package/src/__tests__/agent-loop-exit-reason.test.ts +34 -39
  15. package/src/__tests__/agent-loop-provider-error-recording.test.ts +1 -1
  16. package/src/__tests__/agent-loop.test.ts +37 -87
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  18. package/src/__tests__/annotate-activity-metadata.test.ts +262 -0
  19. package/src/__tests__/annotate-risk-options.test.ts +2 -3
  20. package/src/__tests__/anthropic-provider.test.ts +95 -2
  21. package/src/__tests__/assistant-event-hub.test.ts +25 -0
  22. package/src/__tests__/assistant-events-sse-shed.test.ts +8 -0
  23. package/src/__tests__/{conversation-stream-state.test.ts → assistant-stream-state.test.ts} +252 -91
  24. package/src/__tests__/auth-fallback-events-store.test.ts +116 -0
  25. package/src/__tests__/background-workers-disk-pressure.test.ts +6 -0
  26. package/src/__tests__/btw-routes.test.ts +62 -3
  27. package/src/__tests__/build-persisted-content.test.ts +184 -0
  28. package/src/__tests__/catalog-files.test.ts +1 -1
  29. package/src/__tests__/clawhub-files.test.ts +1 -1
  30. package/src/__tests__/compaction-pipeline.test.ts +1 -1
  31. package/src/__tests__/compaction.benchmark.test.ts +0 -30
  32. package/src/__tests__/config-watcher.test.ts +1 -1
  33. package/src/__tests__/conversation-abort-tool-results.test.ts +57 -19
  34. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +6 -2
  35. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +10 -4
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +313 -1136
  37. package/src/__tests__/conversation-agent-loop.test.ts +596 -1616
  38. package/src/__tests__/conversation-analysis-routes.test.ts +6 -0
  39. package/src/__tests__/conversation-history-web-search.test.ts +11 -1
  40. package/src/__tests__/conversation-pairing.test.ts +4 -31
  41. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +6 -0
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -5
  43. package/src/__tests__/conversation-queue.test.ts +2 -0
  44. package/src/__tests__/conversation-routes-disk-view.test.ts +3 -0
  45. package/src/__tests__/conversation-routes-slash-commands.test.ts +6 -5
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +170 -229
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +3 -24
  48. package/src/__tests__/conversation-slash-commands.test.ts +8 -42
  49. package/src/__tests__/conversation-slash-queue.test.ts +6 -1
  50. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +84 -0
  51. package/src/__tests__/conversation-sync-tags.test.ts +27 -15
  52. package/src/__tests__/conversation-title-service.test.ts +135 -2
  53. package/src/__tests__/conversation-workspace-injection.test.ts +6 -1
  54. package/src/__tests__/cross-provider-web-search.test.ts +214 -1
  55. package/src/__tests__/db-schedule-syntax-migration.test.ts +5 -0
  56. package/src/__tests__/dm-persistence.test.ts +5 -1
  57. package/src/__tests__/empty-response-hook.test.ts +304 -0
  58. package/src/__tests__/feature-flag-test-helpers.ts +2 -2
  59. package/src/__tests__/gemini-image-service.test.ts +13 -0
  60. package/src/__tests__/helpers/mock-provider.ts +110 -0
  61. package/src/__tests__/helpers/native-web-search-harness.ts +129 -0
  62. package/src/__tests__/history-repair-hook.test.ts +1 -0
  63. package/src/__tests__/identity-intro-cache.test.ts +12 -100
  64. package/src/__tests__/identity-routes.test.ts +248 -7
  65. package/src/__tests__/inbound-slack-persistence.test.ts +5 -1
  66. package/src/__tests__/injector-background-turn.test.ts +2 -8
  67. package/src/__tests__/injector-chain.test.ts +106 -270
  68. package/src/__tests__/injector-disk-pressure.test.ts +3 -12
  69. package/src/__tests__/injector-document-comments.test.ts +2 -2
  70. package/src/__tests__/injector-pkb-v2-silenced.test.ts +30 -22
  71. package/src/__tests__/injector-v3-suppression.test.ts +31 -37
  72. package/src/__tests__/internal-telemetry-routes.test.ts +109 -0
  73. package/src/__tests__/list-messages-page-latest.test.ts +60 -0
  74. package/src/__tests__/list-messages-tool-merge.test.ts +20 -0
  75. package/src/__tests__/llm-usage-store.test.ts +223 -1
  76. package/src/__tests__/memory-retrieval-hook.test.ts +297 -0
  77. package/src/__tests__/memory-v2-static-injector.test.ts +103 -35
  78. package/src/__tests__/native-web-search.test.ts +191 -0
  79. package/src/__tests__/onboarding-template-contract.test.ts +2 -0
  80. package/src/__tests__/openai-image-service.test.ts +17 -0
  81. package/src/__tests__/openai-provider.test.ts +31 -1
  82. package/src/__tests__/persist-unsendable-image.test.ts +215 -0
  83. package/src/__tests__/persistence-secret-redaction.test.ts +1 -0
  84. package/src/__tests__/pipeline-runner.test.ts +29 -39
  85. package/src/__tests__/pkb-autoinject.test.ts +2 -5
  86. package/src/__tests__/plugin-bootstrap.test.ts +13 -28
  87. package/src/__tests__/plugin-registry.test.ts +0 -27
  88. package/src/__tests__/plugin-types.test.ts +2 -125
  89. package/src/__tests__/process-message-display-content.test.ts +6 -2
  90. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +5 -1
  91. package/src/__tests__/resolve-trust-class.test.ts +4 -4
  92. package/src/__tests__/runtime-events-sse-reconnect.test.ts +60 -23
  93. package/src/__tests__/schedule-routes.test.ts +603 -2
  94. package/src/__tests__/schedule-store.test.ts +41 -0
  95. package/src/__tests__/schedule-tools.test.ts +35 -0
  96. package/src/__tests__/server-history-render.test.ts +314 -1
  97. package/src/__tests__/skillssh-files.test.ts +1 -1
  98. package/src/__tests__/system-prompt.test.ts +20 -0
  99. package/src/__tests__/task-scheduler.test.ts +162 -1
  100. package/src/__tests__/terminal-tools.test.ts +6 -1
  101. package/src/__tests__/title-generate-hook.test.ts +319 -0
  102. package/src/__tests__/tool-error-hook.test.ts +278 -0
  103. package/src/__tests__/tool-preview-lifecycle.test.ts +468 -5
  104. package/src/__tests__/tool-result-metadata-plumbing.test.ts +1 -0
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +127 -0
  106. package/src/__tests__/tool-result-truncation.test.ts +0 -2
  107. package/src/__tests__/ui-choice-copy-surfaces.test.ts +254 -0
  108. package/src/__tests__/ui-work-result-surface.test.ts +159 -0
  109. package/src/__tests__/usage-routes.test.ts +285 -1
  110. package/src/__tests__/user-plugin-loader.test.ts +2 -2
  111. package/src/__tests__/voice-session-bridge.test.ts +6 -3
  112. package/src/__tests__/web-search-backend-failure.test.ts +166 -0
  113. package/src/agent/loop.ts +346 -442
  114. package/src/api/events/assistant-thinking-delta.ts +33 -0
  115. package/src/api/events/tool-output-chunk.ts +45 -0
  116. package/src/api/events/tool-use-preview-start.ts +32 -0
  117. package/src/api/events/trace-event.ts +69 -0
  118. package/src/api/index.ts +48 -13
  119. package/src/api/responses/conversation-message.ts +368 -0
  120. package/src/avatar/__tests__/avatar-store.test.ts +34 -29
  121. package/src/cli/commands/__tests__/notifications.test.ts +58 -14
  122. package/src/cli/commands/notifications.ts +112 -60
  123. package/src/config/assistant-feature-flags.ts +22 -11
  124. package/src/config/bundled-skills/app-builder/SKILL.md +3 -20
  125. package/src/config/bundled-skills/app-builder/references/examples/README.md +17 -0
  126. package/src/config/bundled-skills/app-builder/references/examples/expense-tracker.md +515 -0
  127. package/src/config/bundled-skills/app-builder/references/examples/focus-timer.md +342 -0
  128. package/src/config/bundled-skills/app-builder/references/examples/habit-tracker.md +490 -0
  129. package/src/config/bundled-skills/document-editor/SKILL.md +1 -1
  130. package/src/config/bundled-skills/messaging/SKILL.md +0 -7
  131. package/src/config/feature-flag-cache.ts +3 -3
  132. package/src/config/feature-flag-registry.json +35 -3
  133. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  134. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -0
  135. package/src/config/schemas/llm.ts +1 -0
  136. package/src/config/schemas/memory-v2.ts +8 -0
  137. package/src/config/schemas/memory-v3.ts +8 -0
  138. package/src/config/schemas/platform.ts +8 -0
  139. package/src/config/seed-inference-profiles.ts +2 -2
  140. package/src/config/skills.ts +13 -0
  141. package/src/context/compactor.ts +1 -1
  142. package/src/context/strip-injections.ts +122 -0
  143. package/src/context/token-estimator.ts +23 -0
  144. package/src/context/tool-result-truncation.ts +0 -23
  145. package/src/context/window-manager.ts +3 -6
  146. package/src/credential-execution/executable-discovery.ts +16 -0
  147. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +6 -0
  148. package/src/daemon/__tests__/inference-profile-notification.test.ts +153 -0
  149. package/src/daemon/__tests__/native-web-search-metadata.test.ts +10 -8
  150. package/src/daemon/assistant-attachments.ts +1 -1
  151. package/src/daemon/config-watcher.ts +2 -2
  152. package/src/daemon/context-overflow-reducer.ts +0 -1
  153. package/src/daemon/conversation-agent-loop-handlers.ts +605 -153
  154. package/src/daemon/conversation-agent-loop.ts +281 -760
  155. package/src/daemon/conversation-history.ts +5 -4
  156. package/src/daemon/conversation-lifecycle.ts +3 -4
  157. package/src/daemon/conversation-messaging.ts +7 -6
  158. package/src/daemon/conversation-process.ts +11 -16
  159. package/src/daemon/conversation-runtime-assembly.ts +130 -347
  160. package/src/daemon/conversation-slash.ts +6 -25
  161. package/src/daemon/conversation-surfaces.ts +222 -4
  162. package/src/daemon/conversation-tool-setup.ts +2 -29
  163. package/src/daemon/conversation.ts +32 -14
  164. package/src/daemon/external-plugins-bootstrap.ts +9 -10
  165. package/src/daemon/handlers/config-a2a.ts +51 -36
  166. package/src/daemon/handlers/config-slack-channel.ts +20 -14
  167. package/src/daemon/handlers/config-telegram.ts +16 -2
  168. package/src/daemon/handlers/shared.ts +156 -84
  169. package/src/daemon/handlers/skills.ts +39 -10
  170. package/src/daemon/lifecycle.ts +4 -0
  171. package/src/daemon/message-types/apps.ts +1 -29
  172. package/src/daemon/message-types/messages.ts +9 -57
  173. package/src/daemon/message-types/skills.ts +2 -0
  174. package/src/daemon/message-types/surfaces.ts +136 -3
  175. package/src/daemon/now-scratchpad.ts +21 -0
  176. package/src/daemon/orphan-reaper.test.ts +210 -0
  177. package/src/daemon/orphan-reaper.ts +240 -0
  178. package/src/daemon/persist-unsendable-image.ts +117 -0
  179. package/src/daemon/process-message.ts +1 -3
  180. package/src/daemon/trace-emitter.ts +6 -4
  181. package/src/daemon/trust-context.ts +19 -0
  182. package/src/daemon/wake-target-adapter.ts +3 -1
  183. package/src/home/home-greeting-cache.ts +24 -1
  184. package/src/ipc/gateway-client.test.ts +2 -2
  185. package/src/ipc/gateway-client.ts +3 -3
  186. package/src/media/gemini-image-service.ts +15 -0
  187. package/src/media/openai-image-service.ts +14 -0
  188. package/src/media/types.ts +34 -0
  189. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +56 -0
  190. package/src/memory/auth-fallback-events-store.ts +94 -0
  191. package/src/memory/conversation-title-service.ts +65 -41
  192. package/src/memory/db-init.ts +4 -0
  193. package/src/memory/graph/__tests__/conversation-graph-memory-registry.test.ts +119 -0
  194. package/src/memory/graph/conversation-graph-memory.ts +65 -0
  195. package/src/memory/jobs-store.ts +33 -0
  196. package/src/memory/jobs-worker.ts +31 -4
  197. package/src/memory/llm-usage-store.ts +224 -50
  198. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +6 -5
  199. package/src/memory/migrations/270-schedule-source-conversation.ts +13 -0
  200. package/src/memory/migrations/271-create-auth-fallback-events.ts +21 -0
  201. package/src/memory/migrations/index.ts +2 -0
  202. package/src/memory/pkb/autoinject.ts +61 -0
  203. package/src/memory/pkb/context.ts +50 -0
  204. package/src/memory/pkb/types.ts +14 -0
  205. package/src/memory/schedule-attribution-sql.ts +104 -0
  206. package/src/memory/schema/infrastructure.ts +16 -0
  207. package/src/memory/usage-grouped-buckets.ts +6 -1
  208. package/src/memory/v2/__tests__/consolidation-job.test.ts +1 -1
  209. package/src/memory/v2/consolidation-job.ts +1 -1
  210. package/src/memory/v3/__tests__/health.test.ts +16 -0
  211. package/src/memory/v3/__tests__/orchestrate.test.ts +45 -9
  212. package/src/memory/v3/__tests__/provider-blocks.test.ts +13 -0
  213. package/src/memory/v3/__tests__/router.test.ts +101 -29
  214. package/src/memory/v3/__tests__/selector.test.ts +93 -27
  215. package/src/memory/v3/__tests__/shadow-plugin.test.ts +23 -5
  216. package/src/memory/v3/health.ts +0 -0
  217. package/src/memory/v3/llm-retry.ts +32 -0
  218. package/src/memory/v3/orchestrate.ts +26 -14
  219. package/src/memory/v3/provider-blocks.ts +15 -5
  220. package/src/memory/v3/router.ts +48 -42
  221. package/src/memory/v3/selector.ts +57 -42
  222. package/src/memory/v3/shadow-plugin.ts +47 -15
  223. package/src/memory/v3/types.ts +8 -0
  224. package/src/notifications/conversation-pairing.ts +8 -15
  225. package/src/notifications/decision-engine.ts +6 -3
  226. package/src/notifications/home-feed-side-effect.ts +12 -1
  227. package/src/permissions/prompter.ts +4 -0
  228. package/src/plugin-api/constants.ts +4 -0
  229. package/src/plugin-api/index.ts +8 -1
  230. package/src/plugin-api/types.ts +151 -1
  231. package/src/plugins/defaults/empty-response/hooks/stop.ts +126 -0
  232. package/src/plugins/defaults/empty-response/register.ts +8 -13
  233. package/src/plugins/defaults/index.ts +1 -15
  234. package/src/plugins/defaults/injectors/register.ts +243 -74
  235. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +91 -0
  236. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +216 -0
  237. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +35 -0
  238. package/src/plugins/defaults/title-generate/hooks/stop.ts +75 -0
  239. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +35 -0
  240. package/src/plugins/defaults/title-generate/package.json +1 -1
  241. package/src/plugins/defaults/title-generate/register.ts +18 -18
  242. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +118 -0
  243. package/src/plugins/defaults/tool-error/package.json +1 -1
  244. package/src/plugins/defaults/tool-error/register.ts +9 -21
  245. package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +32 -0
  246. package/src/plugins/defaults/tool-result-truncate/register.ts +10 -21
  247. package/src/plugins/defaults/tool-result-truncate/terminal.ts +37 -18
  248. package/src/plugins/pipeline.ts +6 -18
  249. package/src/plugins/registry.ts +8 -25
  250. package/src/plugins/types.ts +43 -474
  251. package/src/proactive-artifact/aux-message-injector.ts +3 -3
  252. package/src/proactive-artifact/job.test.ts +7 -12
  253. package/src/prompts/__tests__/system-prompt.test.ts +36 -0
  254. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +62 -0
  255. package/src/prompts/templates/BOOTSTRAP.md +2 -2
  256. package/src/prompts/templates/system-sections.ts +15 -0
  257. package/src/providers/anthropic/client.ts +37 -29
  258. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +112 -0
  259. package/src/providers/openai/chat-completions-provider.ts +44 -0
  260. package/src/providers/openrouter/client.ts +1 -0
  261. package/src/providers/placeholder-sentinels.ts +35 -0
  262. package/src/runtime/__tests__/agent-wake.test.ts +5 -1
  263. package/src/runtime/agent-wake.ts +2 -2
  264. package/src/runtime/assistant-event-hub.ts +36 -6
  265. package/src/runtime/{conversation-stream-state.ts → assistant-stream-state.ts} +132 -58
  266. package/src/runtime/http-router.ts +16 -21
  267. package/src/runtime/http-types.ts +16 -70
  268. package/src/runtime/pending-interactions.ts +1 -0
  269. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +265 -2
  270. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +31 -1
  271. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +6 -2
  272. package/src/runtime/routes/__tests__/tts-routes.test.ts +6 -2
  273. package/src/runtime/routes/app-management-routes.ts +6 -117
  274. package/src/runtime/routes/app-routes.ts +13 -15
  275. package/src/runtime/routes/attachment-routes.ts +26 -15
  276. package/src/runtime/routes/avatar-routes.ts +26 -0
  277. package/src/runtime/routes/btw-routes.ts +29 -23
  278. package/src/runtime/routes/consolidation-routes.ts +120 -20
  279. package/src/runtime/routes/conversation-query-routes.ts +2 -0
  280. package/src/runtime/routes/conversation-routes.ts +358 -184
  281. package/src/runtime/routes/documents-routes.ts +4 -0
  282. package/src/runtime/routes/domain-routes.ts +51 -37
  283. package/src/runtime/routes/epoch-millis-range.ts +34 -0
  284. package/src/runtime/routes/events-routes.ts +28 -34
  285. package/src/runtime/routes/gateway-log-routes.ts +26 -4
  286. package/src/runtime/routes/heartbeat-routes.ts +32 -12
  287. package/src/runtime/routes/identity-intro-cache.ts +11 -34
  288. package/src/runtime/routes/identity-routes.ts +208 -17
  289. package/src/runtime/routes/image-generation-routes.ts +40 -2
  290. package/src/runtime/routes/index.ts +2 -0
  291. package/src/runtime/routes/integrations/a2a.ts +12 -10
  292. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +16 -0
  293. package/src/runtime/routes/integrations/slack/channel.ts +4 -0
  294. package/src/runtime/routes/integrations/slack/share.ts +27 -6
  295. package/src/runtime/routes/integrations/telegram.ts +6 -0
  296. package/src/runtime/routes/integrations/twilio.ts +42 -0
  297. package/src/runtime/routes/internal-telemetry-routes.ts +88 -0
  298. package/src/runtime/routes/log-export-routes.ts +8 -0
  299. package/src/runtime/routes/memory-v2-routes.ts +15 -8
  300. package/src/runtime/routes/memory-v3-routes.ts +50 -28
  301. package/src/runtime/routes/oauth-apps.ts +66 -12
  302. package/src/runtime/routes/oauth-providers.ts +44 -5
  303. package/src/runtime/routes/platform-routes.ts +81 -5
  304. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +6 -4
  305. package/src/runtime/routes/playground/force-compact.ts +1 -1
  306. package/src/runtime/routes/rename-conversation-routes.ts +5 -0
  307. package/src/runtime/routes/schedule-routes.ts +152 -42
  308. package/src/runtime/routes/secret-routes.ts +14 -2
  309. package/src/runtime/routes/skills-routes.ts +43 -14
  310. package/src/runtime/routes/tool-call-confirmation-enrichment.test.ts +161 -0
  311. package/src/runtime/routes/tool-call-confirmation-enrichment.ts +107 -0
  312. package/src/runtime/routes/trust-rules-routes.ts +26 -2
  313. package/src/runtime/routes/tts-routes.ts +35 -0
  314. package/src/runtime/routes/types.ts +66 -8
  315. package/src/runtime/routes/usage-routes.ts +47 -39
  316. package/src/runtime/routes/webhook-routes.ts +41 -2
  317. package/src/runtime/routes/workspace-routes.ts +4 -0
  318. package/src/runtime/services/__tests__/analyze-conversation.test.ts +6 -0
  319. package/src/runtime/services/analyze-conversation.ts +2 -2
  320. package/src/schedule/schedule-store.ts +20 -1
  321. package/src/schedule/schedule-usage-store.ts +83 -0
  322. package/src/schedule/scheduler.ts +12 -5
  323. package/src/skills/catalog-files.ts +2 -2
  324. package/src/skills/catalog-install.ts +3 -0
  325. package/src/skills/categories-cache.ts +118 -0
  326. package/src/skills/clawhub-files.ts +1 -2
  327. package/src/skills/skillssh-files.ts +1 -2
  328. package/src/telemetry/types.ts +29 -1
  329. package/src/telemetry/usage-telemetry-reporter.test.ts +112 -3
  330. package/src/telemetry/usage-telemetry-reporter.ts +57 -2
  331. package/src/tools/executor.ts +1 -53
  332. package/src/tools/network/__tests__/web-search-metadata.test.ts +7 -1
  333. package/src/tools/network/__tests__/web-search.test.ts +11 -3
  334. package/src/tools/network/web-search-error.test.ts +248 -0
  335. package/src/tools/network/web-search-error.ts +267 -0
  336. package/src/tools/network/web-search.ts +207 -48
  337. package/src/tools/schedule/create.ts +2 -0
  338. package/src/tools/terminal/safe-env.ts +10 -1
  339. package/src/tools/ui-surface/definitions.ts +9 -1
  340. package/src/tts/__tests__/provider-catalog-consistency.test.ts +85 -1
  341. package/src/tts/provider-catalog.ts +76 -1
  342. package/src/util/mutex.ts +47 -0
  343. package/src/workspace/git-service.ts +1 -42
  344. package/src/workspace/migrations/095-bump-heartbeat-interval-30m-to-60m.ts +51 -0
  345. package/src/workspace/migrations/096-reduce-quality-profile-effort.ts +72 -0
  346. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +93 -0
  347. package/src/workspace/migrations/registry.ts +6 -0
  348. package/src/__tests__/bootstrap-turn-cleanup.test.ts +0 -44
  349. package/src/__tests__/empty-response-pipeline.test.ts +0 -423
  350. package/src/__tests__/llm-call-pipeline.test.ts +0 -287
  351. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -418
  352. package/src/__tests__/persistence-pipeline.test.ts +0 -503
  353. package/src/__tests__/title-generate-pipeline.test.ts +0 -211
  354. package/src/__tests__/token-estimate-pipeline.test.ts +0 -479
  355. package/src/__tests__/tool-error-pipeline.test.ts +0 -241
  356. package/src/__tests__/tool-execute-pipeline.test.ts +0 -417
  357. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -341
  358. package/src/daemon/bootstrap-turn-cleanup.ts +0 -45
  359. package/src/gallery/default-gallery.ts +0 -1359
  360. package/src/gallery/gallery-manifest.ts +0 -28
  361. package/src/home/feature-gate.ts +0 -22
  362. package/src/plugins/defaults/empty-response/middlewares/emptyResponse.ts +0 -22
  363. package/src/plugins/defaults/empty-response/terminal.ts +0 -106
  364. package/src/plugins/defaults/injectors/package.json +0 -15
  365. package/src/plugins/defaults/llm-call/middlewares/llmCall.ts +0 -17
  366. package/src/plugins/defaults/llm-call/package.json +0 -15
  367. package/src/plugins/defaults/llm-call/register.ts +0 -45
  368. package/src/plugins/defaults/memory-retrieval/middlewares/memoryRetrieval.ts +0 -17
  369. package/src/plugins/defaults/memory-retrieval/package.json +0 -15
  370. package/src/plugins/defaults/memory-retrieval/register.ts +0 -181
  371. package/src/plugins/defaults/persistence/middlewares/persistence.ts +0 -19
  372. package/src/plugins/defaults/persistence/package.json +0 -15
  373. package/src/plugins/defaults/persistence/register.ts +0 -38
  374. package/src/plugins/defaults/persistence/terminal.ts +0 -83
  375. package/src/plugins/defaults/title-generate/terminal.ts +0 -31
  376. package/src/plugins/defaults/token-estimate/middlewares/tokenEstimate.ts +0 -23
  377. package/src/plugins/defaults/token-estimate/package.json +0 -15
  378. package/src/plugins/defaults/token-estimate/register.ts +0 -34
  379. package/src/plugins/defaults/token-estimate/terminal.ts +0 -40
  380. package/src/plugins/defaults/tool-error/middlewares/toolError.ts +0 -21
  381. package/src/plugins/defaults/tool-error/terminal.ts +0 -47
  382. package/src/plugins/defaults/tool-execute/middlewares/toolExecute.ts +0 -23
  383. package/src/plugins/defaults/tool-execute/package.json +0 -15
  384. package/src/plugins/defaults/tool-execute/register.ts +0 -49
  385. package/src/plugins/defaults/tool-result-truncate/middlewares/toolResultTruncate.ts +0 -23
  386. package/src/plugins/defaults/tool-result-truncate/types.ts +0 -22
  387. package/src/skills/category-inference.ts +0 -111
@@ -140,53 +140,19 @@ describe("resolveSlash command contract", () => {
140
140
  });
141
141
  });
142
142
 
143
- describe("resolveSlash /compact target override", () => {
144
- test("plain /compact returns no override", async () => {
143
+ describe("resolveSlash /compact", () => {
144
+ test("plain /compact resolves to kind=compact", async () => {
145
145
  const result = await resolveSlash("/compact");
146
146
  expect(result).toEqual({ kind: "compact" });
147
147
  });
148
148
 
149
- test("/compact <integer> sets explicit token target", async () => {
149
+ test("/compact rejects arguments with usage hint", async () => {
150
150
  const result = await resolveSlash("/compact 30000");
151
- expect(result).toEqual({
152
- kind: "compact",
153
- targetInputTokensOverride: 30000,
154
- });
155
- });
156
-
157
- test("/compact <n>k expands to thousands", async () => {
158
- const result = await resolveSlash("/compact 30k");
159
- expect(result).toEqual({
160
- kind: "compact",
161
- targetInputTokensOverride: 30_000,
162
- });
163
- });
164
-
165
- test("/compact <n>m expands to millions", async () => {
166
- const result = await resolveSlash("/compact 1.5M");
167
- expect(result).toEqual({
168
- kind: "compact",
169
- targetInputTokensOverride: 1_500_000,
170
- });
171
- });
172
-
173
- test("/compact rejects malformed args with usage hint", async () => {
174
- const result = await resolveSlash("/compact bogus");
175
151
  expect(result.kind).toBe("unknown");
176
152
  if (result.kind !== "unknown") throw new Error("expected unknown");
177
- expect(result.message).toContain("`bogus`");
153
+ expect(result.message).toContain("does not take arguments");
178
154
  expect(result.message).toContain("/compact");
179
155
  });
180
-
181
- test("/compact rejects zero", async () => {
182
- const result = await resolveSlash("/compact 0");
183
- expect(result.kind).toBe("unknown");
184
- });
185
-
186
- test("/compact rejects negative numbers", async () => {
187
- const result = await resolveSlash("/compact -50");
188
- expect(result.kind).toBe("unknown");
189
- });
190
156
  });
191
157
 
192
158
  describe("resolveSlash /clean", () => {
@@ -227,9 +193,9 @@ describe("classifySlash is a pure classifier matching resolveSlash kinds", () =>
227
193
  { input: "/status", kind: "unknown" },
228
194
  { input: "/commands", kind: "unknown" },
229
195
  { input: "/compact", kind: "compact" },
230
- { input: "/compact 30000", kind: "compact" },
231
- { input: "/compact 30k", kind: "compact" },
232
- { input: "/compact 1.5M", kind: "compact" },
196
+ { input: "/compact 30000", kind: "unknown" },
197
+ { input: "/compact 30k", kind: "unknown" },
198
+ { input: "/compact 1.5M", kind: "unknown" },
233
199
  { input: "/compact bogus", kind: "unknown" },
234
200
  { input: "/clean", kind: "clean" },
235
201
  { input: " /clean ", kind: "clean" },
@@ -239,7 +205,7 @@ describe("classifySlash is a pure classifier matching resolveSlash kinds", () =>
239
205
  { input: "/opus", kind: "unknown" },
240
206
  { input: "hello", kind: "passthrough" },
241
207
  { input: " /compact ", kind: "compact" },
242
- { input: " /compact 50k ", kind: "compact" },
208
+ { input: " /compact 50k ", kind: "unknown" },
243
209
  { input: "/models foo", kind: "passthrough" },
244
210
  ];
245
211
 
@@ -228,7 +228,12 @@ mock.module("../agent/loop.js", () => ({
228
228
  const history = await new Promise<Message[]>((resolve) => {
229
229
  pendingRuns.push({ resolve, messages, onEvent });
230
230
  });
231
- return { history, exitReason: null };
231
+ return {
232
+ history,
233
+ exitReason: null,
234
+ appendedNewMessages: history.length > messages.length,
235
+ newMessages: history.slice(messages.length),
236
+ };
232
237
  }
233
238
  },
234
239
  }));
@@ -366,6 +366,90 @@ describe("surface action delivery to assistant", () => {
366
366
  expect(JSON.stringify(completeMsg)).not.toContain(largeBase64);
367
367
  });
368
368
 
369
+ test("choice surface broadcasts ui_surface_complete on action", async () => {
370
+ const sent: ServerMessage[] = [];
371
+ const ctx = makeContext(sent);
372
+
373
+ const showResult = await surfaceProxyResolver(ctx, "ui_show", {
374
+ surface_type: "choice",
375
+ title: "Pick an outcome",
376
+ data: {
377
+ options: [
378
+ { id: "inbox", title: "Clean up my inbox" },
379
+ { id: "calendar", title: "Plan my week" },
380
+ ],
381
+ },
382
+ });
383
+
384
+ expect(showResult.isError).toBe(false);
385
+ expect(showResult.yieldToUser).toBe(true);
386
+
387
+ const showMessage = sent.find(
388
+ (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
389
+ ) as UiSurfaceShow;
390
+ const surfaceId = showMessage.surfaceId;
391
+ expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true);
392
+
393
+ await handleSurfaceAction(ctx, surfaceId, "inbox", {
394
+ choiceId: "inbox",
395
+ choiceTitle: "Clean up my inbox",
396
+ selectedIds: ["inbox"],
397
+ selectedTitles: ["Clean up my inbox"],
398
+ });
399
+
400
+ const completeMsg = broadcastedMessages.find(
401
+ (m) =>
402
+ (m as unknown as Record<string, unknown>).type ===
403
+ "ui_surface_complete" &&
404
+ (m as unknown as Record<string, unknown>).surfaceId === surfaceId,
405
+ ) as unknown as Record<string, unknown> | undefined;
406
+ expect(completeMsg).toBeDefined();
407
+ expect(completeMsg?.conversationId).toBe("conv-1");
408
+ expect(completeMsg?.summary).toBe('User chose: "Clean up my inbox"');
409
+ expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false);
410
+ });
411
+
412
+ test("oauth_connect surface broadcasts ui_surface_complete on action", async () => {
413
+ const sent: ServerMessage[] = [];
414
+ const ctx = makeContext(sent);
415
+
416
+ const showResult = await surfaceProxyResolver(ctx, "ui_show", {
417
+ surface_type: "oauth_connect",
418
+ title: "Connect Google",
419
+ data: {
420
+ providerKey: "google",
421
+ displayName: "Google",
422
+ },
423
+ });
424
+
425
+ expect(showResult.isError).toBe(false);
426
+ expect(showResult.yieldToUser).toBe(true);
427
+
428
+ const showMessage = sent.find(
429
+ (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
430
+ ) as UiSurfaceShow;
431
+ const surfaceId = showMessage.surfaceId;
432
+ expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true);
433
+
434
+ await handleSurfaceAction(ctx, surfaceId, "connect", {
435
+ status: "connected",
436
+ providerKey: "google",
437
+ providerLabel: "Google",
438
+ accountLabel: "user@example.com",
439
+ });
440
+
441
+ const completeMsg = broadcastedMessages.find(
442
+ (m) =>
443
+ (m as unknown as Record<string, unknown>).type ===
444
+ "ui_surface_complete" &&
445
+ (m as unknown as Record<string, unknown>).surfaceId === surfaceId,
446
+ ) as unknown as Record<string, unknown> | undefined;
447
+ expect(completeMsg).toBeDefined();
448
+ expect(completeMsg?.conversationId).toBe("conv-1");
449
+ expect(completeMsg?.summary).toBe("Connected Google: user@example.com");
450
+ expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false);
451
+ });
452
+
369
453
  test("table surface does NOT broadcast ui_surface_complete (not one-shot)", async () => {
370
454
  const sent: ServerMessage[] = [];
371
455
  const ctx = makeContext(sent);
@@ -1,5 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
1
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
4
2
 
5
3
  import {
@@ -18,6 +16,7 @@ import { assistantEventHub } from "../runtime/assistant-event-hub.js";
18
16
  import { ROUTES as CONVERSATION_LIST_ROUTES } from "../runtime/routes/conversation-list-routes.js";
19
17
  import { ROUTES as CONVERSATION_MANAGEMENT_ROUTES } from "../runtime/routes/conversation-management-routes.js";
20
18
  import type { RouteDefinition } from "../runtime/routes/types.js";
19
+ import { publishConversationTitleChanged } from "../runtime/sync/resource-sync-events.js";
21
20
  import { resetDbForTesting } from "./db-test-helpers.js";
22
21
  import { waitFor } from "./helpers/wait-for.js";
23
22
 
@@ -122,20 +121,33 @@ describe("conversation sync tags", () => {
122
121
  ).toBe(false);
123
122
  });
124
123
 
125
- test("agent-loop title updates emit metadata-only sync tags (no list umbrella)", () => {
126
- const source = readFileSync(
127
- join(import.meta.dir, "..", "daemon", "conversation-agent-loop.ts"),
128
- "utf-8",
124
+ test("auto-title generation emits the typed title event and a metadata-only sync tag (no list umbrella)", async () => {
125
+ // Auto-title generation (first-pass on prompt submit, second-pass
126
+ // regeneration, bootstrap, and voice) persists via the title service and
127
+ // broadcasts through `publishConversationTitleChanged` — the same helper
128
+ // the rename route uses. Like a rename, generation is content-only: the
129
+ // row stays in place and only the title flips, so web patches the cached
130
+ // row from the typed `conversation_title_updated` event and the per-
131
+ // conversation metadata tag is the belt-and-suspenders signal. The list
132
+ // umbrella is deliberately omitted so web never redrains the paginated
133
+ // list for a title change.
134
+ const conversation = createConversation("Generating…");
135
+
136
+ const received = await captureEvents(() => {
137
+ publishConversationTitleChanged(conversation.id, "Generated title");
138
+ }, 2);
139
+
140
+ expect(received.map((event) => event.message.type)).toEqual([
141
+ "conversation_title_updated",
142
+ "sync_changed",
143
+ ]);
144
+ expect(received[1]!.message).toEqual({
145
+ type: "sync_changed",
146
+ tags: [conversationMetadataSyncTag(conversation.id)],
147
+ });
148
+ expect((received[1]!.message as { tags: string[] }).tags).not.toContain(
149
+ SYNC_TAGS.conversationsList,
129
150
  );
130
- const titleUpdateBlocks =
131
- source.match(
132
- /type: "conversation_title_updated"[\s\S]{0,500}?type: "sync_changed"[\s\S]{0,250}?tags: \[[\s\S]*?\]/g,
133
- ) ?? [];
134
-
135
- expect(titleUpdateBlocks.length).toBeGreaterThanOrEqual(2);
136
- for (const block of titleUpdateBlocks) {
137
- expect(block).not.toContain("SYNC_TAGS.conversationsList");
138
- }
139
151
  });
140
152
 
141
153
  test("create emits a sync_changed with the conversationsList umbrella tag", async () => {
@@ -51,9 +51,18 @@ mock.module("../util/logger.js", () => ({
51
51
  }),
52
52
  }));
53
53
 
54
+ const mockPublishConversationTitleChanged = mock(
55
+ (_conversationId: string, _title: string) => {},
56
+ );
57
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
58
+ publishConversationTitleChanged: mockPublishConversationTitleChanged,
59
+ }));
60
+
54
61
  import {
55
62
  generateAndPersistConversationTitle,
63
+ queueGenerateConversationTitle,
56
64
  regenerateConversationTitle,
65
+ titleMutex,
57
66
  } from "../memory/conversation-title-service.js";
58
67
 
59
68
  describe("conversation-title-service", () => {
@@ -63,6 +72,7 @@ describe("conversation-title-service", () => {
63
72
  mockGetMessages.mockClear();
64
73
  mockUpdateConversationTitle.mockClear();
65
74
  mockGetConfiguredProvider.mockClear();
75
+ mockPublishConversationTitleChanged.mockClear();
66
76
  });
67
77
 
68
78
  test("uses the BTW side-chain helper for initial title generation", async () => {
@@ -87,7 +97,7 @@ describe("conversation-title-service", () => {
87
97
  systemPrompt: expect.stringContaining("conversation titles"),
88
98
  tools: [],
89
99
  callSite: "conversationTitle",
90
- timeoutMs: 10_000,
100
+ timeoutMs: 15_000,
91
101
  }),
92
102
  );
93
103
  expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
@@ -95,6 +105,12 @@ describe("conversation-title-service", () => {
95
105
  "Project kickoff",
96
106
  1,
97
107
  );
108
+ // Emit is service-native: persisting a title broadcasts the update so
109
+ // every title origin (agent loop, bootstrap, voice) updates clients live.
110
+ expect(mockPublishConversationTitleChanged).toHaveBeenCalledWith(
111
+ "conv-1",
112
+ "Project kickoff",
113
+ );
98
114
  });
99
115
 
100
116
  test("regeneration extracts text from JSON content blocks", async () => {
@@ -205,7 +221,7 @@ describe("conversation-title-service", () => {
205
221
  systemPrompt: expect.stringContaining("conversation titles"),
206
222
  tools: [],
207
223
  callSite: "conversationTitle",
208
- timeoutMs: 10_000,
224
+ timeoutMs: 15_000,
209
225
  }),
210
226
  );
211
227
  expect(mockUpdateConversationTitle).toHaveBeenCalledWith(
@@ -354,4 +370,121 @@ describe("conversation-title-service", () => {
354
370
  expect(call.content).not.toContain("do NOT respond");
355
371
  expect(call.systemPrompt).toContain("Do NOT respond");
356
372
  });
373
+
374
+ test("queueGenerateConversationTitle serializes concurrent calls", async () => {
375
+ const callOrder: string[] = [];
376
+ let resolveFirst!: () => void;
377
+ const firstBlocked = new Promise<void>((r) => {
378
+ resolveFirst = r;
379
+ });
380
+
381
+ // First call: blocks until we release it
382
+ mockRunBtwSidechain.mockImplementationOnce(async () => {
383
+ callOrder.push("first:start");
384
+ await firstBlocked;
385
+ callOrder.push("first:end");
386
+ return {
387
+ text: "Title One",
388
+ hadTextDeltas: true,
389
+ response: {
390
+ content: [{ type: "text", text: "Title One" }],
391
+ model: "test-model",
392
+ usage: { inputTokens: 10, outputTokens: 5 },
393
+ stopReason: "end_turn",
394
+ },
395
+ };
396
+ });
397
+
398
+ // Second call: resolves immediately
399
+ mockRunBtwSidechain.mockImplementationOnce(async () => {
400
+ callOrder.push("second:start");
401
+ return {
402
+ text: "Title Two",
403
+ hadTextDeltas: true,
404
+ response: {
405
+ content: [{ type: "text", text: "Title Two" }],
406
+ model: "test-model",
407
+ usage: { inputTokens: 10, outputTokens: 5 },
408
+ stopReason: "end_turn",
409
+ },
410
+ };
411
+ });
412
+
413
+ const provider = {
414
+ name: "test-provider",
415
+ sendMessage: mock(async () => {
416
+ throw new Error("should not call directly");
417
+ }),
418
+ };
419
+
420
+ // Fire both calls — without serialization both would start immediately
421
+ queueGenerateConversationTitle({
422
+ conversationId: "conv-1",
423
+ provider,
424
+ userMessage: "first message",
425
+ });
426
+ queueGenerateConversationTitle({
427
+ conversationId: "conv-2",
428
+ provider,
429
+ userMessage: "second message",
430
+ });
431
+
432
+ // Let microtasks settle — only the first call should have started
433
+ await new Promise((r) => setTimeout(r, 10));
434
+ expect(callOrder).toEqual(["first:start"]);
435
+
436
+ // Release the first call
437
+ resolveFirst();
438
+ await titleMutex.withLock(async () => {});
439
+
440
+ // Second should have started only after first finished
441
+ expect(callOrder).toEqual(["first:start", "first:end", "second:start"]);
442
+ });
443
+
444
+ test("queue continues processing after a failed call", async () => {
445
+ // First call: throws
446
+ mockRunBtwSidechain.mockImplementationOnce(async () => {
447
+ throw new Error("provider timeout");
448
+ });
449
+
450
+ // Second call: succeeds
451
+ mockRunBtwSidechain.mockImplementationOnce(async () => ({
452
+ text: "Recovery Title",
453
+ hadTextDeltas: true,
454
+ response: {
455
+ content: [{ type: "text", text: "Recovery Title" }],
456
+ model: "test-model",
457
+ usage: { inputTokens: 10, outputTokens: 5 },
458
+ stopReason: "end_turn",
459
+ },
460
+ }));
461
+
462
+ const provider = {
463
+ name: "test-provider",
464
+ sendMessage: mock(async () => {
465
+ throw new Error("should not call directly");
466
+ }),
467
+ };
468
+
469
+ queueGenerateConversationTitle({
470
+ conversationId: "conv-1",
471
+ provider,
472
+ userMessage: "will fail",
473
+ });
474
+ queueGenerateConversationTitle({
475
+ conversationId: "conv-2",
476
+ provider,
477
+ userMessage: "will succeed",
478
+ });
479
+
480
+ await titleMutex.withLock(async () => {});
481
+
482
+ // Both calls went through — failure didn't break the chain
483
+ expect(mockRunBtwSidechain).toHaveBeenCalledTimes(2);
484
+ // Second conversation got a proper title
485
+ const secondUpdate = (
486
+ mockUpdateConversationTitle.mock.calls as unknown as string[][]
487
+ ).find((c) => c[0] === "conv-2" && c[1] === "Recovery Title");
488
+ expect(secondUpdate).toBeTruthy();
489
+ });
357
490
  });
@@ -265,7 +265,12 @@ mock.module("../agent/loop.js", () => ({
265
265
  content: [{ type: "text", text: "ok" }],
266
266
  };
267
267
  onEvent({ type: "message_complete", message: assistantMessage });
268
- return { history: [...messages, assistantMessage], exitReason: null };
268
+ return {
269
+ history: [...messages, assistantMessage],
270
+ exitReason: null,
271
+ appendedNewMessages: true,
272
+ newMessages: [assistantMessage],
273
+ };
269
274
  }
270
275
  },
271
276
  }));
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import type {
4
4
  ContentBlock,
@@ -221,6 +221,64 @@ import {
221
221
  OpenAIResponsesProvider,
222
222
  } from "../providers/openai/client.js";
223
223
 
224
+ // ---------------------------------------------------------------------------
225
+ // App-side web_search provider adapters (Brave/Perplexity/Tavily)
226
+ //
227
+ // Exercise the real `web-search.ts` execute path with a mocked config, provider
228
+ // key, and global fetch. The logger is mocked to capture structured warnings so
229
+ // we can assert the `web_search_backend_failure` telemetry (ATL-727).
230
+ // ---------------------------------------------------------------------------
231
+
232
+ let mockWebSearchProvider: string = "brave";
233
+ let mockProviderKey: string | undefined = "test-key";
234
+ const capturedWarnLogs: Record<string, unknown>[] = [];
235
+
236
+ const realConfigLoader = await import("../config/loader.js");
237
+ mock.module("../config/loader.js", () => ({
238
+ ...realConfigLoader,
239
+ getConfig: () => ({
240
+ services: { "web-search": { provider: mockWebSearchProvider } },
241
+ }),
242
+ }));
243
+
244
+ const realSecureKeys = await import("../security/secure-keys.js");
245
+ mock.module("../security/secure-keys.js", () => ({
246
+ ...realSecureKeys,
247
+ getProviderKeyAsync: async () => mockProviderKey,
248
+ }));
249
+
250
+ const realLogger = await import("../util/logger.js");
251
+ mock.module("../util/logger.js", () => ({
252
+ ...realLogger,
253
+ getLogger: () =>
254
+ new Proxy({} as Record<string, unknown>, {
255
+ get: (_target, prop) => {
256
+ if (prop === "warn") {
257
+ return (obj: Record<string, unknown>) => {
258
+ capturedWarnLogs.push(obj);
259
+ };
260
+ }
261
+ return () => {};
262
+ },
263
+ }),
264
+ }));
265
+
266
+ const { webSearchTool } = await import("../tools/network/web-search.js");
267
+ const { WEB_SEARCH_BACKEND_FAILURE_MESSAGE } = await import(
268
+ "../tools/network/web-search-error.js"
269
+ );
270
+
271
+ function executeWebSearch(input: Record<string, unknown>) {
272
+ return webSearchTool.execute(input, {} as never);
273
+ }
274
+
275
+ function executeWebSearchWithSignal(
276
+ input: Record<string, unknown>,
277
+ signal: AbortSignal,
278
+ ) {
279
+ return webSearchTool.execute(input, { signal } as never);
280
+ }
281
+
224
282
  // ---------------------------------------------------------------------------
225
283
  // OpenAI Responses API provider tests
226
284
  // ---------------------------------------------------------------------------
@@ -604,3 +662,158 @@ describe("Cross-Provider Web Search — Gemini", () => {
604
662
  expect(functionCallParts).toHaveLength(0);
605
663
  });
606
664
  });
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // App-side provider backend-failure normalization (ATL-727)
668
+ // ---------------------------------------------------------------------------
669
+
670
+ describe("Cross-Provider Web Search — app-side backend failure normalization", () => {
671
+ let originalFetch: typeof globalThis.fetch;
672
+
673
+ beforeEach(() => {
674
+ originalFetch = globalThis.fetch;
675
+ mockWebSearchProvider = "brave";
676
+ mockProviderKey = "test-key";
677
+ capturedWarnLogs.length = 0;
678
+ });
679
+
680
+ afterEach(() => {
681
+ globalThis.fetch = originalFetch;
682
+ });
683
+
684
+ function backendFailureLog() {
685
+ return capturedWarnLogs.find(
686
+ (entry) => entry.event === "web_search_backend_failure",
687
+ );
688
+ }
689
+
690
+ test("503 from provider yields friendly recoverable copy in content + errorMessage, logs raw 503, no body leak", async () => {
691
+ const rawBody = '{"error":"upstream exploded","trace":"do-not-leak"}';
692
+ globalThis.fetch = (async () =>
693
+ new Response(rawBody, { status: 503 })) as unknown as typeof fetch;
694
+
695
+ const result = await executeWebSearch({ query: "needle in a haystack" });
696
+
697
+ expect(result.isError).toBe(true);
698
+ expect(result.content).toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
699
+ const meta = result.activityMetadata?.webSearch;
700
+ expect(meta?.errorMessage).toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
701
+ expect(meta?.results).toEqual([]);
702
+ expect(meta?.resultCount).toBe(0);
703
+
704
+ const logEntry = backendFailureLog();
705
+ expect(logEntry).toBeDefined();
706
+ expect(logEntry!.provider).toBe("brave");
707
+ expect(logEntry!.errorCategory).toBe("backend_unavailable");
708
+ expect(logEntry!.fallbackShown).toBe(true);
709
+ expect(logEntry!.queryLength).toBe("needle in a haystack".length);
710
+ expect(String(logEntry!.rawDetail)).toContain("503");
711
+ // Provider diagnostic body is preserved in internal telemetry rawDetail.
712
+ expect(String(logEntry!.rawDetail)).toContain("upstream exploded");
713
+ expect(String(logEntry!.rawDetail)).toContain("do-not-leak");
714
+
715
+ // Raw provider body must never reach user-facing fields.
716
+ expect(result.content).not.toContain("upstream exploded");
717
+ expect(result.content).not.toContain("do-not-leak");
718
+ expect(meta?.errorMessage).not.toContain("upstream exploded");
719
+ expect(meta?.errorMessage).not.toContain("do-not-leak");
720
+ });
721
+
722
+ test("thrown network error yields the same friendly backend result", async () => {
723
+ globalThis.fetch = (async () => {
724
+ throw new TypeError("fetch failed");
725
+ }) as unknown as typeof fetch;
726
+
727
+ const result = await executeWebSearch({ query: "offline" });
728
+
729
+ expect(result.isError).toBe(true);
730
+ expect(result.content).toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
731
+ expect(result.activityMetadata?.webSearch?.errorMessage).toBe(
732
+ WEB_SEARCH_BACKEND_FAILURE_MESSAGE,
733
+ );
734
+ const logEntry = backendFailureLog();
735
+ expect(logEntry).toBeDefined();
736
+ expect(logEntry!.errorCategory).toBe("backend_unavailable");
737
+ expect(result.content).not.toContain("fetch failed");
738
+ });
739
+
740
+ test("401 invalid-key preserves the specific message, not the backend copy", async () => {
741
+ globalThis.fetch = (async () =>
742
+ new Response("Unauthorized", { status: 401 })) as unknown as typeof fetch;
743
+
744
+ const result = await executeWebSearch({ query: "bad key" });
745
+
746
+ expect(result.isError).toBe(true);
747
+ expect(result.content).toContain("Invalid or expired Brave Search API key");
748
+ expect(result.content).not.toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
749
+ expect(result.activityMetadata?.webSearch?.errorMessage).not.toBe(
750
+ WEB_SEARCH_BACKEND_FAILURE_MESSAGE,
751
+ );
752
+ expect(backendFailureLog()).toBeUndefined();
753
+ });
754
+
755
+ test("HTTP 200 with zero results stays a success (unchanged)", async () => {
756
+ globalThis.fetch = (async () =>
757
+ new Response(JSON.stringify({ web: { results: [] } }), {
758
+ status: 200,
759
+ headers: { "content-type": "application/json" },
760
+ })) as unknown as typeof fetch;
761
+
762
+ const result = await executeWebSearch({ query: "no hits" });
763
+
764
+ expect(result.isError).toBe(false);
765
+ expect(result.activityMetadata?.webSearch?.errorMessage).toBeUndefined();
766
+ expect(result.content).toContain("No results found");
767
+ expect(backendFailureLog()).toBeUndefined();
768
+ });
769
+
770
+ test("post-retry 429 yields the friendly recoverable copy and preserves body in rawDetail", async () => {
771
+ const rawBody = '{"error":"quota burned","retryHint":"do-not-leak-429"}';
772
+ globalThis.fetch = (async () =>
773
+ new Response(rawBody, {
774
+ status: 429,
775
+ headers: { "retry-after": "0" },
776
+ })) as unknown as typeof fetch;
777
+
778
+ const result = await executeWebSearch({ query: "rate limited" });
779
+
780
+ expect(result.isError).toBe(true);
781
+ expect(result.content).toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
782
+ const meta = result.activityMetadata?.webSearch;
783
+ expect(meta?.errorMessage).toBe(WEB_SEARCH_BACKEND_FAILURE_MESSAGE);
784
+ const logEntry = backendFailureLog();
785
+ expect(logEntry).toBeDefined();
786
+ expect(logEntry!.errorCategory).toBe("rate_limited");
787
+ expect(String(logEntry!.rawDetail)).toContain("429");
788
+ // Provider diagnostic body is preserved in internal telemetry rawDetail.
789
+ expect(String(logEntry!.rawDetail)).toContain("quota burned");
790
+ expect(String(logEntry!.rawDetail)).toContain("do-not-leak-429");
791
+
792
+ // Raw provider body must never reach user-facing fields.
793
+ expect(result.content).not.toContain("quota burned");
794
+ expect(result.content).not.toContain("do-not-leak-429");
795
+ expect(meta?.errorMessage).not.toContain("quota burned");
796
+ expect(meta?.errorMessage).not.toContain("do-not-leak-429");
797
+ });
798
+
799
+ test("caller abort re-throws instead of producing a backend failure (no telemetry)", async () => {
800
+ const controller = new AbortController();
801
+ controller.abort();
802
+
803
+ // A caller-aborted request surfaces an AbortError from fetch.
804
+ globalThis.fetch = (async () => {
805
+ const abortError = new Error("The operation was aborted");
806
+ abortError.name = "AbortError";
807
+ throw abortError;
808
+ }) as unknown as typeof fetch;
809
+
810
+ // The cancellation must re-throw so the executor's abort handling takes
811
+ // over — NOT resolve to the friendly backend-failure result.
812
+ await expect(
813
+ executeWebSearchWithSignal({ query: "cancel me" }, controller.signal),
814
+ ).rejects.toThrow();
815
+
816
+ // No spurious backend-failure telemetry for a user/external cancellation.
817
+ expect(backendFailureLog()).toBeUndefined();
818
+ });
819
+ });