@circuitwall/jarela 2.3.0 → 2.5.0

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 (290) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
  19. package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
  20. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
  21. package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
  24. package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
  25. package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
  26. package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
  27. package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
  32. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
  33. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
  35. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
  37. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
  39. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
  40. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
  41. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
  43. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
  45. package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
  47. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
  48. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
  50. package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
  51. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  52. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  53. package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
  54. package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
  55. package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
  56. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  58. package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
  59. package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
  60. package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
  61. package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
  62. package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
  63. package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
  64. package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
  65. package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
  66. package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
  67. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  68. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  69. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  70. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  71. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  72. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  73. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  74. package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
  75. package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
  76. package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
  78. package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
  79. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
  80. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
  81. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
  82. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
  83. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
  85. package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
  86. package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
  87. package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
  88. package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
  89. package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
  90. package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
  92. package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
  93. package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
  94. package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
  95. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
  96. package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
  97. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  98. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  99. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  100. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  101. package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
  102. package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
  103. package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
  104. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +585 -58
  105. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
  106. package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
  107. package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
  108. package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
  109. package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
  110. package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
  111. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
  112. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  113. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
  114. package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
  115. package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
  116. package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
  117. package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
  118. package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
  119. package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
  120. package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
  121. package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
  122. package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
  123. package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
  124. package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
  125. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
  126. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
  127. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +125 -26
  128. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -2
  129. package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
  130. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  131. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
  133. package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
  134. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
  135. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  136. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  137. package/.next/standalone/.next/server/app/page.js +27323 -27034
  138. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  139. package/.next/standalone/.next/server/app/setup/page.js +8 -0
  140. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  141. package/.next/standalone/.next/server/app-paths-manifest.json +5 -5
  142. package/.next/standalone/.next/server/chunks/1388.js +106 -36
  143. package/.next/standalone/.next/server/chunks/2068.js +47 -10
  144. package/.next/standalone/.next/server/chunks/210.js +6 -6
  145. package/.next/standalone/.next/server/chunks/2447.js +10 -5
  146. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  147. package/.next/standalone/.next/server/chunks/3042.js +10 -8
  148. package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
  149. package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
  150. package/.next/standalone/.next/server/chunks/3524.js +150 -67
  151. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  152. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  153. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  154. package/.next/standalone/.next/server/chunks/46.js +91 -31
  155. package/.next/standalone/.next/server/chunks/466.js +53 -7
  156. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  157. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  158. package/.next/standalone/.next/server/chunks/5908.js +63 -42
  159. package/.next/standalone/.next/server/chunks/6142.js +10 -2
  160. package/.next/standalone/.next/server/chunks/8461.js +10 -8
  161. package/.next/standalone/.next/server/chunks/9032.js +5 -5
  162. package/.next/standalone/.next/server/chunks/9164.js +487 -177
  163. package/.next/standalone/.next/server/chunks/9304.js +51 -12
  164. package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +84 -24
  165. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  166. package/.next/standalone/.next/server/middleware.js +89 -21
  167. package/.next/standalone/.next/server/pages/404.html +14 -2
  168. package/.next/standalone/.next/server/pages/500.html +1 -1
  169. package/.next/standalone/.next/server/preview-props.json +3 -3
  170. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  171. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  172. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  173. package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-2ef8d75d40eb4311.js} +10643 -10295
  174. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  175. package/.next/standalone/docs/adoption/changesets.json +2805 -0
  176. package/.next/standalone/package.json +1 -1
  177. package/CHANGELOG.md +52 -0
  178. package/api/client.models.test.ts +10 -0
  179. package/api/client.startup.test.ts +129 -0
  180. package/api/client.ts +51 -9
  181. package/api/thread-metadata.test.ts +72 -0
  182. package/api/types.ts +6 -3
  183. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  184. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  185. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  186. package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
  187. package/app/globals.css +119 -29
  188. package/app/layout.tsx +12 -0
  189. package/components/agents/AgentsPanel.tsx +5 -13
  190. package/components/chat/ChatView.tsx +9 -18
  191. package/components/chat/InputBar.tsx +1 -1
  192. package/components/chat/MessageBubble.tsx +13 -1
  193. package/components/chat/MessageList.test.tsx +45 -4
  194. package/components/chat/MessageList.tsx +123 -89
  195. package/components/chat/ToolList.tsx +88 -84
  196. package/components/chat/useThreadActivity.test.tsx +2 -2
  197. package/components/chat/useThreadActivity.ts +2 -2
  198. package/components/chat/useThreadData.ts +1 -30
  199. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  200. package/components/chat/useThreadMetadataPolling.ts +50 -0
  201. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  202. package/components/chat/useUserProfileAndAgent.ts +13 -2
  203. package/components/dashboard/DashboardPanel.tsx +1 -1
  204. package/components/dashboard/Series.tsx +34 -21
  205. package/components/dashboard/SummaryCards.tsx +1 -1
  206. package/components/integrations/NetworkSection.tsx +2 -2
  207. package/components/layout/AppShell.tsx +22 -7
  208. package/components/layout/MenuPanel.tsx +14 -24
  209. package/components/profile/ProfilePanel.tsx +2 -2
  210. package/components/settings/AppearancePanel.tsx +70 -3
  211. package/components/settings/SettingsPanel.tsx +2 -2
  212. package/components/tools/ToolsPanel.tsx +2 -7
  213. package/components/ui/AppearancePreferences.test.tsx +68 -0
  214. package/components/ui/BootScreen.test.tsx +44 -0
  215. package/components/ui/BootScreen.tsx +24 -11
  216. package/components/ui/NotificationStatus.test.tsx +2 -2
  217. package/components/ui/NotificationStatus.tsx +5 -5
  218. package/components/ui/SubTabBar.tsx +2 -3
  219. package/contexts/ThemeContext.tsx +84 -1
  220. package/hooks/useAgentSession.test.ts +12 -1
  221. package/hooks/useAgentSession.ts +6 -4
  222. package/hooks/useUrlSync.test.ts +13 -0
  223. package/hooks/useUrlSync.ts +0 -4
  224. package/lib/agents/citation-checker.test.ts +40 -2
  225. package/lib/agents/citation-checker.ts +56 -11
  226. package/lib/agents/context-boundary.ts +5 -6
  227. package/lib/agents/instruction-edits.test.ts +120 -0
  228. package/lib/agents/instruction-edits.ts +65 -42
  229. package/lib/agents/prepare/request.ts +0 -2
  230. package/lib/agents/prepare/system-prompt.test.ts +10 -0
  231. package/lib/agents/prepare/system-prompt.ts +8 -2
  232. package/lib/agents/proposals.test.ts +76 -0
  233. package/lib/agents/proposals.ts +13 -5
  234. package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
  235. package/lib/agents/run-thread.ts +24 -9
  236. package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
  237. package/lib/agents/warm-summary-background.ts +22 -2
  238. package/lib/agents/warm-topics.test.ts +155 -1
  239. package/lib/agents/warm-topics.ts +27 -7
  240. package/lib/bridges/dispatcher.ts +1 -0
  241. package/lib/bridges/message-role.test.ts +81 -0
  242. package/lib/bridges/message-role.ts +23 -10
  243. package/lib/bridges/types.ts +6 -0
  244. package/lib/bridges/whatsapp-link.test.ts +32 -0
  245. package/lib/bridges/whatsapp-link.ts +21 -0
  246. package/lib/bridges/whatsapp.inbound.test.ts +239 -0
  247. package/lib/bridges/whatsapp.sender.test.ts +80 -1
  248. package/lib/bridges/whatsapp.ts +76 -38
  249. package/lib/db/migrations.test.ts +61 -0
  250. package/lib/db/migrations.ts +13 -0
  251. package/lib/embeddings/performance.test.ts +114 -0
  252. package/lib/pricing/snapshot.test.ts +70 -0
  253. package/lib/pricing/snapshot.ts +10 -2
  254. package/lib/proxy/dispatcher.test.ts +119 -1
  255. package/lib/proxy/dispatcher.ts +22 -9
  256. package/lib/stores/agent-configs.ts +10 -5
  257. package/lib/stores/bridges.test.ts +81 -0
  258. package/lib/stores/bridges.ts +9 -8
  259. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  260. package/lib/stores/dashboard-metrics.ts +30 -4
  261. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  262. package/lib/stores/message-embedding-jobs.ts +5 -3
  263. package/lib/stores/message-usage.test.ts +21 -1
  264. package/lib/stores/pricing-cache.test.ts +31 -0
  265. package/lib/stores/pricing.ts +3 -0
  266. package/lib/stores/proxy-config.test.ts +120 -0
  267. package/lib/stores/proxy-config.ts +22 -4
  268. package/lib/stores/threads.ts +12 -8
  269. package/lib/tools/communications/calendar.test.ts +43 -2
  270. package/lib/tools/communications/calendar.ts +2 -0
  271. package/lib/tools/communications/gmail.test.ts +51 -1
  272. package/lib/tools/communications/gmail.ts +44 -2
  273. package/lib/tools/communications/outlook-calendar.ts +2 -0
  274. package/lib/tools/communications/outlook.test.ts +33 -2
  275. package/lib/tools/communications/outlook.ts +2 -0
  276. package/lib/tools/filesystem/edit-match.test.ts +133 -0
  277. package/lib/tools/filesystem/edit-match.ts +7 -2
  278. package/lib/tools/packages/default-packages.test.ts +21 -0
  279. package/lib/tools/packages/optional-package.test.ts +72 -0
  280. package/lib/tools/packages/optional-package.ts +33 -4
  281. package/lib/tools/support/result-refs.test.ts +131 -0
  282. package/lib/tools/support/result-refs.ts +41 -9
  283. package/lib/ui/navigate.test.ts +13 -0
  284. package/lib/ui/navigate.ts +10 -2
  285. package/lib/utils/source-link.test.ts +51 -0
  286. package/lib/utils/source-link.ts +45 -0
  287. package/package.json +1 -1
  288. package/.next/standalone/.next/static/css/312542a5725ba0d5.css +0 -5
  289. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  290. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -85,17 +85,28 @@ const SOURCE_PRODUCING_TOOLS_RESULT = new Set([
85
85
  "web_search", "fetch_webpage", "web_fetch",
86
86
  ]);
