@amodalai/runtime 0.1.26 → 0.2.1

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 (445) hide show
  1. package/dist/src/__fixtures__/README.md +88 -0
  2. package/dist/src/__fixtures__/e2e.test.js +211 -0
  3. package/dist/src/__fixtures__/e2e.test.js.map +1 -0
  4. package/dist/src/__fixtures__/smoke-agent/amodal.json +11 -0
  5. package/dist/src/__fixtures__/smoke-agent/automations/delivery-callback-test.json +9 -0
  6. package/dist/src/__fixtures__/smoke-agent/automations/test-auto.md +5 -0
  7. package/dist/src/__fixtures__/smoke-agent/connections/mock-api/access.json +11 -0
  8. package/dist/src/__fixtures__/smoke-agent/connections/mock-api/spec.json +4 -0
  9. package/dist/src/__fixtures__/smoke-agent/connections/mock-api/surface.md +9 -0
  10. package/dist/src/__fixtures__/smoke-agent/connections/mock-mcp/access.json +3 -0
  11. package/dist/src/__fixtures__/smoke-agent/connections/mock-mcp/spec.json +8 -0
  12. package/dist/src/__fixtures__/smoke-agent/evals/basic-eval.md +12 -0
  13. package/dist/src/__fixtures__/smoke-agent/knowledge/test-knowledge.md +3 -0
  14. package/dist/src/__fixtures__/smoke-agent/skills/test-skill/SKILL.md +11 -0
  15. package/dist/src/__fixtures__/smoke-agent/stores/test-items.json +11 -0
  16. package/dist/src/__fixtures__/smoke-agent/tools/echo_tool/handler.d.ts +18 -0
  17. package/dist/src/__fixtures__/smoke-agent/tools/echo_tool/handler.js +22 -0
  18. package/dist/src/__fixtures__/smoke-agent/tools/echo_tool/handler.js.map +1 -0
  19. package/dist/src/__fixtures__/smoke-agent/tools/echo_tool/tool.json +17 -0
  20. package/dist/src/__fixtures__/smoke.test.js +1404 -0
  21. package/dist/src/__fixtures__/smoke.test.js.map +1 -0
  22. package/dist/src/__fixtures__/test-env.d.ts +27 -0
  23. package/dist/src/__fixtures__/test-env.js +64 -0
  24. package/dist/src/__fixtures__/test-env.js.map +1 -0
  25. package/dist/src/__fixtures__/test-helpers.d.ts +30 -0
  26. package/dist/src/__fixtures__/test-helpers.js +120 -0
  27. package/dist/src/__fixtures__/test-helpers.js.map +1 -0
  28. package/dist/src/__tests__/test-providers.d.ts +40 -0
  29. package/dist/src/__tests__/test-providers.js +61 -0
  30. package/dist/src/__tests__/test-providers.js.map +1 -0
  31. package/dist/src/agent/agent-types.d.ts +22 -0
  32. package/dist/src/agent/agent-types.js.map +1 -1
  33. package/dist/src/agent/automation-bridge.d.ts +9 -0
  34. package/dist/src/agent/automation-bridge.js +26 -0
  35. package/dist/src/agent/automation-bridge.js.map +1 -1
  36. package/dist/src/agent/automation-bridge.test.js +63 -0
  37. package/dist/src/agent/automation-bridge.test.js.map +1 -1
  38. package/dist/src/agent/local-server.d.ts +1 -8
  39. package/dist/src/agent/local-server.js +398 -163
  40. package/dist/src/agent/local-server.js.map +1 -1
  41. package/dist/src/agent/local-server.test.js +14 -8
  42. package/dist/src/agent/local-server.test.js.map +1 -1
  43. package/dist/src/agent/loop-types.d.ts +254 -0
  44. package/dist/src/agent/loop-types.js +24 -0
  45. package/dist/src/agent/loop-types.js.map +1 -0
  46. package/dist/src/agent/loop.d.ts +31 -0
  47. package/dist/src/agent/loop.js +152 -0
  48. package/dist/src/agent/loop.js.map +1 -0
  49. package/dist/src/agent/loop.test.js +1594 -0
  50. package/dist/src/agent/loop.test.js.map +1 -0
  51. package/dist/src/agent/mcp-config.d.ts +28 -0
  52. package/dist/src/agent/mcp-config.js +57 -0
  53. package/dist/src/agent/mcp-config.js.map +1 -0
  54. package/dist/src/agent/page-builder.js +6 -1
  55. package/dist/src/agent/page-builder.js.map +1 -1
  56. package/dist/src/agent/proactive/delivery-router.d.ts +68 -0
  57. package/dist/src/agent/proactive/delivery-router.js +337 -0
  58. package/dist/src/agent/proactive/delivery-router.js.map +1 -0
  59. package/dist/src/agent/{stores-e2e.test.d.ts → proactive/delivery-router.test.d.ts} +1 -1
  60. package/dist/src/agent/proactive/delivery-router.test.js +455 -0
  61. package/dist/src/agent/proactive/delivery-router.test.js.map +1 -0
  62. package/dist/src/agent/proactive/proactive-runner.d.ts +46 -8
  63. package/dist/src/agent/proactive/proactive-runner.js +67 -37
  64. package/dist/src/agent/proactive/proactive-runner.js.map +1 -1
  65. package/dist/src/agent/proactive/proactive-runner.test.d.ts +1 -1
  66. package/dist/src/agent/proactive/proactive-runner.test.js +73 -87
  67. package/dist/src/agent/proactive/proactive-runner.test.js.map +1 -1
  68. package/dist/src/agent/routes/admin-chat-abort.test.d.ts +6 -0
  69. package/dist/src/agent/routes/admin-chat-abort.test.js +206 -0
  70. package/dist/src/agent/routes/admin-chat-abort.test.js.map +1 -0
  71. package/dist/src/agent/routes/admin-chat.d.ts +15 -3
  72. package/dist/src/agent/routes/admin-chat.js +61 -18
  73. package/dist/src/agent/routes/admin-chat.js.map +1 -1
  74. package/dist/src/agent/routes/automations.js +5 -6
  75. package/dist/src/agent/routes/automations.js.map +1 -1
  76. package/dist/src/agent/routes/evals.d.ts +3 -2
  77. package/dist/src/agent/routes/evals.js +25 -12
  78. package/dist/src/agent/routes/evals.js.map +1 -1
  79. package/dist/src/agent/routes/files.js +7 -9
  80. package/dist/src/agent/routes/files.js.map +1 -1
  81. package/dist/src/agent/routes/inspect.d.ts +6 -2
  82. package/dist/src/agent/routes/inspect.js +31 -17
  83. package/dist/src/agent/routes/inspect.js.map +1 -1
  84. package/dist/src/agent/routes/inspect.test.js +18 -42
  85. package/dist/src/agent/routes/inspect.test.js.map +1 -1
  86. package/dist/src/agent/routes/stores.js +9 -12
  87. package/dist/src/agent/routes/stores.js.map +1 -1
  88. package/dist/src/agent/routes/task.d.ts +15 -3
  89. package/dist/src/agent/routes/task.js +16 -7
  90. package/dist/src/agent/routes/task.js.map +1 -1
  91. package/dist/src/agent/routes/task.test.d.ts +1 -1
  92. package/dist/src/agent/routes/task.test.js +68 -53
  93. package/dist/src/agent/routes/task.test.js.map +1 -1
  94. package/dist/src/agent/routes/webhooks.js +12 -3
  95. package/dist/src/agent/routes/webhooks.js.map +1 -1
  96. package/dist/src/agent/snapshot-server.d.ts +2 -22
  97. package/dist/src/agent/snapshot-server.js +48 -27
  98. package/dist/src/agent/snapshot-server.js.map +1 -1
  99. package/dist/src/agent/states/compacting.d.ts +14 -0
  100. package/dist/src/agent/states/compacting.js +260 -0
  101. package/dist/src/agent/states/compacting.js.map +1 -0
  102. package/dist/src/agent/states/confirming.d.ts +10 -0
  103. package/dist/src/agent/states/confirming.js +79 -0
  104. package/dist/src/agent/states/confirming.js.map +1 -0
  105. package/dist/src/agent/states/dispatching.d.ts +18 -0
  106. package/dist/src/agent/states/dispatching.js +285 -0
  107. package/dist/src/agent/states/dispatching.js.map +1 -0
  108. package/dist/src/agent/states/executing.d.ts +21 -0
  109. package/dist/src/agent/states/executing.js +452 -0
  110. package/dist/src/agent/states/executing.js.map +1 -0
  111. package/dist/src/agent/states/streaming.d.ts +10 -0
  112. package/dist/src/agent/states/streaming.js +169 -0
  113. package/dist/src/agent/states/streaming.js.map +1 -0
  114. package/dist/src/agent/states/thinking.d.ts +13 -0
  115. package/dist/src/agent/states/thinking.js +450 -0
  116. package/dist/src/agent/states/thinking.js.map +1 -0
  117. package/dist/src/agent/token-estimate.d.ts +31 -0
  118. package/dist/src/agent/token-estimate.js +34 -0
  119. package/dist/src/agent/token-estimate.js.map +1 -0
  120. package/dist/src/agent/token-estimate.test.d.ts +6 -0
  121. package/dist/src/agent/token-estimate.test.js +44 -0
  122. package/dist/src/agent/token-estimate.test.js.map +1 -0
  123. package/dist/src/agent/tool-executor-local.js +9 -18
  124. package/dist/src/agent/tool-executor-local.js.map +1 -1
  125. package/dist/src/agent/tool-executor-local.test.js +3 -5
  126. package/dist/src/agent/tool-executor-local.test.js.map +1 -1
  127. package/dist/src/api/create-agent.d.ts +15 -0
  128. package/dist/src/api/create-agent.js +134 -0
  129. package/dist/src/api/create-agent.js.map +1 -0
  130. package/dist/src/api/types.d.ts +66 -0
  131. package/dist/src/api/types.js +7 -0
  132. package/dist/src/api/types.js.map +1 -0
  133. package/dist/src/context/compiler.d.ts +13 -0
  134. package/dist/src/context/compiler.js +358 -0
  135. package/dist/src/context/compiler.js.map +1 -0
  136. package/dist/src/context/compiler.test.d.ts +6 -0
  137. package/dist/src/context/compiler.test.js +532 -0
  138. package/dist/src/context/compiler.test.js.map +1 -0
  139. package/dist/src/context/types.d.ts +110 -0
  140. package/dist/src/context/types.js +7 -0
  141. package/dist/src/context/types.js.map +1 -0
  142. package/dist/src/env-ref.d.ts +13 -0
  143. package/dist/src/env-ref.js +31 -0
  144. package/dist/src/env-ref.js.map +1 -0
  145. package/dist/src/env-ref.test.d.ts +6 -0
  146. package/dist/src/env-ref.test.js +34 -0
  147. package/dist/src/env-ref.test.js.map +1 -0
  148. package/dist/src/errors.d.ts +15 -0
  149. package/dist/src/errors.js +22 -0
  150. package/dist/src/errors.js.map +1 -1
  151. package/dist/src/errors.test.js +2 -2
  152. package/dist/src/errors.test.js.map +1 -1
  153. package/dist/src/events/event-bus.d.ts +54 -0
  154. package/dist/src/events/event-bus.js +84 -0
  155. package/dist/src/events/event-bus.js.map +1 -0
  156. package/dist/src/events/event-bus.test.d.ts +6 -0
  157. package/dist/src/events/event-bus.test.js +112 -0
  158. package/dist/src/events/event-bus.test.js.map +1 -0
  159. package/dist/src/events/events-route.d.ts +36 -0
  160. package/dist/src/events/events-route.js +80 -0
  161. package/dist/src/events/events-route.js.map +1 -0
  162. package/dist/src/events/events-route.test.d.ts +6 -0
  163. package/dist/src/events/events-route.test.js +134 -0
  164. package/dist/src/events/events-route.test.js.map +1 -0
  165. package/dist/src/events/store-event-wrapper.d.ts +19 -0
  166. package/dist/src/events/store-event-wrapper.js +57 -0
  167. package/dist/src/events/store-event-wrapper.js.map +1 -0
  168. package/dist/src/events/store-event-wrapper.test.d.ts +6 -0
  169. package/dist/src/events/store-event-wrapper.test.js +91 -0
  170. package/dist/src/events/store-event-wrapper.test.js.map +1 -0
  171. package/dist/src/index.d.ts +33 -6
  172. package/dist/src/index.js +35 -21
  173. package/dist/src/index.js.map +1 -1
  174. package/dist/src/middleware/auth.d.ts +0 -2
  175. package/dist/src/middleware/auth.js.map +1 -1
  176. package/dist/src/providers/create-provider.d.ts +23 -0
  177. package/dist/src/providers/create-provider.js +185 -0
  178. package/dist/src/providers/create-provider.js.map +1 -0
  179. package/dist/src/providers/create-provider.test.d.ts +6 -0
  180. package/dist/src/providers/create-provider.test.js +95 -0
  181. package/dist/src/providers/create-provider.test.js.map +1 -0
  182. package/dist/src/providers/failover.d.ts +38 -0
  183. package/dist/src/providers/failover.js +147 -0
  184. package/dist/src/providers/failover.js.map +1 -0
  185. package/dist/src/providers/failover.test.d.ts +6 -0
  186. package/dist/src/providers/failover.test.js +169 -0
  187. package/dist/src/providers/failover.test.js.map +1 -0
  188. package/dist/src/providers/search-provider.d.ts +64 -0
  189. package/dist/src/providers/search-provider.js +174 -0
  190. package/dist/src/providers/search-provider.js.map +1 -0
  191. package/dist/src/providers/types.d.ts +118 -0
  192. package/dist/src/providers/types.js +7 -0
  193. package/dist/src/providers/types.js.map +1 -0
  194. package/dist/src/routes/ai-stream.d.ts +28 -10
  195. package/dist/src/routes/ai-stream.js +85 -41
  196. package/dist/src/routes/ai-stream.js.map +1 -1
  197. package/dist/src/routes/chat-new.test.d.ts +6 -0
  198. package/dist/src/routes/chat-new.test.js +107 -0
  199. package/dist/src/routes/chat-new.test.js.map +1 -0
  200. package/dist/src/routes/chat-stream-new.test.d.ts +6 -0
  201. package/dist/src/routes/chat-stream-new.test.js +135 -0
  202. package/dist/src/routes/chat-stream-new.test.js.map +1 -0
  203. package/dist/src/routes/chat-stream.d.ts +20 -4
  204. package/dist/src/routes/chat-stream.js +49 -29
  205. package/dist/src/routes/chat-stream.js.map +1 -1
  206. package/dist/src/routes/chat.d.ts +19 -4
  207. package/dist/src/routes/chat.js +62 -23
  208. package/dist/src/routes/chat.js.map +1 -1
  209. package/dist/src/routes/health.d.ts +3 -2
  210. package/dist/src/routes/health.js.map +1 -1
  211. package/dist/src/routes/route-helpers.d.ts +50 -0
  212. package/dist/src/routes/route-helpers.js +80 -0
  213. package/dist/src/routes/route-helpers.js.map +1 -0
  214. package/dist/src/routes/session-resolver.d.ts +77 -0
  215. package/dist/src/routes/session-resolver.js +109 -0
  216. package/dist/src/routes/session-resolver.js.map +1 -0
  217. package/dist/src/routes/session-resolver.test.d.ts +6 -0
  218. package/dist/src/routes/session-resolver.test.js +207 -0
  219. package/dist/src/routes/session-resolver.test.js.map +1 -0
  220. package/dist/src/routes/webhooks.d.ts +3 -1
  221. package/dist/src/routes/webhooks.js +12 -4
  222. package/dist/src/routes/webhooks.js.map +1 -1
  223. package/dist/src/security/permission-checker.d.ts +80 -0
  224. package/dist/src/security/permission-checker.js +75 -0
  225. package/dist/src/security/permission-checker.js.map +1 -0
  226. package/dist/src/security/permission-checker.test.d.ts +6 -0
  227. package/dist/src/security/permission-checker.test.js +208 -0
  228. package/dist/src/security/permission-checker.test.js.map +1 -0
  229. package/dist/src/server.d.ts +18 -11
  230. package/dist/src/server.js +46 -46
  231. package/dist/src/server.js.map +1 -1
  232. package/dist/src/server.test.d.ts +1 -1
  233. package/dist/src/server.test.js +6 -144
  234. package/dist/src/server.test.js.map +1 -1
  235. package/dist/src/session/drizzle-session-store.d.ts +56 -0
  236. package/dist/src/session/drizzle-session-store.js +203 -0
  237. package/dist/src/session/drizzle-session-store.js.map +1 -0
  238. package/dist/src/session/manager.d.ts +101 -0
  239. package/dist/src/session/manager.js +394 -0
  240. package/dist/src/session/manager.js.map +1 -0
  241. package/dist/src/session/manager.test.d.ts +6 -0
  242. package/dist/src/session/manager.test.js +309 -0
  243. package/dist/src/session/manager.test.js.map +1 -0
  244. package/dist/src/session/pglite-session-store.d.ts +23 -0
  245. package/dist/src/session/pglite-session-store.js +70 -0
  246. package/dist/src/session/pglite-session-store.js.map +1 -0
  247. package/dist/src/session/postgres-session-store.d.ts +44 -0
  248. package/dist/src/session/postgres-session-store.js +138 -0
  249. package/dist/src/session/postgres-session-store.js.map +1 -0
  250. package/dist/src/session/session-builder.d.ts +69 -0
  251. package/dist/src/session/session-builder.js +384 -0
  252. package/dist/src/session/session-builder.js.map +1 -0
  253. package/dist/src/session/session-builder.test.d.ts +6 -0
  254. package/dist/src/session/session-builder.test.js +350 -0
  255. package/dist/src/session/session-builder.test.js.map +1 -0
  256. package/dist/src/session/session-store-selector.d.ts +49 -0
  257. package/dist/src/session/session-store-selector.js +60 -0
  258. package/dist/src/session/session-store-selector.js.map +1 -0
  259. package/dist/src/session/session-store-selector.test.d.ts +6 -0
  260. package/dist/src/session/session-store-selector.test.js +79 -0
  261. package/dist/src/session/session-store-selector.test.js.map +1 -0
  262. package/dist/src/session/store.d.ts +171 -0
  263. package/dist/src/session/store.js +155 -0
  264. package/dist/src/session/store.js.map +1 -0
  265. package/dist/src/session/store.test.d.ts +6 -0
  266. package/dist/src/session/store.test.js +423 -0
  267. package/dist/src/session/store.test.js.map +1 -0
  268. package/dist/src/session/stream-hooks.d.ts +39 -0
  269. package/dist/src/session/stream-hooks.js +7 -0
  270. package/dist/src/session/stream-hooks.js.map +1 -0
  271. package/dist/src/session/tool-context-factory.d.ts +61 -0
  272. package/dist/src/session/tool-context-factory.js +189 -0
  273. package/dist/src/session/tool-context-factory.js.map +1 -0
  274. package/dist/src/session/tool-context-factory.test.d.ts +6 -0
  275. package/dist/src/session/tool-context-factory.test.js +284 -0
  276. package/dist/src/session/tool-context-factory.test.js.map +1 -0
  277. package/dist/src/session/types.d.ts +195 -0
  278. package/dist/src/session/types.js +7 -0
  279. package/dist/src/session/types.js.map +1 -0
  280. package/dist/src/stores/drizzle-store-backend.d.ts +49 -0
  281. package/dist/src/stores/drizzle-store-backend.js +306 -0
  282. package/dist/src/stores/drizzle-store-backend.js.map +1 -0
  283. package/dist/src/stores/drizzle-store-backend.test.d.ts +6 -0
  284. package/dist/src/stores/drizzle-store-backend.test.js +215 -0
  285. package/dist/src/stores/drizzle-store-backend.test.js.map +1 -0
  286. package/dist/src/stores/index.d.ts +4 -0
  287. package/dist/src/stores/index.js +2 -0
  288. package/dist/src/stores/index.js.map +1 -1
  289. package/dist/src/stores/pglite-store-backend.d.ts +16 -19
  290. package/dist/src/stores/pglite-store-backend.js +85 -239
  291. package/dist/src/stores/pglite-store-backend.js.map +1 -1
  292. package/dist/src/stores/postgres-store-backend.d.ts +30 -0
  293. package/dist/src/stores/postgres-store-backend.js +100 -0
  294. package/dist/src/stores/postgres-store-backend.js.map +1 -0
  295. package/dist/src/stores/schema.d.ts +457 -0
  296. package/dist/src/stores/schema.js +59 -0
  297. package/dist/src/stores/schema.js.map +1 -0
  298. package/dist/src/tools/admin-file-tools.d.ts +42 -0
  299. package/dist/src/tools/admin-file-tools.js +714 -0
  300. package/dist/src/tools/admin-file-tools.js.map +1 -0
  301. package/dist/src/tools/admin-file-tools.test.d.ts +6 -0
  302. package/dist/src/tools/admin-file-tools.test.js +521 -0
  303. package/dist/src/tools/admin-file-tools.test.js.map +1 -0
  304. package/dist/src/tools/custom-tool-adapter.d.ts +41 -0
  305. package/dist/src/tools/custom-tool-adapter.js +190 -0
  306. package/dist/src/tools/custom-tool-adapter.js.map +1 -0
  307. package/dist/src/tools/custom-tool-adapter.test.d.ts +6 -0
  308. package/dist/src/tools/custom-tool-adapter.test.js +243 -0
  309. package/dist/src/tools/custom-tool-adapter.test.js.map +1 -0
  310. package/dist/src/tools/dispatch-tool.d.ts +52 -0
  311. package/dist/src/tools/dispatch-tool.js +71 -0
  312. package/dist/src/tools/dispatch-tool.js.map +1 -0
  313. package/dist/src/tools/dispatch-tool.test.d.ts +6 -0
  314. package/dist/src/tools/dispatch-tool.test.js +75 -0
  315. package/dist/src/tools/dispatch-tool.test.js.map +1 -0
  316. package/dist/src/tools/fetch-url-tool.d.ts +23 -0
  317. package/dist/src/tools/fetch-url-tool.js +333 -0
  318. package/dist/src/tools/fetch-url-tool.js.map +1 -0
  319. package/dist/src/tools/fetch-url-tool.test.d.ts +6 -0
  320. package/dist/src/tools/fetch-url-tool.test.js +228 -0
  321. package/dist/src/tools/fetch-url-tool.test.js.map +1 -0
  322. package/dist/src/tools/mcp-tool-adapter.d.ts +18 -0
  323. package/dist/src/tools/mcp-tool-adapter.js +135 -0
  324. package/dist/src/tools/mcp-tool-adapter.js.map +1 -0
  325. package/dist/src/tools/mcp-tool-adapter.test.d.ts +6 -0
  326. package/dist/src/tools/mcp-tool-adapter.test.js +226 -0
  327. package/dist/src/tools/mcp-tool-adapter.test.js.map +1 -0
  328. package/dist/src/tools/registry.d.ts +25 -0
  329. package/dist/src/tools/registry.js +72 -0
  330. package/dist/src/tools/registry.js.map +1 -0
  331. package/dist/src/tools/registry.test.d.ts +6 -0
  332. package/dist/src/tools/registry.test.js +120 -0
  333. package/dist/src/tools/registry.test.js.map +1 -0
  334. package/dist/src/tools/request-tool.d.ts +42 -0
  335. package/dist/src/tools/request-tool.js +190 -0
  336. package/dist/src/tools/request-tool.js.map +1 -0
  337. package/dist/src/tools/request-tool.test.d.ts +6 -0
  338. package/dist/src/tools/request-tool.test.js +253 -0
  339. package/dist/src/tools/request-tool.test.js.map +1 -0
  340. package/dist/src/tools/store-tools.d.ts +29 -0
  341. package/dist/src/tools/store-tools.js +224 -0
  342. package/dist/src/tools/store-tools.js.map +1 -0
  343. package/dist/src/tools/store-tools.test.d.ts +6 -0
  344. package/dist/src/tools/store-tools.test.js +215 -0
  345. package/dist/src/tools/store-tools.test.js.map +1 -0
  346. package/dist/src/tools/types.d.ts +129 -0
  347. package/dist/src/tools/types.js +7 -0
  348. package/dist/src/tools/types.js.map +1 -0
  349. package/dist/src/tools/web-search-tool.d.ts +31 -0
  350. package/dist/src/tools/web-search-tool.js +170 -0
  351. package/dist/src/tools/web-search-tool.js.map +1 -0
  352. package/dist/src/tools/web-search-tool.test.d.ts +6 -0
  353. package/dist/src/tools/web-search-tool.test.js +153 -0
  354. package/dist/src/tools/web-search-tool.test.js.map +1 -0
  355. package/dist/src/tools/web-tools-shared.d.ts +21 -0
  356. package/dist/src/tools/web-tools-shared.js +32 -0
  357. package/dist/src/tools/web-tools-shared.js.map +1 -0
  358. package/dist/src/types.d.ts +40 -12
  359. package/dist/src/types.js +16 -2
  360. package/dist/src/types.js.map +1 -1
  361. package/dist/tsconfig.tsbuildinfo +1 -1
  362. package/package.json +27 -4
  363. package/dist/src/__tests__/sse-contract.test.js +0 -464
  364. package/dist/src/__tests__/sse-contract.test.js.map +0 -1
  365. package/dist/src/__tests__/tools.test.js +0 -583
  366. package/dist/src/__tests__/tools.test.js.map +0 -1
  367. package/dist/src/agent/agent-runner.d.ts +0 -33
  368. package/dist/src/agent/agent-runner.js +0 -1040
  369. package/dist/src/agent/agent-runner.js.map +0 -1
  370. package/dist/src/agent/custom-tools-e2e.test.d.ts +0 -6
  371. package/dist/src/agent/custom-tools-e2e.test.js +0 -566
  372. package/dist/src/agent/custom-tools-e2e.test.js.map +0 -1
  373. package/dist/src/agent/request-helper.d.ts +0 -16
  374. package/dist/src/agent/request-helper.js +0 -96
  375. package/dist/src/agent/request-helper.js.map +0 -1
  376. package/dist/src/agent/session-store.d.ts +0 -62
  377. package/dist/src/agent/session-store.js +0 -151
  378. package/dist/src/agent/session-store.js.map +0 -1
  379. package/dist/src/agent/stores-e2e.test.js +0 -433
  380. package/dist/src/agent/stores-e2e.test.js.map +0 -1
  381. package/dist/src/agent/tool-context-builder.d.ts +0 -11
  382. package/dist/src/agent/tool-context-builder.js +0 -102
  383. package/dist/src/agent/tool-context-builder.js.map +0 -1
  384. package/dist/src/agent/tool-context-builder.test.d.ts +0 -6
  385. package/dist/src/agent/tool-context-builder.test.js +0 -152
  386. package/dist/src/agent/tool-context-builder.test.js.map +0 -1
  387. package/dist/src/agent/write-repo-file.test.js +0 -270
  388. package/dist/src/agent/write-repo-file.test.js.map +0 -1
  389. package/dist/src/cron/heartbeat-runner.d.ts +0 -21
  390. package/dist/src/cron/heartbeat-runner.js +0 -79
  391. package/dist/src/cron/heartbeat-runner.js.map +0 -1
  392. package/dist/src/cron/heartbeat-runner.test.d.ts +0 -6
  393. package/dist/src/cron/heartbeat-runner.test.js +0 -120
  394. package/dist/src/cron/heartbeat-runner.test.js.map +0 -1
  395. package/dist/src/cron/heartbeat-scheduler.d.ts +0 -26
  396. package/dist/src/cron/heartbeat-scheduler.js +0 -55
  397. package/dist/src/cron/heartbeat-scheduler.js.map +0 -1
  398. package/dist/src/cron/heartbeat-scheduler.test.d.ts +0 -6
  399. package/dist/src/cron/heartbeat-scheduler.test.js +0 -61
  400. package/dist/src/cron/heartbeat-scheduler.test.js.map +0 -1
  401. package/dist/src/routes/ai-stream.test.d.ts +0 -6
  402. package/dist/src/routes/ai-stream.test.js +0 -586
  403. package/dist/src/routes/ai-stream.test.js.map +0 -1
  404. package/dist/src/routes/ask-user-response.d.ts +0 -30
  405. package/dist/src/routes/ask-user-response.js +0 -61
  406. package/dist/src/routes/ask-user-response.js.map +0 -1
  407. package/dist/src/routes/ask-user-response.test.d.ts +0 -6
  408. package/dist/src/routes/ask-user-response.test.js +0 -88
  409. package/dist/src/routes/ask-user-response.test.js.map +0 -1
  410. package/dist/src/routes/chat-stream.test.d.ts +0 -6
  411. package/dist/src/routes/chat-stream.test.js +0 -155
  412. package/dist/src/routes/chat-stream.test.js.map +0 -1
  413. package/dist/src/routes/chat.test.d.ts +0 -6
  414. package/dist/src/routes/chat.test.js +0 -99
  415. package/dist/src/routes/chat.test.js.map +0 -1
  416. package/dist/src/routes/widget-actions.d.ts +0 -49
  417. package/dist/src/routes/widget-actions.js +0 -78
  418. package/dist/src/routes/widget-actions.js.map +0 -1
  419. package/dist/src/session/admin-file-tools.d.ts +0 -136
  420. package/dist/src/session/admin-file-tools.js +0 -240
  421. package/dist/src/session/admin-file-tools.js.map +0 -1
  422. package/dist/src/session/custom-tool-adapter.d.ts +0 -74
  423. package/dist/src/session/custom-tool-adapter.js +0 -180
  424. package/dist/src/session/custom-tool-adapter.js.map +0 -1
  425. package/dist/src/session/history-converter.d.ts +0 -21
  426. package/dist/src/session/history-converter.js +0 -59
  427. package/dist/src/session/history-converter.js.map +0 -1
  428. package/dist/src/session/history-converter.test.d.ts +0 -6
  429. package/dist/src/session/history-converter.test.js +0 -130
  430. package/dist/src/session/history-converter.test.js.map +0 -1
  431. package/dist/src/session/session-manager.d.ts +0 -219
  432. package/dist/src/session/session-manager.js +0 -915
  433. package/dist/src/session/session-manager.js.map +0 -1
  434. package/dist/src/session/session-manager.test.d.ts +0 -6
  435. package/dist/src/session/session-manager.test.js +0 -455
  436. package/dist/src/session/session-manager.test.js.map +0 -1
  437. package/dist/src/session/session-runner.d.ts +0 -45
  438. package/dist/src/session/session-runner.js +0 -719
  439. package/dist/src/session/session-runner.js.map +0 -1
  440. package/dist/src/session/session-runner.test.d.ts +0 -6
  441. package/dist/src/session/session-runner.test.js +0 -834
  442. package/dist/src/session/session-runner.test.js.map +0 -1
  443. /package/dist/src/{__tests__/sse-contract.test.d.ts → __fixtures__/e2e.test.d.ts} +0 -0
  444. /package/dist/src/{__tests__/tools.test.d.ts → __fixtures__/smoke.test.d.ts} +0 -0
  445. /package/dist/src/agent/{write-repo-file.test.d.ts → loop.test.d.ts} +0 -0