87
87
 
88
- // Strip query/fragment for URLs and normalize backslashes for paths so a
89
- // citation that drops `?utm=...` or uses forward slashes on Windows still
90
- // matches the visited entry. Also fold `~/...` to the resolved home dir
91
- // so `~/Downloads/x` and `C:\Users\andre\Downloads\x` de-dupe.
88
+ const TRACKING_PARAM_RE = /^(?:utm(?:_.*)?|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|igshid|ref_src)$/i;
89
+
90
+ // Drop tracking params and in-page anchors for URLs and normalize
91
+ // backslashes for paths so a citation that adds `?utm_source=...` or uses
92
+ // forward slashes on Windows still matches the visited entry. Identifying
93
+ // query params (Outlook `?ItemID=`, `watch?v=`) and hash routes (Gmail
94
+ // `#all/<id>`) are kept verbatim: they are case-sensitive and are the only
95
+ // thing that distinguishes two message permalinks. Also fold `~/...` to the
96
+ // resolved home dir so `~/Downloads/x` and `C:\Users\andre\Downloads\x` de-dupe.
92
97
  export function normalizeSource(raw: string): string {
93
98
  const trimmed = raw.trim();
94
99
  if (!trimmed) return "";
95
100
  if (/^https?:\/\//i.test(trimmed)) {
96
101
  try {
97
102
  const u = new URL(trimmed);
98
- return `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "").toLowerCase();
103
+ const base = `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "").toLowerCase();
104
+ const params = [...u.searchParams]
105
+ .filter(([key]) => !TRACKING_PARAM_RE.test(key))
106
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
107
+ const query = params.length > 0 ? `?${new URLSearchParams(params).toString()}` : "";
108
+ const hashRoute = u.hash.includes("/") ? u.hash : "";
109
+ return base + query + hashRoute;
99
110
  } catch {
100
111
  return trimmed.toLowerCase();
101
112
  }
@@ -436,12 +447,9 @@ ${sent}`;
436
447
  export function parseCitationVerdict(text: string): CitationClaim[] | null {
437
448
  if (!text) return null;
438
449
  const stripped = text.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "").trim();
439
- const m = /\{[^]*"claims"[^]*\}/.exec(stripped);
440
- if (!m) return null;
441
- let parsed: unknown;
442
- try { parsed = JSON.parse(m[0]); } catch { return null; }
443
- if (!parsed || typeof parsed !== "object") return null;
444
- const claimsRaw = (parsed as { claims?: unknown }).claims;
450
+ const parsed = findClaimsObject(stripped);
451
+ if (!parsed) return null;
452
+ const claimsRaw = parsed.claims;
445
453
  if (!Array.isArray(claimsRaw)) return null;
446
454
  const claims: CitationClaim[] = [];
447
455
  for (const item of claimsRaw) {
@@ -464,6 +472,43 @@ export function parseCitationVerdict(text: string): CitationClaim[] | null {
464
472
  return claims;
465
473
  }
466
474
 
475
+ // Models often wrap the verdict in prose that itself contains braces, so a
476
+ // single greedy `{...}` match can start or end in the wrong place. Try each
477
+ // `{` as a start and parse the balanced object it opens (string-aware).
478
+ function findClaimsObject(text: string): { claims?: unknown } | null {
479
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
480
+ const end = balancedObjectEnd(text, start);
481
+ if (end === -1) continue;
482
+ const candidate = text.slice(start, end + 1);
483
+ if (!candidate.includes('"claims"')) continue;
484
+ try {
485
+ const parsed: unknown = JSON.parse(candidate);
486
+ if (parsed && typeof parsed === "object" && "claims" in parsed) return parsed as { claims?: unknown };
487
+ } catch { /* try the next opening brace */ }
488
+ }
489
+ return null;
490
+ }
491
+
492
+ function balancedObjectEnd(text: string, start: number): number {
493
+ let depth = 0;
494
+ let inString = false;
495
+ for (let i = start; i < text.length; i++) {
496
+ const ch = text[i];
497
+ if (inString) {
498
+ if (ch === "\\") i++;
499
+ else if (ch === '"') inString = false;
500
+ } else if (ch === '"') {
501
+ inString = true;
502
+ } else if (ch === "{") {
503
+ depth++;
504
+ } else if (ch === "}") {
505
+ depth--;
506
+ if (depth === 0) return i;
507
+ }
508
+ }
509
+ return -1;
510
+ }
511
+
467
512
  function collectStringsFromPayload(payload: unknown, out: Set<string>): void {
468
513
  if (!payload) return;
469
514
  if (typeof payload === "string") {
@@ -3,12 +3,11 @@ import { kickBoundaryCompaction } from "@/lib/agents/warm-summary-background";
3
3
 
4
4
  // Every caller of moveThreadContextBoundary represents a user-initiated
5
5
  // boundary change (drag the line, the manual /compact command, or the agent's
6
- // compact_context tool acting on the user's say-so) — the
7
- // auto-detector's own commit (lib/agents/warm-summary-background.ts
8
- // commitBoundaryCompaction) calls setThreadContextPin directly instead, so
9
- // it never re-arms this lock. Suppresses the hot-turn-limit compaction in
10
- // lib/agents/run-thread.ts for the next N eligible turns so a manual
11
- // choice doesn't get silently re-moved on the very next turn.
6
+ // compact_context tool acting on the user's say-so). Suppresses the
7
+ // hot-turn-limit compaction in lib/agents/run-thread.ts for the next N
8
+ // eligible turns so a manual choice doesn't get silently re-moved on the very
9
+ // next turn. The auto-detector arms a shorter cooldown of its own
10
+ // (hotTurnCooldownUntil) so it doesn't re-run on every turn either.
12
11
  // Counted in messages (~2 per turn: one user + one assistant row) since
13
12
  // that's what's already on the thread row — no extra query or per-turn
14
13
  // write needed to check it.
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyInstructionEdits } from "./instruction-edits";
3
+
4
+ function edit(base: string, edits: unknown): string {
5
+ const out = applyInstructionEdits(base, edits);
6
+ if (!out.ok) throw new Error(out.error);
7
+ return out.text;
8
+ }
9
+
10
+ describe("applyInstructionEdits validation", () => {
11
+ it("rejects a non-array and reports the failing index", () => {
12
+ expect(applyInstructionEdits("x", { op: "append" })).toEqual({ ok: false, error: "instructions_edits must be an array" });
13
+ expect(applyInstructionEdits("x", [{ op: "append", text: "a" }, null])).toEqual({ ok: false, error: "instructions_edits[1] must be an object" });
14
+ expect(applyInstructionEdits("x", [{}])).toEqual({ ok: false, error: "instructions_edits[0].op is required" });
15
+ expect(applyInstructionEdits("x", [{ op: "rewrite" }])).toEqual({ ok: false, error: "instructions_edits[0].op is not supported" });
16
+ expect(applyInstructionEdits("x", [{ op: "replace", find: "x" }])).toEqual({ ok: false, error: "instructions_edits[0].replace must be a string" });
17
+ });
18
+
19
+ it("fails the whole batch instead of applying a prefix of it", () => {
20
+ const out = applyInstructionEdits("keep", [{ op: "append", text: " more" }, { op: "remove" }]);
21
+ expect(out.ok).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe("append / prepend", () => {
26
+ it("skips an if_missing append when the text is already present, honoring ignore_case", () => {
27
+ const out = applyInstructionEdits("Always cite sources.", [
28
+ { op: "append", text: "always CITE sources.", if_missing: true, ignore_case: true },
29
+ { op: "prepend", text: "Be brief. ", if_missing: true },
30
+ ]);
31
+ expect(out).toEqual({
32
+ ok: true,
33
+ text: "Be brief. Always cite sources.",
34
+ summary: [
35
+ { index: 0, op: "append", applied: false },
36
+ { index: 1, op: "prepend", applied: true },
37
+ ],
38
+ });
39
+ });
40
+ });
41
+
42
+ describe("replace / remove", () => {
43
+ it("treats find text literally, including regex metacharacters and $ patterns", () => {
44
+ expect(edit("cost is $5 (approx.)", [{ op: "replace", find: "$5 (approx.)", replace: "$$ & $&", ignore_case: true }]))
45
+ .toBe("cost is $$ & $&");
46
+ expect(edit("a.b a.b", [{ op: "replace", find: "a.b", replace: "$'", all: true }])).toBe("$' $'");
47
+ });
48
+
49
+ it("replaces only the first match unless all is set, and counts replacements", () => {
50
+ const out = applyInstructionEdits("Foo foo FOO", [
51
+ { op: "replace", find: "foo", replace: "bar", ignore_case: true },
52
+ { op: "remove", text: "FOO", all: true },
53
+ ]);
54
+ expect(out).toMatchObject({ ok: true, text: "bar foo ", summary: [{ replacements: 1 }, { removals: 1 }] });
55
+ });
56
+
57
+ it("is a no-op for an empty find string rather than inserting between every character", () => {
58
+ expect(edit("abc", [{ op: "replace", find: "", replace: "-", all: true }])).toBe("abc");
59
+ });
60
+ });
61
+
62
+ describe("dedupe_lines", () => {
63
+ it("removes repeated rules case-insensitively but keeps blank lines", () => {
64
+ const out = applyInstructionEdits("- Cite sources\n\n- cite sources \n- Be brief\n\n", [{ op: "dedupe_lines" }]);
65
+ expect(out).toMatchObject({ ok: true, text: "- Cite sources\n\n- Be brief\n\n", summary: [{ removed: 1 }] });
66
+ });
67
+
68
+ it("keeps the last occurrence when asked", () => {
69
+ expect(edit("a\nb\nA", [{ op: "dedupe_lines", keep: "last" }])).toBe("b\nA");
70
+ });
71
+
72
+ it("never removes code fence markers, fenced content, or horizontal rules", () => {
73
+ const base = [
74
+ "Rule one",
75
+ "```json",
76
+ "{ \"a\": 1 }",
77
+ "```",
78
+ "---",
79
+ "Example two:",
80
+ "```json",
81
+ "{ \"a\": 1 }",
82
+ "```",
83
+ "---",
84
+ "Rule one",
85
+ ].join("\n");
86
+ const expected = base.split("\n").slice(0, -1).join("\n");
87
+ expect(edit(base, [{ op: "dedupe_lines" }])).toBe(expected);
88
+ });
89
+
90
+ it("does not treat a fence line with an info string as the closing fence", () => {
91
+ const base = "```text\n```json\nx\nx\n```\n";
92
+ expect(edit(base, [{ op: "dedupe_lines" }])).toBe(base);
93
+ });
94
+ it("preserves CRLF line endings", () => {
95
+ expect(edit("a\r\nb\r\na\r\nc", [{ op: "dedupe_lines" }])).toBe("a\r\nb\r\nc");
96
+ });
97
+ });
98
+
99
+ describe("dedupe_paragraphs", () => {
100
+ it("removes a repeated paragraph and leaves the rest of the text byte-identical", () => {
101
+ const base = "# Rules\n\n - indented item\n - second\n\n\nAlways cite.\n\nalways cite.\n";
102
+ expect(edit(base, [{ op: "dedupe_paragraphs" }])).toBe("# Rules\n\n - indented item\n - second\n\n\nAlways cite.\n");
103
+ });
104
+
105
+ it("does not touch whitespace when there is nothing to remove", () => {
106
+ const base = "\n\nIntro\n\n\n\n indented code\n";
107
+ const out = applyInstructionEdits(base, [{ op: "dedupe_paragraphs" }]);
108
+ expect(out).toMatchObject({ ok: true, text: base, summary: [{ removed: 0 }] });
109
+ });
110
+
111
+ it("respects trim: false by treating differently indented paragraphs as distinct", () => {
112
+ const base = "item\n\n item";
113
+ expect(edit(base, [{ op: "dedupe_paragraphs", trim: false }])).toBe(base);
114
+ expect(edit(base, [{ op: "dedupe_paragraphs" }])).toBe("item");
115
+ });
116
+
117
+ it("keeps the last occurrence and drops the separator that belonged to the removed paragraph", () => {
118
+ expect(edit("A\n\nB\n\na", [{ op: "dedupe_paragraphs", keep: "last" }])).toBe("B\n\na");
119
+ });
120
+ });
@@ -172,58 +172,81 @@ function containsText(source: string, needle: string, ignoreCase: boolean): bool
172
172
  return source.toLowerCase().includes(needle.toLowerCase());