@@ -0,0 +1,1404 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ /**
7
+ * Smoke tests — end-to-end integration tests against a self-contained
8
+ * test agent with mock REST and MCP servers.
9
+ *
10
+ * Requires ANTHROPIC_API_KEY in the environment (skips otherwise).
11
+ * Starts amodal dev programmatically, runs assertions, tears down.
12
+ */
13
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14
+ import { fork } from 'node:child_process';
15
+ import { resolve } from 'node:path';
16
+ import { readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs';
17
+ import { expectDoneReason, expectTotalTokens } from './test-helpers.js';
18
+ import { loadTestEnv, defaultTargetName } from './test-env.js';
19
+ // Pull API keys out of <repo-root>/.env.test (gitignored). Missing keys
20
+ // cause the describe block below to skip with a reason.
21
+ loadTestEnv();
22
+ // ---------------------------------------------------------------------------
23
+ // Config
24
+ // ---------------------------------------------------------------------------
25
+ const AGENT_PORT = 9900;
26
+ const REST_PORT = 9901;
27
+ const AGENT_DIR = resolve(__dirname, 'smoke-agent');
28
+ const REST_SERVER = resolve(__dirname, 'smoke-rest-server.mjs');
29
+ const MCP_SERVER = resolve(__dirname, 'smoke-mcp-server.mjs');
30
+ const TIMEOUT = 45_000; // per-test timeout for LLM calls
31
+ const SMOKE_TARGETS = {
32
+ anthropic: { provider: 'anthropic', model: 'claude-sonnet-4-20250514', apiKeyEnv: 'ANTHROPIC_API_KEY' },
33
+ google: { provider: 'google', model: 'gemini-2.5-flash', apiKeyEnv: 'GOOGLE_API_KEY' },
34
+ openai: { provider: 'openai', model: 'gpt-4o-mini', apiKeyEnv: 'OPENAI_API_KEY' },
35
+ groq: { provider: 'groq', model: 'llama-3.3-70b-versatile', apiKeyEnv: 'GROQ_API_KEY' },
36
+ };
37
+ function pickSmokeTarget() {
38
+ const override = process.env['SMOKE_TARGET'];
39
+ const name = override ?? defaultTargetName(SMOKE_TARGETS);
40
+ return { name, target: SMOKE_TARGETS[name] };
41
+ }
42
+ const { name: smokeTargetName, target: smokeTarget } = pickSmokeTarget();
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+ async function waitForServer(port, maxMs = 15_000) {
47
+ const deadline = Date.now() + maxMs;
48
+ while (Date.now() < deadline) {
49
+ try {
50
+ const res = await fetch(`http://localhost:${port}/health`, { signal: AbortSignal.timeout(1000) });
51
+ if (res.ok)
52
+ return;
53
+ }
54
+ catch { /* not ready yet */ }
55
+ await new Promise((r) => setTimeout(r, 500));
56
+ }
57
+ throw new Error(`Server on port ${port} did not start within ${maxMs}ms`);
58
+ }
59
+ async function chat(message, sessionId, opts) {
60
+ const body = { message };
61
+ if (sessionId)
62
+ body['session_id'] = sessionId;
63
+ if (opts?.maxSessionTokens !== undefined)
64
+ body['max_session_tokens'] = opts.maxSessionTokens;
65
+ const res = await fetch(`http://localhost:${AGENT_PORT}/chat`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify(body),
69
+ signal: AbortSignal.timeout(TIMEOUT),
70
+ });
71
+ const text = await res.text();
72
+ const events = [];
73
+ let sid = '';
74
+ for (const line of text.split('\n')) {
75
+ if (!line.startsWith('data: '))
76
+ continue;
77
+ try {
78
+ const event = JSON.parse(line.slice(6));
79
+ events.push(event);
80
+ if (event['type'] === 'init' && typeof event['session_id'] === 'string') {
81
+ sid = event['session_id'];
82
+ }
83
+ }
84
+ catch { /* skip */ }
85
+ }
86
+ return { events, sessionId: sid };
87
+ }
88
+ function findEvent(events, type) {
89
+ return events.find((e) => e['type'] === type);
90
+ }
91
+ function findEvents(events, type) {
92
+ return events.filter((e) => e['type'] === type);
93
+ }
94
+ function allText(events) {
95
+ return events
96
+ .filter((e) => e['type'] === 'text_delta')
97
+ .map((e) => String(e['content'] ?? ''))
98
+ .join('');
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Setup / Teardown
102
+ // ---------------------------------------------------------------------------
103
+ let restServer = null;
104
+ let agentServer = null;
105
+ /** Captures payloads delivered to callback-type targets for assertion. */
106
+ const receivedAutomationResults = [];
107
+ const skipReason = !smokeTarget
108
+ ? `unknown SMOKE_TARGET "${smokeTargetName}"; known: ${Object.keys(SMOKE_TARGETS).join(', ')}`
109
+ : process.env[smokeTarget.apiKeyEnv]
110
+ ? ''
111
+ : `${smokeTarget.apiKeyEnv} not set`;
112
+ describe.skipIf(!!skipReason)(`smoke tests [${smokeTargetName}]`, () => {
113
+ // Stash fixture files so afterAll can restore them; otherwise the
114
+ // per-run rewrites (provider + absolute MCP path) leak into the repo.
115
+ const amodalPath = resolve(AGENT_DIR, 'amodal.json');
116
+ const mcpSpecPath = resolve(AGENT_DIR, 'connections/mock-mcp/spec.json');
117
+ const originalAmodalJson = readFileSync(amodalPath, 'utf-8');
118
+ const originalMcpSpec = readFileSync(mcpSpecPath, 'utf-8');
119
+ beforeAll(async () => {
120
+ // 0. Nuke prior state — clean slate for every run
121
+ rmSync(resolve(AGENT_DIR, '.amodal/store-data'), { recursive: true, force: true });
122
+ // 1. Rewrite amodal.json with the selected provider/model.
123
+ // smokeTarget is guaranteed defined here — skipReason above gates
124
+ // the describe block when it's undefined or missing a key.
125
+ if (!smokeTarget)
126
+ throw new Error('unreachable: smokeTarget is undefined under skipReason guard');
127
+ const amodalConfig = JSON.parse(originalAmodalJson);
128
+ amodalConfig['models'] = {
129
+ main: { provider: smokeTarget.provider, model: smokeTarget.model },
130
+ };
131
+ // Enable web_search + fetch_url tools when a Google API key is available.
132
+ // Key resolution happens in the core config parser via env: prefix.
133
+ if (process.env['GOOGLE_API_KEY']) {
134
+ amodalConfig['webTools'] = {
135
+ provider: 'google',
136
+ apiKey: 'env:GOOGLE_API_KEY',
137
+ model: 'gemini-3-flash-preview',
138
+ };
139
+ }
140
+ writeFileSync(amodalPath, JSON.stringify(amodalConfig, null, 2));
141
+ // 2. Write MCP server spec with absolute path (loadRepo reads this as-is).
142
+ // Restored in afterAll so the env-specific path doesn't leak into git.
143
+ writeFileSync(mcpSpecPath, JSON.stringify({ protocol: 'mcp', transport: 'stdio', command: 'node', args: [MCP_SERVER] }, null, 2));
144
+ // 3. Start mock REST server
145
+ restServer = fork(REST_SERVER, [], {
146
+ env: { ...process.env, SMOKE_REST_PORT: String(REST_PORT) },
147
+ stdio: 'pipe',
148
+ });
149
+ await new Promise((r) => setTimeout(r, 1000));
150
+ // 4. Start amodal dev programmatically
151
+ const { createLocalServer } = await import('../agent/local-server.js');
152
+ agentServer = await createLocalServer({
153
+ repoPath: AGENT_DIR,
154
+ port: AGENT_PORT,
155
+ hotReload: false,
156
+ onAutomationResult: (payload) => {
157
+ receivedAutomationResults.push(payload);
158
+ },
159
+ });
160
+ await agentServer.start();
161
+ await waitForServer(AGENT_PORT);
162
+ }, 30_000);
163
+ afterAll(async () => {
164
+ if (agentServer) {
165
+ await agentServer.stop();
166
+ }
167
+ if (restServer) {
168
+ restServer.kill('SIGTERM');
169
+ }
170
+ // Restore fixture files so the per-run rewrites stay test-local and
171
+ // don't show up in git status afterwards.
172
+ writeFileSync(amodalPath, originalAmodalJson);
173
+ writeFileSync(mcpSpecPath, originalMcpSpec);
174
+ });
175
+ // -------------------------------------------------------------------------
176
+ // 1. Server lifecycle
177
+ // -------------------------------------------------------------------------
178
+ it('health endpoint returns ok', async () => {
179
+ const res = await fetch(`http://localhost:${AGENT_PORT}/health`);
180
+ const body = await res.json();
181
+ expect(res.status).toBe(200);
182
+ expect(body['status']).toBe('ok');
183
+ });
184
+ it('config endpoint returns agent info', async () => {
185
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/config`);
186
+ const body = await res.json();
187
+ expect(res.status).toBe(200);
188
+ expect(body['name']).toBe('smoke-test-agent');
189
+ });
190
+ // -------------------------------------------------------------------------
191
+ // 2. System prompt (G9)
192
+ // -------------------------------------------------------------------------
193
+ it('system prompt includes all context sections', async () => {
194
+ const res = await fetch(`http://localhost:${AGENT_PORT}/inspect/context`);
195
+ const body = await res.json();
196
+ const prompt = String(body['system_prompt'] ?? '');
197
+ expect(prompt.length).toBeGreaterThan(500);
198
+ expect(prompt).toContain('mock-api'); // connection
199
+ expect(prompt).toContain('test-skill'); // skill
200
+ expect(prompt).toContain('Smoke Test Reference'); // knowledge
201
+ expect(prompt).toContain('test-items'); // store
202
+ });
203
+ // -------------------------------------------------------------------------
204
+ // 3. Chat streaming
205
+ // -------------------------------------------------------------------------
206
+ it('streams chat with init, text, and done events', async () => {
207
+ const { events } = await chat('Say hello in exactly 3 words.');
208
+ const init = findEvent(events, 'init');
209
+ const done = findEvent(events, 'done');
210
+ const textDeltas = findEvents(events, 'text_delta');
211
+ expect(init).toBeDefined();
212
+ expect(done).toBeDefined();
213
+ expect(textDeltas.length).toBeGreaterThan(0);
214
+ // Done event should have usage
215
+ const usage = done?.['usage'];
216
+ expect(usage?.['input_tokens']).toBeGreaterThan(0);
217
+ expect(usage?.['output_tokens']).toBeGreaterThan(0);
218
+ }, TIMEOUT);
219
+ // -------------------------------------------------------------------------
220
+ // 4. Session resume
221
+ // -------------------------------------------------------------------------
222
+ it('resumes session with prior context', async () => {
223
+ const first = await chat('Remember this code: SMOKE7742. Just confirm you noted it.');
224
+ expect(first.sessionId).toBeTruthy();
225
+ const second = await chat('What was the code I asked you to remember? Reply with just the code.', first.sessionId);
226
+ const responseText = allText(second.events);
227
+ expect(responseText).toContain('SMOKE7742');
228
+ }, TIMEOUT * 2);
229
+ // -------------------------------------------------------------------------
230
+ // 5. Tool call — store
231
+ // -------------------------------------------------------------------------
232
+ it('makes at least one tool call across chat interactions', async () => {
233
+ // Use a prompt that strongly implies tool use — query existing data
234
+ const { events } = await chat('Query the test-items store for all items. Use the query_store tool with store="test-items".');
235
+ // The model should call query_store. If no tool calls at all, the test
236
+ // is still valid — it means the model chose not to call tools, which is
237
+ // an LLM non-determinism issue, not a code bug. We mark it as a soft check.
238
+ const toolResults = findEvents(events, 'tool_call_result');
239
+ if (toolResults.length === 0) {
240
+ // Soft fail — log but don't block CI
241
+ // eslint-disable-next-line no-console -- intentional test diagnostic
242
+ console.warn('[smoke] Model did not call any tools — LLM non-determinism, not a code bug');
243
+ }
244
+ else {
245
+ // If tools were called, verify they have proper status
246
+ for (const result of toolResults) {
247
+ expect(result['status']).toMatch(/^(success|error)$/);
248
+ }
249
+ }
250
+ }, TIMEOUT);
251
+ // -------------------------------------------------------------------------
252
+ // 6. Tool call — connection request
253
+ // -------------------------------------------------------------------------
254
+ it('calls request tool against mock-api', async () => {
255
+ const { events } = await chat('Use the request tool to GET /items from the mock-api connection with intent "read".');
256
+ const toolResults = findEvents(events, 'tool_call_result');
257
+ const success = toolResults.find((e) => e['status'] === 'success');
258
+ expect(success).toBeDefined();
259
+ const responseText = allText(events);
260
+ expect(responseText).toContain('Widget');
261
+ }, TIMEOUT);
262
+ // -------------------------------------------------------------------------
263
+ // 7. Tool error status
264
+ // -------------------------------------------------------------------------
265
+ it('reports tool errors with status error, not success', async () => {
266
+ const { events } = await chat('Use the request tool to call GET /items on a connection called "nonexistent-connection" with intent "read".');
267
+ const toolResults = findEvents(events, 'tool_call_result');
268
+ const errorResult = toolResults.find((e) => e['status'] === 'error');
269
+ expect(errorResult).toBeDefined();
270
+ }, TIMEOUT);
271
+ // -------------------------------------------------------------------------
272
+ // 8. Eval run
273
+ // -------------------------------------------------------------------------
274
+ it('runs eval and returns results', async () => {
275
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/evals/run`, {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ evalNames: ['basic-eval'] }),
279
+ signal: AbortSignal.timeout(60_000),
280
+ });
281
+ const text = await res.text();
282
+ const events = [];
283
+ for (const line of text.split('\n')) {
284
+ if (!line.startsWith('data: '))
285
+ continue;
286
+ try {
287
+ events.push(JSON.parse(line.slice(6)));
288
+ }
289
+ catch { /* skip */ }
290
+ }
291
+ const complete = findEvent(events, 'eval_complete');
292
+ expect(complete).toBeDefined();
293
+ expect(complete?.['passed']).toBe(true);
294
+ }, 60_000);
295
+ // -------------------------------------------------------------------------
296
+ // 9. Admin chat — reads repo files
297
+ // -------------------------------------------------------------------------
298
+ it('admin agent can read skill files', async () => {
299
+ const res = await fetch(`http://localhost:${AGENT_PORT}/config/chat`, {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify({ message: 'Read the test-skill skill file and tell me what it says. Be brief.' }),
303
+ signal: AbortSignal.timeout(TIMEOUT),
304
+ });
305
+ const text = await res.text();
306
+ const events = parseSSE(text);
307
+ const init = findEvent(events, 'init');
308
+ expect(init).toBeDefined();
309
+ // Admin agent should use read_repo_file tool
310
+ const toolStarts = findEvents(events, 'tool_call_start');
311
+ const readTool = toolStarts.find((e) => e['tool_name'] === 'read_repo_file');
312
+ expect(readTool).toBeDefined();
313
+ // The matching result should be a success — validates the full
314
+ // tool_call_start → execute → tool_call_result SSE round-trip.
315
+ const toolResults = findEvents(events, 'tool_call_result');
316
+ const readResult = toolResults.find((e) => e['tool_id'] === readTool?.['tool_id']);
317
+ expect(readResult).toBeDefined();
318
+ expect(readResult?.['status']).toBe('success');
319
+ const responseText = allText(events);
320
+ expect(responseText.toLowerCase()).toContain('test');
321
+ }, TIMEOUT);
322
+ // End-to-end: the "reduce emojis in formatting rules" scenario from the
323
+ // admin-agent regression. Before the discovery + edit tools existed, the
324
+ // agent guessed wrong paths and often created a new skill file instead
325
+ // of editing the existing knowledge doc. With list_repo_files /
326
+ // glob_repo_files / grep_repo_files / edit_repo_file available, it
327
+ // should discover knowledge/formatting-rules.md and edit it in place.
328
+ it('admin agent discovers and edits the right file (emoji-reduction scenario)', async () => {
329
+ const formattingRulesPath = resolve(AGENT_DIR, 'knowledge', 'formatting-rules.md');
330
+ const emojiHeavyBody = [
331
+ '# Formatting Rules 🎨',
332
+ '',
333
+ 'Use emojis liberally to make the output more engaging! 🎉🎉🎉',
334
+ '',
335
+ '## Tone 💬',
336
+ '',
337
+ "Drop a 🚀 when celebrating a win, a 🔥 when highlighting risk, and a ✨ when introducing a new feature. Don't hold back! 🙌",
338
+ '',
339
+ 'Every bullet point should start with an emoji. 📝 Every heading should have one too. 🏷️',
340
+ '',
341
+ '## Examples 📚',
342
+ '- ✅ "Deployment succeeded 🎉"',
343
+ '- ❌ "Deployment failed 💥"',
344
+ '',
345
+ ].join('\n');
346
+ const emojiCount = (s) => (s.match(/\p{Emoji_Presentation}/gu) ?? []).length;
347
+ const initialEmojis = emojiCount(emojiHeavyBody);
348
+ expect(initialEmojis).toBeGreaterThan(5);
349
+ writeFileSync(formattingRulesPath, emojiHeavyBody);
350
+ // Snapshot skills/ so we can assert the agent didn't create a bogus skill.
351
+ const skillsDir = resolve(AGENT_DIR, 'skills');
352
+ const skillsBefore = new Set(readdirSync(skillsDir));
353
+ try {
354
+ const res = await fetch(`http://localhost:${AGENT_PORT}/config/chat`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify({
358
+ message: 'I want to use emojis less often in my formatting rules. Find where they are defined in my repo and reduce the emoji guidance — remove most emoji usage from the instructions, keep the document but make it plain text. Work carefully: first look around to find the right file, then edit it in place. Do not create any new skills.',
359
+ }),
360
+ signal: AbortSignal.timeout(TIMEOUT * 2),
361
+ });
362
+ const text = await res.text();
363
+ const events = parseSSE(text);
364
+ const toolStarts = findEvents(events, 'tool_call_start');
365
+ const toolNames = toolStarts.map((e) => String(e['tool_name']));
366
+ // Discovery: the agent should have used at least one of the new
367
+ // discovery tools to find formatting-rules.md instead of guessing.
368
+ const usedDiscovery = toolNames.some((n) => n === 'list_repo_files' || n === 'glob_repo_files' || n === 'grep_repo_files');
369
+ expect(usedDiscovery).toBe(true);
370
+ // Action: should edit in place, NOT rewrite the whole file or create
371
+ // a new skill. We allow either edit_repo_file (preferred) or
372
+ // write_repo_file targeting the same path (acceptable).
373
+ const editedInPlace = toolNames.includes('edit_repo_file');
374
+ const rewroteFile = toolNames.includes('write_repo_file');
375
+ expect(editedInPlace || rewroteFile).toBe(true);
376
+ // Regression guard: agent must NOT have created a new skill.
377
+ const skillsAfter = new Set(readdirSync(skillsDir));
378
+ const newSkills = [...skillsAfter].filter((s) => !skillsBefore.has(s));
379
+ expect(newSkills).toEqual([]);
380
+ // Outcome: the file should still exist and contain significantly
381
+ // fewer emojis than before.
382
+ const after = readFileSync(formattingRulesPath, 'utf-8');
383
+ expect(after.length).toBeGreaterThan(0);
384
+ const afterEmojis = emojiCount(after);
385
+ expect(afterEmojis).toBeLessThan(initialEmojis);
386
+ }
387
+ finally {
388
+ // Clean up — remove the formatting-rules.md fixture regardless of pass/fail.
389
+ rmSync(formattingRulesPath, { force: true });
390
+ }
391
+ }, TIMEOUT * 2);
392
+ // Pagination end-to-end: drop a 3000-line file with a sentinel on line
393
+ // 2800, ask the admin agent to report what's there verbatim. The default
394
+ // read cap is 2000 lines, so the agent MUST either paginate via offset
395
+ // or use grep. Verifies the new line_start/line_end/total_lines/
396
+ // truncated response shape is actually usable by a real LLM.
397
+ it('admin agent paginates a long file to reach content past the default cap', async () => {
398
+ const bigFilePath = resolve(AGENT_DIR, 'knowledge', 'big-file.md');
399
+ // Sentinel must be distinct enough that the agent can quote it back.
400
+ const SENTINEL = 'TARGET:CONTENT:ABCD1234:the-answer-is-42';
401
+ const TARGET_LINE = 2800;
402
+ const TOTAL_LINES = 3000;
403
+ const body = Array.from({ length: TOTAL_LINES }, (_, i) => {
404
+ const n = i + 1;
405
+ return n === TARGET_LINE ? `line ${String(n)}: ${SENTINEL}` : `line ${String(n)}: filler`;
406
+ }).join('\n');
407
+ writeFileSync(bigFilePath, body);
408
+ try {
409
+ const res = await fetch(`http://localhost:${AGENT_PORT}/config/chat`, {
410
+ method: 'POST',
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: JSON.stringify({
413
+ message: `I just added a long file at knowledge/big-file.md. Tell me exactly what's on line ${String(TARGET_LINE)} — report the full line content verbatim. Just give me the line, no summary.`,
414
+ }),
415
+ signal: AbortSignal.timeout(TIMEOUT * 2),
416
+ });
417
+ const text = await res.text();
418
+ const events = parseSSE(text);
419
+ const toolStarts = findEvents(events, 'tool_call_start');
420
+ const toolNames = toolStarts.map((e) => String(e['tool_name']));
421
+ // The agent needs to touch the file — either read_repo_file or
422
+ // grep_repo_files would work to find the target line.
423
+ const touchedFile = toolNames.some((n) => n === 'read_repo_file' || n === 'grep_repo_files');
424
+ expect(touchedFile).toBe(true);
425
+ // If the agent used read_repo_file, at least one call must have
426
+ // specified an offset/limit that covers line 2800 (the default
427
+ // 2000-line window doesn't reach it, so the agent HAS to adapt).
428
+ const readCalls = toolStarts.filter((e) => e['tool_name'] === 'read_repo_file');
429
+ if (readCalls.length > 0) {
430
+ const usedPagination = readCalls.some((e) => {
431
+ const params = e['parameters'];
432
+ if (!params)
433
+ return false;
434
+ const offset = typeof params['offset'] === 'number' ? params['offset'] : 1;
435
+ const limit = typeof params['limit'] === 'number' ? params['limit'] : 2000;
436
+ // Covers line TARGET_LINE if offset <= TARGET_LINE AND
437
+ // offset + limit - 1 >= TARGET_LINE.
438
+ return offset <= TARGET_LINE && offset + limit - 1 >= TARGET_LINE;
439
+ });
440
+ expect(usedPagination).toBe(true);
441
+ }
442
+ // Hard assertion: the response contains the sentinel verbatim.
443
+ const responseText = allText(events);
444
+ expect(responseText).toContain(SENTINEL);
445
+ }
446
+ finally {
447
+ rmSync(bigFilePath, { force: true });
448
+ }
449
+ }, TIMEOUT * 2);
450
+ // Multi-chunk pagination: sentinels spread across a 5000-line file so no
451
+ // single default read (2000 lines) can cover all of them. Verifies the
452
+ // agent either (a) chains multiple reads following the truncated: true
453
+ // signal, or (b) uses grep. Either is acceptable — what matters is that
454
+ // the agent finds content past the default window.
455
+ it('admin agent finds content scattered across a long file via pagination or grep', async () => {
456
+ const bigFilePath = resolve(AGENT_DIR, 'knowledge', 'scatter.md');
457
+ const MARKER = 'MARKER-ZXCV9876';
458
+ const MARKER_LINES = [500, 2500, 4500];
459
+ const TOTAL_LINES = 5000;
460
+ const body = Array.from({ length: TOTAL_LINES }, (_, i) => {
461
+ const n = i + 1;
462
+ return MARKER_LINES.includes(n) ? `line ${String(n)}: ${MARKER}` : `line ${String(n)}: filler`;
463
+ }).join('\n');
464
+ writeFileSync(bigFilePath, body);
465
+ try {
466
+ const res = await fetch(`http://localhost:${AGENT_PORT}/config/chat`, {
467
+ method: 'POST',
468
+ headers: { 'Content-Type': 'application/json' },
469
+ body: JSON.stringify({
470
+ message: `Read knowledge/scatter.md and quote the exact content of line 500, line 2500, and line 4500 verbatim. Report each line's full text.`,
471
+ }),
472
+ signal: AbortSignal.timeout(TIMEOUT * 2),
473
+ });
474
+ const text = await res.text();
475
+ const events = parseSSE(text);
476
+ const toolStarts = findEvents(events, 'tool_call_start');
477
+ const toolNames = toolStarts.map((e) => String(e['tool_name']));
478
+ // Agent must have touched the file.
479
+ const touchedFile = toolNames.some((n) => n === 'read_repo_file' || n === 'grep_repo_files');
480
+ expect(touchedFile).toBe(true);
481
+ // If the agent committed to read-only discovery (no grep), verify at
482
+ // least one read_repo_file call reached past the default 2000-line
483
+ // cap — otherwise it couldn't have seen markers at lines 2500 or
484
+ // 4500. When grep is used first, pagination isn't required because
485
+ // the agent may have used read_repo_file only to confirm a line it
486
+ // already found via grep.
487
+ const usedGrep = toolNames.includes('grep_repo_files');
488
+ const readCalls = toolStarts.filter((e) => e['tool_name'] === 'read_repo_file');
489
+ if (readCalls.length > 0 && !usedGrep) {
490
+ const reachedPastCap = readCalls.some((e) => {
491
+ const params = e['parameters'];
492
+ if (!params)
493
+ return false;
494
+ const offset = typeof params['offset'] === 'number' ? params['offset'] : 1;
495
+ const limit = typeof params['limit'] === 'number' ? params['limit'] : 2000;
496
+ // A single read covers up to line_end = offset + limit - 1.
497
+ return offset + limit - 1 > 2000;
498
+ });
499
+ expect(reachedPastCap).toBe(true);
500
+ }
501
+ // Hard assertion: final response identifies all three marker line
502
+ // numbers. LLMs paraphrase, so search the response for each number.
503
+ const responseText = allText(events);
504
+ for (const n of MARKER_LINES) {
505
+ expect(responseText).toContain(String(n));
506
+ }
507
+ }
508
+ finally {
509
+ rmSync(bigFilePath, { force: true });
510
+ }
511
+ }, TIMEOUT * 2);
512
+ // -------------------------------------------------------------------------
513
+ // 10. Write intent enforcement (G8)
514
+ // -------------------------------------------------------------------------
515
+ it('rejects POST with intent "read"', async () => {
516
+ const { events } = await chat('Use the request tool to call POST /items on mock-api with intent "read" and data {"name": "test"}. Do not use "write" intent — use exactly "read".');
517
+ const toolResults = findEvents(events, 'tool_call_result');
518
+ // Should get an error result about intent mismatch
519
+ const hasError = toolResults.some((e) => e['status'] === 'error');
520
+ const responseText = allText(events);
521
+ const mentionsIntent = responseText.toLowerCase().includes('intent') || responseText.toLowerCase().includes('write');
522
+ // Either the tool returned an error about intent, or the model explained the rejection
523
+ expect(hasError || mentionsIntent).toBe(true);
524
+ }, TIMEOUT);
525
+ // -------------------------------------------------------------------------
526
+ // 11. Store write + query persistence
527
+ // -------------------------------------------------------------------------
528
+ it('persists data across store write and query', async () => {
529
+ // Write
530
+ const writeResult = await chat('Store a test item: use store_test_items with item_id="persist-check", name="Persistence Test", status="active". Call the tool now.');
531
+ const writeToolResults = findEvents(writeResult.events, 'tool_call_result');
532
+ const writeSuccess = writeToolResults.find((e) => e['status'] === 'success');
533
+ if (!writeSuccess) {
534
+ // Model didn't call the tool — skip gracefully
535
+ return;
536
+ }
537
+ // Query back in a NEW session (proves persistence, not just in-memory)
538
+ const queryResult = await chat('You have a tool called query_store. Use it now with store="test-items" and filter={"item_id": "persist-check"}. Then tell me the name field of the result.');
539
+ const queryToolResults = findEvents(queryResult.events, 'tool_call_result');
540
+ if (queryToolResults.length === 0) {
541
+ // Model didn't call query_store despite explicit instruction — LLM non-determinism
542
+ // eslint-disable-next-line no-console -- intentional test diagnostic
543
+ console.warn('[smoke] Model did not call query_store in persistence test — LLM non-determinism');
544
+ return;
545
+ }
546
+ const responseText = allText(queryResult.events);
547
+ expect(responseText).toContain('Persistence Test');
548
+ }, TIMEOUT * 2);
549
+ // -------------------------------------------------------------------------
550
+ // 11b. Store batch write
551
+ // -------------------------------------------------------------------------
552
+ it('batch writes multiple items to store', async () => {
553
+ const { events } = await chat('Write two items to the test-items store using the batch tool: item_id="batch-1", name="First Batch", status="active" and item_id="batch-2", name="Second Batch", status="archived".');
554
+ const toolStarts = findEvents(events, 'tool_call_start');
555
+ const batchTool = toolStarts.find((e) => String(e['tool_name'] ?? '').includes('batch'));
556
+ if (!batchTool) {
557
+ // Model didn't use batch — might have used individual writes, that's OK
558
+ return;
559
+ }
560
+ const toolResults = findEvents(events, 'tool_call_result');
561
+ const batchResult = toolResults.find((e) => e['tool_id'] === batchTool['tool_id']);
562
+ expect(batchResult).toBeDefined();
563
+ expect(batchResult?.['status']).toBe('success');
564
+ }, TIMEOUT);
565
+ // -------------------------------------------------------------------------
566
+ // 11c. Store single document fetch by key
567
+ // -------------------------------------------------------------------------
568
+ it('fetches a single document by key from store', async () => {
569
+ // First write a known item and verify the write succeeded
570
+ const writeResult = await chat('Write to the test-items store: item_id="key-lookup-test", name="Key Lookup Item", status="active".');
571
+ const writeSuccess = findEvents(writeResult.events, 'tool_call_result').find((e) => e['status'] === 'success');
572
+ if (!writeSuccess)
573
+ return; // Write didn't happen — skip
574
+ // Fetch by key in a new session
575
+ const { events } = await chat('Use query_store with store="test-items" and key="key-lookup-test". What is the name field?');
576
+ const toolResults = findEvents(events, 'tool_call_result');
577
+ if (toolResults.length === 0) {
578
+ // eslint-disable-next-line no-console -- intentional test diagnostic
579
+ console.warn('[smoke] Model did not call query_store for key lookup — LLM non-determinism');
580
+ return;
581
+ }
582
+ const responseText = allText(events);
583
+ expect(responseText).toContain('Key Lookup');
584
+ }, TIMEOUT * 2);
585
+ // -------------------------------------------------------------------------
586
+ // 11d. Store filtered query (multiple results)
587
+ // -------------------------------------------------------------------------
588
+ it('queries store with filter and returns multiple results', async () => {
589
+ // Write two items with a unique status we can filter on
590
+ const w1 = await chat('Write to test-items store: item_id="filter-a", name="Filter Alpha", status="archived".');
591
+ const w2 = await chat('Write to test-items store: item_id="filter-b", name="Filter Beta", status="archived".');
592
+ const w1ok = findEvents(w1.events, 'tool_call_result').some((e) => e['status'] === 'success');
593
+ const w2ok = findEvents(w2.events, 'tool_call_result').some((e) => e['status'] === 'success');
594
+ if (!w1ok || !w2ok)
595
+ return; // Writes didn't happen — skip
596
+ // Query with filter in a new session
597
+ const { events } = await chat('Use query_store with store="test-items" and filter={"status": "archived"}. List the names of all results.');
598
+ const toolResults = findEvents(events, 'tool_call_result');
599
+ if (toolResults.length === 0) {
600
+ // eslint-disable-next-line no-console -- intentional test diagnostic
601
+ console.warn('[smoke] Model did not call query_store for filtered query — LLM non-determinism');
602
+ return;
603
+ }
604
+ const responseText = allText(events);
605
+ const mentionsAny = responseText.includes('Filter Alpha') || responseText.includes('Filter Beta');
606
+ expect(mentionsAny).toBe(true);
607
+ }, TIMEOUT * 3);
608
+ // -------------------------------------------------------------------------
609
+ // 11e. Parallel tool calls — batched read-only execution
610
+ // -------------------------------------------------------------------------
611
+ it('batches parallel read-only tool calls in a single turn', async () => {
612
+ // Seed three distinct items with unique names so we can verify the
613
+ // model saw each result.
614
+ const seed = [
615
+ ['parallel-alpha', 'Alpha Marker'],
616
+ ['parallel-beta', 'Beta Marker'],
617
+ ['parallel-gamma', 'Gamma Marker'],
618
+ ];
619
+ const writes = await Promise.all(seed.map(([id, name]) => chat(`Write to test-items store: item_id="${id}", name="${name}", status="active".`)));
620
+ const allWritesOk = writes.every((w) => findEvents(w.events, 'tool_call_result').some((e) => e['status'] === 'success'));
621
+ if (!allWritesOk)
622
+ return; // seeding failed — skip
623
+ // Ask the model to fetch all three in parallel. Models sometimes split
624
+ // this across turns; when they emit a single-turn parallel batch we
625
+ // verify the runtime handled it correctly end-to-end.
626
+ const { events } = await chat('Fetch all three of these items from the test-items store in parallel ' +
627
+ 'using three concurrent query_store tool calls (one per key): ' +
628
+ '"parallel-alpha", "parallel-beta", "parallel-gamma". Then list the name ' +
629
+ 'field of each item in your response.');
630
+ const toolStarts = findEvents(events, 'tool_call_start');
631
+ const toolResults = findEvents(events, 'tool_call_result');
632
+ const queryStoreStarts = toolStarts.filter((e) => e['tool_name'] === 'query_store');
633
+ if (queryStoreStarts.length < 2) {
634
+ // eslint-disable-next-line no-console -- intentional test diagnostic
635
+ console.warn(`[smoke] Model emitted ${String(queryStoreStarts.length)} query_store call(s) — ` +
636
+ 'parallel-batch path not exercised this run (LLM non-determinism)');
637
+ return;
638
+ }
639
+ // Every start must have a matching successful result — batching must
640
+ // not drop events or corrupt SSE ordering. (This is the assertion that
641
+ // actually exercises the batching code path; content coverage of the
642
+ // response is LLM-variable and not the batcher's job.)
643
+ const successResults = toolResults.filter((e) => e['status'] === 'success');
644
+ expect(successResults.length).toBeGreaterThanOrEqual(queryStoreStarts.length);
645
+ }, TIMEOUT * 3);
646
+ // -------------------------------------------------------------------------
647
+ // 12. Concurrent sessions don't bleed context
648
+ // -------------------------------------------------------------------------
649
+ it('concurrent sessions are isolated', async () => {
650
+ // Session A: tell it a secret
651
+ const sessionA = await chat('My secret code for this session is ALPHA9999. Just confirm.');
652
+ // Session B: different secret (we don't need session B's ID)
653
+ await chat('My secret code for this session is BETA5555. Just confirm.');
654
+ // Ask session A about B's secret — should NOT know it
655
+ const checkA = await chat('What is the BETA code?', sessionA.sessionId);
656
+ const textA = allText(checkA.events);
657
+ // Session A should not contain session B's secret
658
+ expect(textA).not.toContain('BETA5555');
659
+ }, TIMEOUT * 3);
660
+ // -------------------------------------------------------------------------
661
+ // 13. Automation trigger
662
+ // -------------------------------------------------------------------------
663
+ it('triggers automation via API', async () => {
664
+ const res = await fetch(`http://localhost:${AGENT_PORT}/automations`, { signal: AbortSignal.timeout(5000) });
665
+ const body = await res.json();
666
+ // Smoke agent doesn't have automations defined in amodal.json,
667
+ // but the endpoint should still respond
668
+ expect(res.status).toBe(200);
669
+ expect(body.automations).toBeDefined();
670
+ });
671
+ // -------------------------------------------------------------------------
672
+ // 14. Multi-turn tool loop
673
+ // -------------------------------------------------------------------------
674
+ it('handles multi-turn tool interaction', async () => {
675
+ // Ask something that requires a tool call then reasoning about the result
676
+ const { events } = await chat('Fetch items from mock-api using the request tool (GET /items, intent "read"), then tell me how many items have status "active".');
677
+ // Should have tool call AND text response with the count
678
+ const toolResults = findEvents(events, 'tool_call_result');
679
+ const responseText = allText(events);
680
+ expect(toolResults.length).toBeGreaterThan(0);
681
+ // Mock returns 2 active items (Widget, Doohickey) out of 3
682
+ expect(responseText).toMatch(/2|two/i);
683
+ }, TIMEOUT);
684
+ // -------------------------------------------------------------------------
685
+ // 15. Evals list endpoint
686
+ // -------------------------------------------------------------------------
687
+ it('lists eval suites from repo', async () => {
688
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/evals/suites`, { signal: AbortSignal.timeout(5000) });
689
+ const body = await res.json();
690
+ expect(res.status).toBe(200);
691
+ expect(body.suites.length).toBeGreaterThan(0);
692
+ expect(body.suites[0]?.['name']).toBe('basic-eval');
693
+ });
694
+ // -------------------------------------------------------------------------
695
+ // 16. Inspect endpoint — connection health
696
+ // -------------------------------------------------------------------------
697
+ it('inspect shows connection status', async () => {
698
+ const res = await fetch(`http://localhost:${AGENT_PORT}/inspect/context`, { signal: AbortSignal.timeout(10000) });
699
+ const body = await res.json();
700
+ expect(res.status).toBe(200);
701
+ expect(body['connections']).toBeDefined();
702
+ });
703
+ // -------------------------------------------------------------------------
704
+ // 17. MCP tool call
705
+ // -------------------------------------------------------------------------
706
+ it('calls MCP tool and gets result', async () => {
707
+ const { events } = await chat('Use the mock-mcp__smoke_search tool to search for "test". Call the tool now.');
708
+ const toolStarts = findEvents(events, 'tool_call_start');
709
+ const mcpTool = toolStarts.find((e) => String(e['tool_name'] ?? '').includes('smoke_search'));
710
+ if (!mcpTool) {
711
+ // Check if MCP tools are even available — model might not know about them
712
+ const responseText = allText(events);
713
+ // If the model says it doesn't have that tool, MCP isn't wired
714
+ if (responseText.toLowerCase().includes('not available') || responseText.toLowerCase().includes('don\'t have')) {
715
+ throw new Error('MCP tools not registered — mock-mcp__smoke_search not available to the model');
716
+ }
717
+ // Model just chose not to call it — LLM non-determinism
718
+ return;
719
+ }
720
+ const toolResults = findEvents(events, 'tool_call_result');
721
+ const mcpResult = toolResults.find((e) => e['tool_id'] === mcpTool['tool_id']);
722
+ expect(mcpResult).toBeDefined();
723
+ expect(mcpResult?.['status']).toBe('success');
724
+ }, TIMEOUT);
725
+ // -------------------------------------------------------------------------
726
+ // 18. Custom tool (echo_tool) with ctx.request() + ctx.store()
727
+ // -------------------------------------------------------------------------
728
+ it('custom tool calls ctx.request and ctx.store', async () => {
729
+ const { events } = await chat('Use the echo_tool with message "smoke-test-ping". Call the tool now.');
730
+ const toolStarts = findEvents(events, 'tool_call_start');
731
+ const echoTool = toolStarts.find((e) => e['tool_name'] === 'echo_tool');
732
+ if (!echoTool) {
733
+ const responseText = allText(events);
734
+ if (responseText.toLowerCase().includes('not available') || responseText.toLowerCase().includes('don\'t have')) {
735
+ throw new Error('echo_tool not registered');
736
+ }
737
+ return; // LLM non-determinism
738
+ }
739
+ const toolResults = findEvents(events, 'tool_call_result');
740
+ const echoResult = toolResults.find((e) => e['tool_id'] === echoTool['tool_id']);
741
+ expect(echoResult).toBeDefined();
742
+ expect(echoResult?.['status']).toBe('success');
743
+ }, TIMEOUT);
744
+ // -------------------------------------------------------------------------
745
+ // 19. Stop execution tool terminates loop
746
+ // -------------------------------------------------------------------------
747
+ it('stop_execution tool is available', async () => {
748
+ // We can't easily force the model to call stop_execution, but we can
749
+ // verify it's in the tool list by asking the model
750
+ const { events } = await chat('Do you have a tool called stop_execution? Answer yes or no, nothing else.');
751
+ const responseText = allText(events).toLowerCase();
752
+ expect(responseText).toContain('yes');
753
+ }, TIMEOUT);
754
+ // -------------------------------------------------------------------------
755
+ // 20. Done event always has usage (G2)
756
+ // -------------------------------------------------------------------------
757
+ it('done event always includes token usage', async () => {
758
+ const { events } = await chat('Reply with exactly the word "pong".');
759
+ const done = findEvent(events, 'done');
760
+ expect(done).toBeDefined();
761
+ const usage = done?.['usage'];
762
+ expect(usage).toBeDefined();
763
+ expect(typeof usage?.['input_tokens']).toBe('number');
764
+ expect(typeof usage?.['output_tokens']).toBe('number');
765
+ expect(usage?.['input_tokens']).toBeGreaterThan(0);
766
+ expect(usage?.['output_tokens']).toBeGreaterThan(0);
767
+ }, TIMEOUT);
768
+ // -------------------------------------------------------------------------
769
+ // 21. Sub-agent dispatch
770
+ // -------------------------------------------------------------------------
771
+ it('dispatch_task spawns child agent and returns result', async () => {
772
+ const { events } = await chat('Use the dispatch_task tool to delegate a sub-task. Set agent_name to "data-fetcher", tools to ["request"], and prompt to "Fetch GET /items from mock-api with intent read and summarize what you find." Call dispatch_task now.');
773
+ // Look for subagent events (child activity)
774
+ const subagentEvents = findEvents(events, 'subagent_event');
775
+ // Look for the dispatch_task tool call result
776
+ const toolStarts = findEvents(events, 'tool_call_start');
777
+ const dispatchStart = toolStarts.find((e) => e['tool_name'] === 'dispatch_task');
778
+ if (!dispatchStart) {
779
+ // Model didn't call dispatch_task — LLM non-determinism
780
+ const responseText = allText(events);
781
+ if (responseText.toLowerCase().includes('not available') || responseText.toLowerCase().includes('don\'t have')) {
782
+ throw new Error('dispatch_task tool not registered');
783
+ }
784
+ return;
785
+ }
786
+ // dispatch_task was called — verify it completed
787
+ const toolResults = findEvents(events, 'tool_call_result');
788
+ const dispatchResult = toolResults.find((e) => e['tool_id'] === dispatchStart['tool_id']);
789
+ expect(dispatchResult).toBeDefined();
790
+ expect(dispatchResult?.['status']).toBe('success');
791
+ // SubagentEvents should have been emitted during child execution
792
+ if (subagentEvents.length > 0) {
793
+ // All should reference the same parent_tool_id
794
+ for (const event of subagentEvents) {
795
+ expect(event['parent_tool_id']).toBe(dispatchStart['tool_id']);
796
+ expect(event['agent_name']).toBe('data-fetcher');
797
+ }
798
+ }
799
+ // Parent should have incorporated the child's result into its response
800
+ const responseText = allText(events);
801
+ expect(responseText.length).toBeGreaterThan(0);
802
+ }, TIMEOUT * 2);
803
+ it('dispatch_task tool is available to the model', async () => {
804
+ const { events } = await chat('Do you have a tool called dispatch_task? Answer yes or no, nothing else.');
805
+ const responseText = allText(events).toLowerCase();
806
+ expect(responseText).toContain('yes');
807
+ }, TIMEOUT);
808
+ // -------------------------------------------------------------------------
809
+ // 22. Pages — user-defined React pages
810
+ // -------------------------------------------------------------------------
811
+ it('lists pages with metadata from repo', async () => {
812
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/pages`, { signal: AbortSignal.timeout(5000) });
813
+ const body = await res.json();
814
+ expect(res.status).toBe(200);
815
+ expect(body.pages.length).toBeGreaterThan(0);
816
+ const testPage = body.pages.find((p) => p['name'] === 'TestPage');
817
+ expect(testPage).toBeDefined();
818
+ expect(testPage?.['description']).toBe('Smoke test page fixture');
819
+ expect(testPage?.['stores']).toEqual(['test-items']);
820
+ });
821
+ it('serves compiled page bundle', async () => {
822
+ const res = await fetch(`http://localhost:${AGENT_PORT}/pages-bundle/TestPage.js`, { signal: AbortSignal.timeout(5000) });
823
+ expect(res.status).toBe(200);
824
+ const bundle = await res.text();
825
+ // IIFE bundle registers on window.__AMODAL_PAGES__
826
+ expect(bundle).toContain('__AMODAL_PAGES__');
827
+ expect(bundle).toContain('TestPage');
828
+ });
829
+ // -------------------------------------------------------------------------
830
+ // 23. Sessions — listing and history
831
+ // -------------------------------------------------------------------------
832
+ it('sessions endpoint returns a sessions array', async () => {
833
+ const res = await fetch(`http://localhost:${AGENT_PORT}/sessions`, { signal: AbortSignal.timeout(5000) });
834
+ const body = await res.json();
835
+ expect(res.status).toBe(200);
836
+ expect(Array.isArray(body.sessions)).toBe(true);
837
+ });
838
+ it('returns 404 for unknown session', async () => {
839
+ const res = await fetch(`http://localhost:${AGENT_PORT}/session/nonexistent-id`, { signal: AbortSignal.timeout(5000) });
840
+ expect(res.status).toBe(404);
841
+ });
842
+ it('persists chat session through full list/get/patch/delete lifecycle', async () => {
843
+ // Full dev-UI session history loop, all served from DrizzleSessionStore.
844
+ const { sessionId } = await chat('Say "ok" in one word.');
845
+ expect(sessionId).toBeTruthy();
846
+ // 1. Session appears in /sessions with the UI response shape
847
+ const listRes = await fetch(`http://localhost:${AGENT_PORT}/sessions`, { signal: AbortSignal.timeout(5000) });
848
+ const listBody = await listRes.json();
849
+ expect(listRes.status).toBe(200);
850
+ const found = listBody.sessions.find((s) => s['id'] === sessionId);
851
+ expect(found).toBeDefined();
852
+ if (!found)
853
+ throw new Error('unreachable');
854
+ expect(found['appId']).toBe('local');
855
+ expect(typeof found['summary']).toBe('string');
856
+ expect(String(found['summary']).length).toBeGreaterThan(0);
857
+ expect(typeof found['createdAt']).toBe('number');
858
+ expect(typeof found['lastAccessedAt']).toBe('number');
859
+ expect(found['automationName']).toBeUndefined();
860
+ // 2. /session/:id returns the conversation history
861
+ const getRes = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, { signal: AbortSignal.timeout(5000) });
862
+ const getBody = await getRes.json();
863
+ expect(getRes.status).toBe(200);
864
+ expect(getBody.session_id).toBe(sessionId);
865
+ expect(getBody.messages.length).toBeGreaterThan(0);
866
+ expect(getBody.messages[0].role).toBe('user');
867
+ expect(getBody.messages[0].text).toContain('Say "ok"');
868
+ // 3. PATCH title updates metadata and is visible on subsequent list
869
+ const patchRes = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, {
870
+ method: 'PATCH',
871
+ headers: { 'Content-Type': 'application/json' },
872
+ body: JSON.stringify({ title: 'smoke renamed' }),
873
+ signal: AbortSignal.timeout(5000),
874
+ });
875
+ expect(patchRes.status).toBe(200);
876
+ const list2Res = await fetch(`http://localhost:${AGENT_PORT}/sessions`, { signal: AbortSignal.timeout(5000) });
877
+ const list2Body = await list2Res.json();
878
+ const renamed = list2Body.sessions.find((s) => s['id'] === sessionId);
879
+ expect(renamed?.['title']).toBe('smoke renamed');
880
+ expect(renamed?.['summary']).toBe('smoke renamed');
881
+ // 4. DELETE removes the session, subsequent GET 404s
882
+ const delRes = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, {
883
+ method: 'DELETE',
884
+ signal: AbortSignal.timeout(5000),
885
+ });
886
+ expect(delRes.status).toBe(200);
887
+ const getAfterDelRes = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, { signal: AbortSignal.timeout(5000) });
888
+ expect(getAfterDelRes.status).toBe(404);
889
+ }, TIMEOUT);
890
+ it('preserves tool-call chips in /session/:id history', async () => {
891
+ // Tool calls appear as {type: 'tool-call'} parts in the assistant's
892
+ // ModelMessage.content — flattenModelMessage should surface them to
893
+ // the UI as toolCalls[]. Without this, the dev-UI chat history panel
894
+ // renders the assistant's reply but drops the tool-call chips.
895
+ const { sessionId } = await chat('Use the request tool to GET /items from the mock-api connection with intent "read".');
896
+ expect(sessionId).toBeTruthy();
897
+ const getRes = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, { signal: AbortSignal.timeout(5000) });
898
+ const getBody = await getRes.json();
899
+ expect(getRes.status).toBe(200);
900
+ const assistantWithTools = getBody.messages.find((m) => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0);
901
+ // Soft assertion: the model may choose not to call tools on any given
902
+ // turn (LLM non-determinism). When it does, the toolCall round-trip
903
+ // must work end-to-end.
904
+ if (assistantWithTools?.toolCalls) {
905
+ const call = assistantWithTools.toolCalls[0];
906
+ expect(call.toolId).toBeTruthy();
907
+ expect(call.toolName).toBeTruthy();
908
+ expect(typeof call.parameters).toBe('object');
909
+ }
910
+ else {
911
+ // eslint-disable-next-line no-console -- intentional test diagnostic
912
+ console.warn('[smoke] Model did not call a tool for the request prompt — LLM non-determinism, skipping toolCall round-trip assertion');
913
+ }
914
+ }, TIMEOUT);
915
+ // -------------------------------------------------------------------------
916
+ // 24. Files — browser and editor
917
+ // -------------------------------------------------------------------------
918
+ it('lists repo files as a tree', async () => {
919
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/files`, { signal: AbortSignal.timeout(5000) });
920
+ const body = await res.json();
921
+ expect(res.status).toBe(200);
922
+ expect(body.tree.length).toBeGreaterThan(0);
923
+ // Should include at least one convention directory
924
+ const names = body.tree.map((n) => String(n['name']));
925
+ expect(names.some((n) => ['skills', 'connections', 'stores', 'tools'].includes(n))).toBe(true);
926
+ });
927
+ it('reads a specific file', async () => {
928
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/files/amodal.json`, { signal: AbortSignal.timeout(5000) });
929
+ expect(res.status).toBe(200);
930
+ const body = await res.json();
931
+ expect(body.path).toBe('amodal.json');
932
+ expect(body.language).toBe('json');
933
+ expect(body.content).toContain('smoke-test-agent');
934
+ });
935
+ it('writes a file and reads it back', async () => {
936
+ const testPath = 'knowledge/smoke-write-test.md';
937
+ const testContent = '# Smoke Write Test\n\nThis file was written by a smoke test.';
938
+ const writeRes = await fetch(`http://localhost:${AGENT_PORT}/api/files/${testPath}`, {
939
+ method: 'PUT',
940
+ headers: { 'Content-Type': 'application/json' },
941
+ body: JSON.stringify({ content: testContent }),
942
+ signal: AbortSignal.timeout(5000),
943
+ });
944
+ expect(writeRes.status).toBe(200);
945
+ const readRes = await fetch(`http://localhost:${AGENT_PORT}/api/files/${testPath}`, { signal: AbortSignal.timeout(5000) });
946
+ expect(readRes.status).toBe(200);
947
+ const body = await readRes.json();
948
+ expect(body.content).toBe(testContent);
949
+ });
950
+ it('rejects path traversal attempts', async () => {
951
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/files/..%2F..%2F..%2Fetc%2Fpasswd`, { signal: AbortSignal.timeout(5000) });
952
+ expect([400, 403, 404]).toContain(res.status);
953
+ });
954
+ // -------------------------------------------------------------------------
955
+ // 25. Webhooks — inbound automation trigger
956
+ // -------------------------------------------------------------------------
957
+ it('rejects webhook for unknown automation with 404', async () => {
958
+ const res = await fetch(`http://localhost:${AGENT_PORT}/webhooks/nonexistent-automation`, {
959
+ method: 'POST',
960
+ headers: { 'Content-Type': 'application/json' },
961
+ body: JSON.stringify({ event: 'test' }),
962
+ signal: AbortSignal.timeout(5000),
963
+ });
964
+ expect(res.status).toBe(404);
965
+ const body = await res.json();
966
+ expect(body.error).toContain('not found');
967
+ });
968
+ // -------------------------------------------------------------------------
969
+ // 26. Store REST API — CRUD outside chat
970
+ // -------------------------------------------------------------------------
971
+ it('lists stores with document counts', async () => {
972
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/stores`, { signal: AbortSignal.timeout(5000) });
973
+ expect(res.status).toBe(200);
974
+ const body = await res.json();
975
+ expect(body.stores.length).toBeGreaterThan(0);
976
+ const testItems = body.stores.find((s) => s['name'] === 'test-items');
977
+ expect(testItems).toBeDefined();
978
+ expect(typeof testItems?.['documentCount']).toBe('number');
979
+ });
980
+ it('writes and retrieves a document via REST', async () => {
981
+ const writeRes = await fetch(`http://localhost:${AGENT_PORT}/api/stores/test-items`, {
982
+ method: 'POST',
983
+ headers: { 'Content-Type': 'application/json' },
984
+ body: JSON.stringify({ item_id: 'rest-api-test', name: 'REST API Item', status: 'active' }),
985
+ signal: AbortSignal.timeout(5000),
986
+ });
987
+ expect(writeRes.status).toBe(201);
988
+ const writeBody = await writeRes.json();
989
+ expect(writeBody.key).toBe('rest-api-test');
990
+ const readRes = await fetch(`http://localhost:${AGENT_PORT}/api/stores/test-items/rest-api-test`, { signal: AbortSignal.timeout(5000) });
991
+ expect(readRes.status).toBe(200);
992
+ const readBody = await readRes.json();
993
+ expect(readBody.document.payload['name']).toBe('REST API Item');
994
+ });
995
+ it('lists documents in a store', async () => {
996
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/stores/test-items?limit=10`, { signal: AbortSignal.timeout(5000) });
997
+ expect(res.status).toBe(200);
998
+ const body = await res.json();
999
+ expect(Array.isArray(body.documents)).toBe(true);
1000
+ expect(typeof body.total).toBe('number');
1001
+ });
1002
+ it('returns 404 for unknown store', async () => {
1003
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/stores/nonexistent-store`, { signal: AbortSignal.timeout(5000) });
1004
+ expect(res.status).toBe(404);
1005
+ });
1006
+ // -------------------------------------------------------------------------
1007
+ // 27. Feedback
1008
+ // -------------------------------------------------------------------------
1009
+ it('saves feedback rating', async () => {
1010
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/feedback`, {
1011
+ method: 'POST',
1012
+ headers: { 'Content-Type': 'application/json' },
1013
+ body: JSON.stringify({
1014
+ sessionId: 'smoke-session',
1015
+ messageId: 'smoke-msg-1',
1016
+ rating: 'up',
1017
+ query: 'Test query',
1018
+ response: 'Test response',
1019
+ }),
1020
+ signal: AbortSignal.timeout(5000),
1021
+ });
1022
+ expect(res.status).toBe(200);
1023
+ const body = await res.json();
1024
+ expect(body.ok).toBe(true);
1025
+ expect(body.id).toBeTruthy();
1026
+ });
1027
+ it('returns feedback summary stats', async () => {
1028
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/feedback/summary`, { signal: AbortSignal.timeout(5000) });
1029
+ expect(res.status).toBe(200);
1030
+ const body = await res.json();
1031
+ expect(typeof body.total).toBe('number');
1032
+ expect(typeof body.thumbsUp).toBe('number');
1033
+ expect(typeof body.thumbsDown).toBe('number');
1034
+ });
1035
+ it('rejects invalid feedback rating', async () => {
1036
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/feedback`, {
1037
+ method: 'POST',
1038
+ headers: { 'Content-Type': 'application/json' },
1039
+ body: JSON.stringify({ sessionId: 'x', messageId: 'y', rating: 'invalid' }),
1040
+ signal: AbortSignal.timeout(5000),
1041
+ });
1042
+ expect(res.status).toBe(400);
1043
+ });
1044
+ // -------------------------------------------------------------------------
1045
+ // Agent loop safety features (budget, done reason)
1046
+ // -------------------------------------------------------------------------
1047
+ it('done event carries reason=model_stop on normal completion', async () => {
1048
+ const { events } = await chat('Reply with just the word "ok".');
1049
+ expectDoneReason(events, 'model_stop');
1050
+ });
1051
+ it('max_session_tokens budget terminates the loop with reason=budget_exceeded', async () => {
1052
+ // 200 tokens is well below what any single-turn + tool-call response
1053
+ // will consume, so the budget check fires after the first turn.
1054
+ const { events } = await chat('Echo these strings one at a time, calling echo_tool for each: alpha, bravo, charlie, delta, echo, foxtrot.', undefined, { maxSessionTokens: 200 });
1055
+ expectDoneReason(events, 'budget_exceeded');
1056
+ expectTotalTokens(events, { atLeast: 200 });
1057
+ });
1058
+ // -------------------------------------------------------------------------
1059
+ // 25. Runtime event bus (/api/events SSE stream)
1060
+ // -------------------------------------------------------------------------
1061
+ it('emits session_created when a new chat session is created', async () => {
1062
+ const stream = await openEventStream();
1063
+ try {
1064
+ const chatResult = await chat('Say "hi" and nothing else.');
1065
+ const event = await stream.waitFor((e) => e['type'] === 'session_created' && e['sessionId'] === chatResult.sessionId, TIMEOUT);
1066
+ expect(event['type']).toBe('session_created');
1067
+ expect(event['sessionId']).toBe(chatResult.sessionId);
1068
+ expect(event['seq']).toBeGreaterThan(0);
1069
+ expect(typeof event['timestamp']).toBe('string');
1070
+ }
1071
+ finally {
1072
+ stream.close();
1073
+ }
1074
+ }, TIMEOUT);
1075
+ it('emits session_updated on follow-up messages in an existing session', async () => {
1076
+ const first = await chat('Remember the number 7.');
1077
+ const stream = await openEventStream();
1078
+ try {
1079
+ await chat('Reply with just "ok".', first.sessionId);
1080
+ const event = await stream.waitFor((e) => e['type'] === 'session_updated' && e['sessionId'] === first.sessionId, TIMEOUT);
1081
+ expect(event['type']).toBe('session_updated');
1082
+ expect(event['sessionId']).toBe(first.sessionId);
1083
+ }
1084
+ finally {
1085
+ stream.close();
1086
+ }
1087
+ }, TIMEOUT * 2);
1088
+ it('emits session_deleted when a session is DELETEd', async () => {
1089
+ const { sessionId } = await chat('Say "ok".');
1090
+ const stream = await openEventStream();
1091
+ try {
1092
+ const res = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, {
1093
+ method: 'DELETE',
1094
+ signal: AbortSignal.timeout(5000),
1095
+ });
1096
+ expect(res.status).toBe(200);
1097
+ const event = await stream.waitFor((e) => e['type'] === 'session_deleted' && e['sessionId'] === sessionId, 5000);
1098
+ expect(event['type']).toBe('session_deleted');
1099
+ expect(event['sessionId']).toBe(sessionId);
1100
+ }
1101
+ finally {
1102
+ stream.close();
1103
+ }
1104
+ }, TIMEOUT);
1105
+ it('emits automation_triggered and automation_completed on manual run', async () => {
1106
+ // The automation's registered name is derived from the filename
1107
+ // (automations/test-auto.md → "test-auto"), not the frontmatter.
1108
+ const automationName = 'test-auto';
1109
+ const stream = await openEventStream();
1110
+ try {
1111
+ const runPromise = fetch(`http://localhost:${AGENT_PORT}/automations/${automationName}/run`, {
1112
+ method: 'POST',
1113
+ headers: { 'Content-Type': 'application/json' },
1114
+ body: '{}',
1115
+ signal: AbortSignal.timeout(TIMEOUT),
1116
+ });
1117
+ const triggered = await stream.waitFor((e) => e['type'] === 'automation_triggered' && e['name'] === automationName, 5000);
1118
+ expect(triggered['source']).toBeDefined();
1119
+ const completed = await stream.waitFor((e) => (e['type'] === 'automation_completed' || e['type'] === 'automation_failed') &&
1120
+ e['name'] === automationName, TIMEOUT);
1121
+ expect(completed['type']).toBe('automation_completed');
1122
+ expect(typeof completed['durationMs']).toBe('number');
1123
+ const runRes = await runPromise;
1124
+ expect([200, 500]).toContain(runRes.status);
1125
+ }
1126
+ finally {
1127
+ stream.close();
1128
+ }
1129
+ }, TIMEOUT + 10_000);
1130
+ it('delivers automation result via onAutomationResult callback (full chain)', async () => {
1131
+ // This test verifies the full wiring:
1132
+ // automation runs → DeliveryRouter dispatches callback target →
1133
+ // LocalServerConfig.onAutomationResult fires with the payload.
1134
+ //
1135
+ // The delivery-callback-test automation has `delivery.targets: [{ type:
1136
+ // 'callback' }]` with a template. When it completes, our onAutomationResult
1137
+ // (configured in beforeAll) should receive the full DeliveryPayload.
1138
+ const before = receivedAutomationResults.length;
1139
+ const runRes = await fetch(`http://localhost:${AGENT_PORT}/automations/delivery-callback-test/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(TIMEOUT) });
1140
+ expect([200, 500]).toContain(runRes.status);
1141
+ // Poll briefly for the callback to fire (delivery happens after runMessage drains)
1142
+ const deadline = Date.now() + 5000;
1143
+ while (receivedAutomationResults.length === before && Date.now() < deadline) {
1144
+ await new Promise((r) => setTimeout(r, 100));
1145
+ }
1146
+ expect(receivedAutomationResults.length).toBeGreaterThan(before);
1147
+ const payload = receivedAutomationResults[receivedAutomationResults.length - 1];
1148
+ expect(payload?.['automation']).toBe('delivery-callback-test');
1149
+ expect(payload?.['status']).toBe('success');
1150
+ // Template renders with {{automation}} built-in; {{count}} should come
1151
+ // from the JSON result (soft-check since LLM output varies slightly).
1152
+ const message = String(payload?.['message'] ?? '');
1153
+ expect(message).toContain('delivery-callback-test');
1154
+ }, TIMEOUT + 10_000);
1155
+ it('replays buffered events via Last-Event-ID on reconnect', async () => {
1156
+ // Produce at least one event, capture its seq, disconnect, reconnect
1157
+ // with Last-Event-ID set to seq-1, and verify we get the event back.
1158
+ const firstStream = await openEventStream();
1159
+ let capturedSeq = 0;
1160
+ try {
1161
+ await chat('Say "ok".');
1162
+ const event = await firstStream.waitFor((e) => e['type'] === 'session_created', TIMEOUT);
1163
+ capturedSeq = Number(event['seq']);
1164
+ expect(capturedSeq).toBeGreaterThan(0);
1165
+ }
1166
+ finally {
1167
+ firstStream.close();
1168
+ }
1169
+ const replayStream = await openEventStream({ lastEventId: String(capturedSeq - 1) });
1170
+ try {
1171
+ const replayed = await replayStream.waitFor((e) => Number(e['seq']) === capturedSeq, 5000);
1172
+ expect(Number(replayed['seq'])).toBe(capturedSeq);
1173
+ }
1174
+ finally {
1175
+ replayStream.close();
1176
+ }
1177
+ }, TIMEOUT);
1178
+ it('emits session_updated when title is PATCHed', async () => {
1179
+ const { sessionId } = await chat('Say "ok".');
1180
+ const stream = await openEventStream();
1181
+ try {
1182
+ const res = await fetch(`http://localhost:${AGENT_PORT}/session/${sessionId}`, {
1183
+ method: 'PATCH',
1184
+ headers: { 'Content-Type': 'application/json' },
1185
+ body: JSON.stringify({ title: 'my renamed session' }),
1186
+ signal: AbortSignal.timeout(5000),
1187
+ });
1188
+ expect(res.status).toBe(200);
1189
+ const event = await stream.waitFor((e) => e['type'] === 'session_updated' && e['sessionId'] === sessionId && e['title'] === 'my renamed session', 5000);
1190
+ expect(event['title']).toBe('my renamed session');
1191
+ }
1192
+ finally {
1193
+ stream.close();
1194
+ }
1195
+ }, TIMEOUT);
1196
+ it('emits store_updated when a tool writes to a store', async () => {
1197
+ const stream = await openEventStream();
1198
+ try {
1199
+ // Ask the agent to write to test-items store. Agent non-determinism
1200
+ // means it might not actually call the tool; we soft-check the event.
1201
+ await chat('Write an item to the test-items store with id="evt-smoke-1" and name="smoke event test".');
1202
+ const event = stream.events.find((e) => e['type'] === 'store_updated' && e['storeName'] === 'test-items');
1203
+ if (event) {
1204
+ expect(event['operation']).toBe('put');
1205
+ }
1206
+ else {
1207
+ // Model may have chosen not to call the store tool — this test is
1208
+ // soft (logged, not asserted) because it depends on LLM behavior.
1209
+ // eslint-disable-next-line no-console -- intentional test diagnostic
1210
+ console.warn('[smoke] store_updated not emitted — LLM may have declined to call store_write');
1211
+ }
1212
+ }
1213
+ finally {
1214
+ stream.close();
1215
+ }
1216
+ }, TIMEOUT);
1217
+ it('emits store_updated when a direct REST write happens', async () => {
1218
+ // This path doesn't depend on the LLM — assertable hard.
1219
+ const stream = await openEventStream();
1220
+ try {
1221
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/stores/test-items`, {
1222
+ method: 'POST',
1223
+ headers: { 'Content-Type': 'application/json' },
1224
+ body: JSON.stringify({ id: 'rest-smoke-1', name: 'direct rest write' }),
1225
+ signal: AbortSignal.timeout(5000),
1226
+ });
1227
+ expect(res.status).toBe(201);
1228
+ const event = await stream.waitFor((e) => e['type'] === 'store_updated' && e['storeName'] === 'test-items' && e['operation'] === 'put', 5000);
1229
+ expect(event['operation']).toBe('put');
1230
+ }
1231
+ finally {
1232
+ stream.close();
1233
+ }
1234
+ }, TIMEOUT);
1235
+ it('emits automation_started and automation_stopped', async () => {
1236
+ // The smoke agent's test-auto has no cron schedule, so start will fail.
1237
+ // That's fine — we want to verify the happy path when a schedulable
1238
+ // automation exists. Skip if none are available.
1239
+ const listRes = await fetch(`http://localhost:${AGENT_PORT}/automations`);
1240
+ const listBody = await listRes.json();
1241
+ const schedulable = listBody.automations.find((a) => a.schedule);
1242
+ if (!schedulable) {
1243
+ return; // smoke agent has no scheduled automation — skip
1244
+ }
1245
+ const stream = await openEventStream();
1246
+ try {
1247
+ const startRes = await fetch(`http://localhost:${AGENT_PORT}/automations/${schedulable.name}/start`, { method: 'POST', signal: AbortSignal.timeout(5000) });
1248
+ if (startRes.status !== 200)
1249
+ return; // not a schedulable automation
1250
+ const started = await stream.waitFor((e) => e['type'] === 'automation_started' && e['name'] === schedulable.name, 5000);
1251
+ expect(typeof started['intervalMs']).toBe('number');
1252
+ await fetch(`http://localhost:${AGENT_PORT}/automations/${schedulable.name}/stop`, { method: 'POST', signal: AbortSignal.timeout(5000) });
1253
+ const stopped = await stream.waitFor((e) => e['type'] === 'automation_stopped' && e['name'] === schedulable.name, 5000);
1254
+ expect(stopped['name']).toBe(schedulable.name);
1255
+ }
1256
+ finally {
1257
+ stream.close();
1258
+ }
1259
+ }, TIMEOUT);
1260
+ it('fans out the same event to all concurrent clients (two-tab case)', async () => {
1261
+ // Two independent SSE connections — the "two browser tabs" scenario.
1262
+ // Every event emitted by the server should reach BOTH clients with
1263
+ // the same seq number.
1264
+ const [s1, s2] = await Promise.all([openEventStream(), openEventStream()]);
1265
+ try {
1266
+ const chatResult = await chat('Say "ok".');
1267
+ const [e1, e2] = await Promise.all([
1268
+ s1.waitFor((e) => e['type'] === 'session_created' && e['sessionId'] === chatResult.sessionId, TIMEOUT),
1269
+ s2.waitFor((e) => e['type'] === 'session_created' && e['sessionId'] === chatResult.sessionId, TIMEOUT),
1270
+ ]);
1271
+ // Same logical event reached both clients
1272
+ expect(e1['seq']).toBe(e2['seq']);
1273
+ expect(e1['timestamp']).toBe(e2['timestamp']);
1274
+ expect(e1['sessionId']).toBe(e2['sessionId']);
1275
+ }
1276
+ finally {
1277
+ s1.close();
1278
+ s2.close();
1279
+ }
1280
+ }, TIMEOUT);
1281
+ // -------------------------------------------------------------------------
1282
+ // Web tools (web_search, fetch_url) — gated on GOOGLE_API_KEY.
1283
+ //
1284
+ // When the smoke target is Anthropic/OpenAI but GOOGLE_API_KEY is set,
1285
+ // these tests exercise the cross-provider case: the main agent runs on
1286
+ // one provider, but web_search routes through the dedicated Gemini
1287
+ // backend. beforeAll injects the webTools config when the key is set.
1288
+ // -------------------------------------------------------------------------
1289
+ const hasGoogleKey = !!process.env['GOOGLE_API_KEY'];
1290
+ it.skipIf(!hasGoogleKey)('web_search tool is invoked for a current-information question', async () => {
1291
+ const { events } = await chat('Use the web_search tool to find an authoritative source for the current stable version of Node.js. Reply with just the version number.');
1292
+ const toolStarts = findEvents(events, 'tool_call_start');
1293
+ const toolResults = findEvents(events, 'tool_call_result');
1294
+ const webSearchStart = toolStarts.find((e) => e['tool_name'] === 'web_search');
1295
+ expect(webSearchStart).toBeDefined();
1296
+ // The matching result for that tool_id should be a success.
1297
+ const toolId = webSearchStart?.['tool_id'];
1298
+ const webSearchResult = toolResults.find((e) => e['tool_id'] === toolId);
1299
+ expect(webSearchResult).toBeDefined();
1300
+ expect(webSearchResult?.['status']).toBe('success');
1301
+ // The session should finish normally with text output.
1302
+ const done = findEvent(events, 'done');
1303
+ expect(done?.['reason']).toBe('model_stop');
1304
+ expect(allText(events).length).toBeGreaterThan(0);
1305
+ }, TIMEOUT);
1306
+ });
1307
+ // ---------------------------------------------------------------------------
1308
+ // SSE parser helper
1309
+ // ---------------------------------------------------------------------------
1310
+ function parseSSE(text) {
1311
+ const events = [];
1312
+ for (const line of text.split('\n')) {
1313
+ if (!line.startsWith('data: '))
1314
+ continue;
1315
+ try {
1316
+ events.push(JSON.parse(line.slice(6)));
1317
+ }
1318
+ catch { /* skip */ }
1319
+ }
1320
+ return events;
1321
+ }
1322
+ async function openEventStream(options = {}) {
1323
+ const controller = new AbortController();
1324
+ const headers = { Accept: 'text/event-stream' };
1325
+ if (options.lastEventId)
1326
+ headers['Last-Event-ID'] = options.lastEventId;
1327
+ const res = await fetch(`http://localhost:${AGENT_PORT}/api/events`, {
1328
+ headers,
1329
+ signal: controller.signal,
1330
+ });
1331
+ if (!res.body)
1332
+ throw new Error('no response body from /api/events');
1333
+ const events = [];
1334
+ const waiters = [];
1335
+ // Drain the stream in the background, parsing SSE frames. Push each event
1336
+ // to the events array and notify any waiting predicates.
1337
+ const reader = res.body.getReader();
1338
+ const decoder = new TextDecoder();
1339
+ let buffer = '';
1340
+ let draining = true;
1341
+ void (async () => {
1342
+ try {
1343
+ while (draining) {
1344
+ const { done, value } = await reader.read();
1345
+ if (done)
1346
+ break;
1347
+ buffer += decoder.decode(value, { stream: true });
1348
+ let idx;
1349
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
1350
+ const frame = buffer.slice(0, idx);
1351
+ buffer = buffer.slice(idx + 2);
1352
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
1353
+ if (!dataLine)
1354
+ continue;
1355
+ try {
1356
+ const event = JSON.parse(dataLine.slice(6));
1357
+ events.push(event);
1358
+ // Notify any matching waiters (iterate a snapshot; matching
1359
+ // waiters are removed from the queue).
1360
+ for (let i = waiters.length - 1; i >= 0; i--) {
1361
+ const waiter = waiters[i];
1362
+ if (waiter && waiter.predicate(event)) {
1363
+ waiters.splice(i, 1);
1364
+ waiter.resolve(event);
1365
+ }
1366
+ }
1367
+ }
1368
+ catch { /* malformed frame */ }
1369
+ }
1370
+ }
1371
+ }
1372
+ catch { /* aborted or connection closed */ }
1373
+ })();
1374
+ return {
1375
+ events,
1376
+ waitFor(predicate, timeoutMs = 5000) {
1377
+ // Check already-buffered events first
1378
+ const already = events.find(predicate);
1379
+ if (already)
1380
+ return Promise.resolve(already);
1381
+ return new Promise((resolve, reject) => {
1382
+ const timer = setTimeout(() => {
1383
+ const idx = waiters.findIndex((w) => w.predicate === predicate);
1384
+ if (idx !== -1)
1385
+ waiters.splice(idx, 1);
1386
+ reject(new Error(`waitFor timed out after ${String(timeoutMs)}ms`));
1387
+ }, timeoutMs);
1388
+ waiters.push({
1389
+ predicate,
1390
+ resolve: (event) => {
1391
+ clearTimeout(timer);
1392
+ resolve(event);
1393
+ },
1394
+ });
1395
+ });
1396
+ },
1397
+ close() {
1398
+ draining = false;
1399
+ controller.abort();
1400
+ reader.cancel().catch(() => { });
1401
+ },
1402
+ };
1403
+ }
1404
+ //# sourceMappingURL=smoke.test.js.map