173
173
  }
174
174
 
175
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
176
+ const HORIZONTAL_RULE_RE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
177
+
178
+ /**
179
+ * Drop duplicate segments from `parts`, which alternates content (even
180
+ * indexes) and the original separator that follows it (odd indexes).
181
+ * Separators are preserved verbatim, so only removed segments change the
182
+ * text. `keyOf` returns null for a segment that must always be kept.
183
+ */
184
+ function dedupeSegments(
185
+ parts: readonly string[],
186
+ keyOf: (segmentIndex: number) => string | null,
187
+ keep: "first" | "last",
188
+ ): { text: string; removed: number } {
189
+ const contentIdx = parts.map((_, i) => i).filter((i) => i % 2 === 0);
190
+ const visit = keep === "first" ? contentIdx : [...contentIdx].reverse();
191
+ const seen = new Set<string>();
192
+ const dropped = new Set<number>();
193
+ for (const idx of visit) {
194
+ const key = keyOf(idx);
195
+ if (key === null) continue;
196
+ if (seen.has(key)) dropped.add(idx);
197
+ else seen.add(key);
198
+ }
199
+ if (dropped.size === 0) return { text: parts.join(""), removed: 0 };
200
+ let text = "";
201
+ let emitted = false;
202
+ for (const idx of contentIdx) {
203
+ if (dropped.has(idx)) continue;
204
+ if (emitted && idx > 0) text += parts[idx - 1];
205
+ text += parts[idx];
206
+ emitted = true;
207
+ }
208
+ return { text, removed: dropped.size };
209
+ }
210
+
175
211
  function dedupeLines(
176
212
  source: string,
177
213
  opts: { trim: boolean; ignoreCase: boolean; skipBlank: boolean; keep: "first" | "last" },
178
214
  ): { text: string; removed: number } {
179
- const lines = source.split(/\r?\n/);
180
- const keyed = lines.map((line) => {
181
- const normalized = opts.trim ? line.trim() : line;
182
- return opts.ignoreCase ? normalized.toLowerCase() : normalized;
183
- });
184
-
185
- const keep = new Set<number>();
186
- const visit = opts.keep === "first"
187
- ? [...keyed.keys()]
188
- : [...keyed.keys()].reverse();
189
- const seen = new Set<string>();
190
- for (const idx of visit) {
191
- const key = keyed[idx];
192
- if (opts.skipBlank && key.length === 0) {
193
- keep.add(idx);
194
- continue;
215
+ const parts = source.split(/(\r?\n)/);
216
+ // Fence markers, fenced code and horizontal rules repeat by design; a
217
+ // closing ``` is always a "duplicate" of the opening one.
218
+ const protectedIdx = new Set<number>();
219
+ let fence: string | null = null;
220
+ for (let i = 0; i < parts.length; i += 2) {
221
+ const marker = FENCE_RE.exec(parts[i])?.[1];
222
+ if (fence !== null) {
223
+ protectedIdx.add(i);
224
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length && /^\s*[`~]+\s*$/.test(parts[i])) fence = null;
225
+ } else if (marker) {
226
+ protectedIdx.add(i);
227
+ fence = marker;
228
+ } else if (HORIZONTAL_RULE_RE.test(parts[i])) {
229
+ protectedIdx.add(i);
195
230
  }
196
- if (seen.has(key)) continue;
197
- seen.add(key);
198
- keep.add(idx);
199
231
  }
200
- const next = lines.filter((_, idx) => keep.has(idx));
201
- return { text: next.join("\n"), removed: lines.length - next.length };
232
+ return dedupeSegments(parts, (i) => {
233
+ if (protectedIdx.has(i)) return null;
234
+ const normalized = opts.trim ? parts[i].trim() : parts[i];
235
+ if (opts.skipBlank && normalized.length === 0) return null;
236
+ return opts.ignoreCase ? normalized.toLowerCase() : normalized;
237
+ }, opts.keep);
202
238
  }
203
239
 
204
240
  function dedupeParagraphs(
205
241
  source: string,
206
242
  opts: { trim: boolean; ignoreCase: boolean; keep: "first" | "last" },
207
243
  ): { text: string; removed: number } {
208
- const paragraphs = source
209
- .split(/\n\s*\n/)
210
- .map((p) => p.trim())
211
- .filter((p) => p.length > 0);
212
- const keys = paragraphs.map((p) => {
213
- const base = opts.trim ? p.trim() : p;
244
+ const tail = /\s*$/.exec(source)?.[0] ?? "";
245
+ const parts = source.slice(0, source.length - tail.length).split(/(\r?\n(?:[ \t]*\r?\n)+)/);
246
+ const out = dedupeSegments(parts, (i) => {
247
+ if (parts[i].trim().length === 0) return null;
248
+ const base = opts.trim ? parts[i].trim() : parts[i];
214
249
  return opts.ignoreCase ? base.toLowerCase() : base;
215
- });
216
- const keep = new Set<number>();
217
- const visit = opts.keep === "first"
218
- ? [...keys.keys()]
219
- : [...keys.keys()].reverse();
220
- const seen = new Set<string>();
221
- for (const idx of visit) {
222
- const key = keys[idx];
223
- if (seen.has(key)) continue;
224
- seen.add(key);
225
- keep.add(idx);
226
- }
227
- const next = paragraphs.filter((_, idx) => keep.has(idx));
228
- return { text: next.join("\n\n"), removed: paragraphs.length - next.length };
229
- }
250
+ }, opts.keep);
251
+ return { text: out.text + tail, removed: out.removed };
252
+ }
@@ -38,8 +38,6 @@ export interface ThreadRunRequest {
38
38
  _onCompacting?: () => void;
39
39
  /** Internal — the submission path already committed this user's transcript row. */
40
40
  _user_message_persisted?: boolean;
41
- /** Thread message count captured before an eagerly persisted user turn. */
42
- _message_count_before_user?: number;
43
41
  /** Conversation idle duration captured before an eagerly persisted user turn. */
44
42
  _idle_ms_before_user?: number | null;
45
43
  /**
@@ -78,6 +78,8 @@ describe("buildSystemPrompt delivery channel", () => {
78
78
  // The directive must reassure the agent it has access to the channel —
79
79
  // that's the whole point of this block.
80
80
  expect(prompt).toMatch(/DO have access to WhatsApp/);
81
+ // WhatsApp shows markdown links as raw text.
82
+ expect(prompt).toContain("WhatsApp does not render markdown links: write any link as the bare URL, even where a cite_as value is given.");
81
83
  });
82
84
 
83
85
  it("falls back to the raw kind when no human label is mapped", () => {
@@ -95,6 +97,7 @@ describe("buildSystemPrompt delivery channel", () => {
95
97
 
96
98
  expect(prompt).toContain("--- Delivery channel ---");
97
99
  expect(prompt).toContain("carrier-pigeon");
100
+ expect(prompt).not.toContain("does not render markdown links");
98
101
  });
99
102
 
100
103
  it("omits the block entirely for direct chat (no delivery_channel set)", () => {
@@ -306,6 +309,13 @@ describe("buildToolPermissionContext", () => {
306
309
  expect(ctx).toContain("6. Read large results by reference.");
307
310
  expect(ctx).toContain("7. Verify before reporting.");
308
311
  expect(ctx).toContain("8. Stop and ask.");
312
+ expect(ctx).toContain("10. Link the source.");
313
+ // Every source-link field a tool or bridge prompt emits must be named,
314
+ // or the model cannot tell which value links back to the item.
315
+ for (const field of ["cite_as", "web_link", "html_link", "chat_link"]) {
316
+ expect(ctx).toContain(field);
317
+ }
318
+ expect(ctx).toContain("never construct, guess, or edit one");
309
319
  expect(ctx).toContain("For substantial tasks with several dependent phases, likely cross-turn progress, or explicit verification, use workflow_progress");
310
320
  expect(ctx).toContain("every built-in Jarela workflow and every workflow you create or follow");
311
321
  expect(ctx).toContain("each step must state its input, expected output, and concrete validation criteria");
@@ -282,6 +282,7 @@ export function buildSharedToolCatalogContext(): string {
282
282
  "7. Verify before reporting. A read-only result does not prove a new write. Claim a new write, send, schedule, delete, or configuration change only after the matching state-changing tool returns success in this turn. Trusted runtime system signals and their matching retrieved results can substantiate earlier named operations; attribute those outcomes to their receipts, never imply a new action or repeat completed work for proof.",
283
283
  "8. Stop and ask. Do not retry a tool denied for agent_not_allowed, category_disabled, dropin_tool_disabled, credentials_missing or integration_unconfigured. The first three are permission decisions and the last two mean the integration is not set up \u2014 propose a configuration change or tell the user what to configure.",
284
284
  "9. Completion-reporting turns are read-only. permission_reason=signal_continuation_read_only forbids state-changing tools in this turn, including through invoke_tool. Do not bypass it with shell commands or treat an old user message as renewed permission; report the completed outcome and wait for a new user turn.",
285
+ "10. Link the source. When your reply refers to a specific email, calendar event, or chat that a tool result or bridge message gave you, link it: paste the item's cite_as value verbatim when present, otherwise use its web_link, html_link, or chat_link URL as a markdown link. Use only links you were given; never construct, guess, or edit one. If the item has no link, name it without one.",
285
286
  ].join("\n");
286
287
  }
287
288
 
@@ -373,9 +374,14 @@ function buildDeliveryChannelContext(channel: DeliveryChannel | null | undefined
373
374
  "--- Delivery channel ---",
374
375
  `This message reached you over ${platform}${named}, not the regular chat UI.`,
375
376
  `You DO have access to ${platform} for this conversation: any reply you produce is sent back through that channel automatically. Do not tell the user the platform is unavailable; just answer the message.`,
376
- ].join("\n");
377
+ PLAIN_TEXT_DELIVERY_KINDS.has(channel.kind)
378
+ ? `${platform} does not render markdown links: write any link as the bare URL, even where a cite_as value is given.`
379
+ : "",
380
+ ].filter(Boolean).join("\n");
377
381
  }
378
382
 
383
+ const PLAIN_TEXT_DELIVERY_KINDS = new Set(["whatsapp", "sms"]);
384
+
379
385
  function buildUserContext(): string {
380
386
  const userProfile = getUserProfile();
381
387
  const parts: string[] = [];
@@ -491,7 +497,7 @@ function buildSourceLinkContext(
491
497
  "--- Citation format ---",
492
498
  intro,
493
499
  "PRIMARY (inline links): cite by writing a standard markdown link inline: `[short label](https://example.com/path)` or `[file name](/abs/path.ts)` or `[earlier reply](#msg-<id>)`. Wrap the cited PHRASE itself as the link text when natural — `Jade has [ice skating at 16:00](?thread=…&agent=…)` reads better than a trailing `[1]`.",
494
- "When a tool result contains a `cite_as` field (delegate_to_agent does, and so do some integrations), paste that exact value somewhere in your reply — it's a pre-built markdown link to the source. Anything you summarize from a delegate or from a web/file tool result MUST carry at least one inline link back to where it came from; a summary with zero links reads as if you made the facts up.",
500
+ "When a tool result contains a `cite_as` field (delegate_to_agent does, and so do the email and calendar tools), paste that exact value somewhere in your reply — it's a pre-built markdown link to the source. Anything you summarize from a delegate or from a web/file tool result MUST carry at least one inline link back to where it came from; a summary with zero links reads as if you made the facts up.",
495
501
  "For web_search / fetch_webpage results, the URL is right there in the tool output — use it: `[headline or short label](https://...)`. For file_read results, link the absolute path: `[filename.ext](/abs/path/filename.ext)`.",
496
502
  "FALLBACK (declared refs block): if a source is awkward to cite inline (e.g. you summarized 4 articles in one paragraph), you MAY end your reply with a fenced block exactly like this:",
497
503
  "```jarela-references",
@@ -395,3 +395,79 @@ describe("applyAction(update_agent) instructions_append", () => {
395
395
  expect(String(result.detail)).toMatch(/one instructions mode only/);
396
396
  });
397
397
  });
398
+
399
+ describe("applyAction(update_agent) edge cases", () => {
400
+ function seedTunedAgent(id: string) {
401
+ upsertAgentConfig({
402
+ id,
403
+ name: id,
404
+ identity: "test agent",
405
+ instructions: "be helpful",
406
+ tools: ["web_search"],
407
+ hot_turn_limit: 12,
408
+ adaptive_persona_enabled: true,
409
+ adaptive_mbti: "ENFP",
410
+ adaptive_persona_strength: 91,
411
+ adaptive_empathy: 7,
412
+ adaptive_expressiveness: 88,
413
+ adaptive_verbosity: 3,
414
+ never_reply: true,
415
+ });
416
+ }
417
+
418
+ it("applies hot_turn_limit, which the propose tool advertises", async () => {
419
+ seedTunedAgent("agent-hot-turns");
420
+ const result = await applyAction("update_agent", { agent_id: "agent-hot-turns", hot_turn_limit: 4 });
421
+ expect(result.ok).toBe(true);
422
+ expect(getAgentConfig("agent-hot-turns")!.hot_turn_limit).toBe(4);
423
+ });
424
+
425
+ it.each([
426
+ ["hot_turn_limit", -1],
427
+ ["hot_turn_limit", 2.5],
428
+ ["history_limit", 1.5],
429
+ ["history_limit", -5],
430
+ ["history_window_hours", -1],
431
+ ])("rejects %s=%s without writing", async (field, value) => {
432
+ seedTunedAgent(`agent-bad-${field}`);
433
+ const before = getAgentConfig(`agent-bad-${field}`)!;
434
+ const result = await applyAction("update_agent", { agent_id: `agent-bad-${field}`, [field]: value });
435
+ expect(result.ok).toBe(false);
436
+ expect(String(result.detail)).toContain(field);
437
+ expect(getAgentConfig(`agent-bad-${field}`)).toEqual(before);
438
+ });
439
+
440
+ it("keeps settings the proposal did not mention, including tuned persona sliders", async () => {
441
+ seedTunedAgent("agent-keep");
442
+ const before = getAgentConfig("agent-keep")!;
443
+ expect((await applyAction("update_agent", { agent_id: "agent-keep", identity: "renamed" })).ok).toBe(true);
444
+ const after = getAgentConfig("agent-keep")!;
445
+ expect(after.identity).toBe("renamed");
446
+ expect(after).toMatchObject({
447
+ adaptive_mbti: before.adaptive_mbti,
448
+ adaptive_persona_strength: 91,
449
+ adaptive_empathy: 7,
450
+ adaptive_expressiveness: 88,
451
+ adaptive_verbosity: 3,
452
+ never_reply: before.never_reply,
453
+ hot_turn_limit: 12,
454
+ });
455
+ });
456
+
457
+ it("keeps tuned persona sliders when only tools change", async () => {
458
+ seedTunedAgent("agent-keep-tools");
459
+ expect((await applyAction("update_agent_tools", { agent_id: "agent-keep-tools", tools: ["memory_write"] })).ok).toBe(true);
460
+ expect(getAgentConfig("agent-keep-tools")).toMatchObject({ adaptive_persona_strength: 91, adaptive_verbosity: 3 });
461
+ });
462
+
463
+ it("drops non-string tool names instead of storing them", async () => {
464
+ seedTunedAgent("agent-tool-junk");
465
+ const result = await applyAction("update_agent_tools", {
466
+ agent_id: "agent-tool-junk",
467
+ tools: ["memory_write", 42, null, "", " memory_write "],
468
+ mode: "replace",
469
+ });
470
+ expect(result.ok).toBe(true);
471
+ expect(JSON.parse(getAgentConfig("agent-tool-junk")!.tools)).toEqual(["memory_write"]);
472
+ });
473
+ });
@@ -130,9 +130,13 @@ function applyUpdateAgentTools(payload: unknown): ApplyResult {
130
130
  const before = getAgentTools(existing);
131
131
  const mode = p.mode ?? "add";
132
132
  if (mode !== "add" && mode !== "replace") return { ok: false, detail: "mode must be add or replace" };
133
+ const requested = (p.tools as unknown[])
134
+ .filter((tool): tool is string => typeof tool === "string")
135
+ .map((tool) => tool.trim())
136
+ .filter(Boolean);
133
137
  const nextTools = mode === "replace"
134
- ? [...new Set(p.tools)]
135
- : [...new Set([...before, ...p.tools])];
138
+ ? [...new Set(requested)]
139
+ : [...new Set([...before, ...requested])];
136
140
  upsertAgentConfig({
137
141
  id: existing.id,
138
142
  name: existing.name,
@@ -154,13 +158,16 @@ function applyUpdateAgent(payload: unknown): ApplyResult {
154
158
  instructions_edits?: unknown;
155
159
  history_limit?: number;
156
160
  history_window_hours?: number;
161
+ hot_turn_limit?: number;
157
162
  warm_summary_limit?: number;
158
163
  harness_id?: string | null;
159
164
  };
160
165
  if (!p.agent_id) return { ok: false, detail: "agent_id required" };
161
- if (p.warm_summary_limit !== undefined
162
- && (!Number.isInteger(p.warm_summary_limit) || p.warm_summary_limit < 0)) {
163
- return { ok: false, detail: "warm_summary_limit must be a nonnegative integer" };
166
+ for (const field of ["warm_summary_limit", "hot_turn_limit", "history_limit", "history_window_hours"] as const) {
167
+ const value = p[field];
168
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
169
+ return { ok: false, detail: `${field} must be a nonnegative integer` };
170
+ }
164
171
  }
165
172
  const editModes = [
166
173
  p.instructions !== undefined,
@@ -209,6 +216,7 @@ function applyUpdateAgent(payload: unknown): ApplyResult {
209
216
  model_config_name: existing.model_config_name,
210
217
  history_limit: p.history_limit ?? existing.history_limit,
211
218
  history_window_hours: p.history_window_hours ?? existing.history_window_hours,
219
+ hot_turn_limit: p.hot_turn_limit,
212
220
  warm_summary_limit: p.warm_summary_limit ?? existing.warm_summary_limit,
213
221
  harness_id: p.harness_id,
214
222
  });