flowent 0.0.0 → 0.0.4

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 (494) hide show
  1. package/README.md +70 -10
  2. package/assets/flowent-banner.png +0 -0
  3. package/backend/.python-version +1 -0
  4. package/backend/pyproject.toml +57 -0
  5. package/backend/src/flowent/__init__.py +3 -0
  6. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/stats_service.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/_version.py +7 -0
  33. package/backend/src/flowent/access.py +247 -0
  34. package/backend/src/flowent/agent.py +2808 -0
  35. package/backend/src/flowent/assistant_commands.py +106 -0
  36. package/backend/src/flowent/channels/__init__.py +3 -0
  37. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  39. package/backend/src/flowent/channels/telegram.py +615 -0
  40. package/backend/src/flowent/cli.py +85 -0
  41. package/backend/src/flowent/config.py +14 -0
  42. package/backend/src/flowent/dev.py +3 -0
  43. package/backend/src/flowent/events.py +157 -0
  44. package/backend/src/flowent/graph_runtime.py +60 -0
  45. package/backend/src/flowent/graph_service.py +1346 -0
  46. package/backend/src/flowent/image_assets.py +356 -0
  47. package/backend/src/flowent/logging.py +155 -0
  48. package/backend/src/flowent/main.py +124 -0
  49. package/backend/src/flowent/mcp_service.py +1904 -0
  50. package/backend/src/flowent/model_metadata.py +98 -0
  51. package/backend/src/flowent/models/__init__.py +121 -0
  52. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/models/agent.py +33 -0
  66. package/backend/src/flowent/models/base.py +24 -0
  67. package/backend/src/flowent/models/blueprint.py +176 -0
  68. package/backend/src/flowent/models/content.py +164 -0
  69. package/backend/src/flowent/models/delta.py +44 -0
  70. package/backend/src/flowent/models/event.py +51 -0
  71. package/backend/src/flowent/models/graph.py +437 -0
  72. package/backend/src/flowent/models/history.py +214 -0
  73. package/backend/src/flowent/models/llm.py +61 -0
  74. package/backend/src/flowent/models/message.py +27 -0
  75. package/backend/src/flowent/models/tab.py +48 -0
  76. package/backend/src/flowent/models/todo.py +10 -0
  77. package/backend/src/flowent/network.py +146 -0
  78. package/backend/src/flowent/prompts/__init__.py +67 -0
  79. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  82. package/backend/src/flowent/prompts/common.py +250 -0
  83. package/backend/src/flowent/prompts/steward.py +64 -0
  84. package/backend/src/flowent/providers/__init__.py +23 -0
  85. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/providers/anthropic.py +468 -0
  100. package/backend/src/flowent/providers/base_url.py +60 -0
  101. package/backend/src/flowent/providers/configuration.py +182 -0
  102. package/backend/src/flowent/providers/content.py +122 -0
  103. package/backend/src/flowent/providers/errors.py +223 -0
  104. package/backend/src/flowent/providers/gateway.py +169 -0
  105. package/backend/src/flowent/providers/gemini.py +447 -0
  106. package/backend/src/flowent/providers/headers.py +20 -0
  107. package/backend/src/flowent/providers/management.py +96 -0
  108. package/backend/src/flowent/providers/ollama.py +293 -0
  109. package/backend/src/flowent/providers/openai.py +422 -0
  110. package/backend/src/flowent/providers/openai_responses.py +655 -0
  111. package/backend/src/flowent/providers/registry.py +144 -0
  112. package/backend/src/flowent/providers/sse.py +31 -0
  113. package/backend/src/flowent/providers/thinking.py +79 -0
  114. package/backend/src/flowent/registry.py +73 -0
  115. package/backend/src/flowent/role_management.py +255 -0
  116. package/backend/src/flowent/routes/__init__.py +30 -0
  117. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/routes/access.py +48 -0
  132. package/backend/src/flowent/routes/assistant.py +155 -0
  133. package/backend/src/flowent/routes/image_assets.py +33 -0
  134. package/backend/src/flowent/routes/mcp.py +125 -0
  135. package/backend/src/flowent/routes/meta.py +28 -0
  136. package/backend/src/flowent/routes/nodes.py +365 -0
  137. package/backend/src/flowent/routes/prompts.py +46 -0
  138. package/backend/src/flowent/routes/providers_route.py +364 -0
  139. package/backend/src/flowent/routes/roles.py +207 -0
  140. package/backend/src/flowent/routes/settings.py +324 -0
  141. package/backend/src/flowent/routes/stats.py +229 -0
  142. package/backend/src/flowent/routes/tabs.py +292 -0
  143. package/backend/src/flowent/routes/ws.py +33 -0
  144. package/backend/src/flowent/runtime.py +188 -0
  145. package/backend/src/flowent/sandbox.py +45 -0
  146. package/backend/src/flowent/security.py +42 -0
  147. package/backend/src/flowent/settings.py +2467 -0
  148. package/backend/src/flowent/settings_management.py +286 -0
  149. package/backend/src/flowent/state_db.py +120 -0
  150. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
  151. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
  152. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
  153. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
  154. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
  155. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
  156. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
  157. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
  158. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
  159. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
  160. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
  161. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  162. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  163. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
  164. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
  165. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
  166. package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
  167. package/backend/src/flowent/static/assets/dialog-BeGSweF6.js +1 -0
  168. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  169. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  170. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  171. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +1 -0
  172. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
  173. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  174. package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
  175. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
  176. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  177. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
  178. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  179. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
  180. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
  181. package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
  182. package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
  183. package/backend/src/flowent/static/index.html +36 -0
  184. package/backend/src/flowent/stats_service.py +218 -0
  185. package/backend/src/flowent/tools/__init__.py +201 -0
  186. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  205. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  206. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  207. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  208. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  209. package/backend/src/flowent/tools/connect.py +156 -0
  210. package/backend/src/flowent/tools/contacts.py +22 -0
  211. package/backend/src/flowent/tools/create_agent.py +270 -0
  212. package/backend/src/flowent/tools/create_tab.py +59 -0
  213. package/backend/src/flowent/tools/delete_tab.py +39 -0
  214. package/backend/src/flowent/tools/edit.py +142 -0
  215. package/backend/src/flowent/tools/exec.py +117 -0
  216. package/backend/src/flowent/tools/fetch.py +85 -0
  217. package/backend/src/flowent/tools/idle.py +27 -0
  218. package/backend/src/flowent/tools/list_roles.py +50 -0
  219. package/backend/src/flowent/tools/list_tabs.py +96 -0
  220. package/backend/src/flowent/tools/list_tools.py +24 -0
  221. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  222. package/backend/src/flowent/tools/manage_providers.py +220 -0
  223. package/backend/src/flowent/tools/manage_roles.py +275 -0
  224. package/backend/src/flowent/tools/manage_settings.py +346 -0
  225. package/backend/src/flowent/tools/mcp.py +199 -0
  226. package/backend/src/flowent/tools/read.py +152 -0
  227. package/backend/src/flowent/tools/send.py +50 -0
  228. package/backend/src/flowent/tools/set_permissions.py +84 -0
  229. package/backend/src/flowent/tools/sleep.py +41 -0
  230. package/backend/src/flowent/tools/todo.py +51 -0
  231. package/backend/src/flowent/workspace_store.py +479 -0
  232. package/backend/tests/__init__.py +0 -0
  233. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  234. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  235. package/backend/tests/conftest.py +6 -0
  236. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  242. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  243. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  244. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  245. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  246. package/backend/tests/integration/api/conftest.py +29 -0
  247. package/backend/tests/integration/api/test_access_api.py +182 -0
  248. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  249. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  250. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  251. package/backend/tests/integration/api/test_meta_api.py +33 -0
  252. package/backend/tests/integration/api/test_nodes_api.py +486 -0
  253. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  254. package/backend/tests/integration/api/test_roles_api.py +227 -0
  255. package/backend/tests/integration/api/test_tabs_api.py +501 -0
  256. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  261. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/unit/agent/test_agent_public_api.py +746 -0
  265. package/backend/tests/unit/agent/test_agent_runtime.py +2726 -0
  266. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  267. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  268. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/unit/logging/test_logging.py +132 -0
  270. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  271. package/backend/tests/unit/prompts/test_prompts.py +569 -0
  272. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  275. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  276. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  277. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  278. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  279. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  280. package/backend/tests/unit/providers/test_errors.py +68 -0
  281. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  282. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  283. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  284. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  285. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  286. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  288. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  289. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  291. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  292. package/backend/tests/unit/routes/test_providers_route.py +368 -0
  293. package/backend/tests/unit/routes/test_roles_routes.py +426 -0
  294. package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
  295. package/backend/tests/unit/routes/test_stats_routes.py +149 -0
  296. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  297. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1012 -0
  298. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  299. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  300. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  301. package/backend/tests/unit/security/test_security.py +110 -0
  302. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  303. package/backend/tests/unit/settings/test_settings_roles.py +711 -0
  304. package/backend/tests/unit/test_access.py +45 -0
  305. package/backend/tests/unit/test_cli.py +124 -0
  306. package/backend/tests/unit/test_graph_runtime.py +72 -0
  307. package/backend/tests/unit/test_network.py +51 -0
  308. package/backend/tests/unit/test_state_sqlite_storage.py +93 -0
  309. package/backend/tests/unit/test_workspace_store.py +231 -0
  310. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  319. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  321. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  323. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  324. package/backend/tests/unit/tools/test_connect_tool.py +229 -0
  325. package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
  326. package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -0
  327. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  328. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  329. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  330. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  331. package/backend/tests/unit/tools/test_manage_providers_tool.py +458 -0
  332. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  333. package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
  334. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  335. package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
  336. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  337. package/backend/tests/unit/tools/test_tool_registry.py +91 -0
  338. package/backend/uv.lock +1144 -0
  339. package/bin/flowent.mjs +62 -35
  340. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
  341. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
  342. package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
  343. package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
  344. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
  345. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
  346. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
  347. package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
  348. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
  349. package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
  350. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
  351. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  352. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  353. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
  354. package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
  355. package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
  356. package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
  357. package/dist/frontend/assets/dialog-BeGSweF6.js +1 -0
  358. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  359. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  360. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  361. package/dist/frontend/assets/index-BHC1Vhy8.css +1 -0
  362. package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
  363. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  364. package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
  365. package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
  366. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  367. package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
  368. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  369. package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
  370. package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
  371. package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
  372. package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
  373. package/dist/frontend/index.html +36 -0
  374. package/package.json +28 -41
  375. package/dist/.next/BUILD_ID +0 -1
  376. package/dist/.next/app-path-routes-manifest.json +0 -6
  377. package/dist/.next/build-manifest.json +0 -20
  378. package/dist/.next/package.json +0 -1
  379. package/dist/.next/prerender-manifest.json +0 -114
  380. package/dist/.next/required-server-files.json +0 -333
  381. package/dist/.next/routes-manifest.json +0 -69
  382. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  383. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  384. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  385. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  386. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  387. package/dist/.next/server/app/_global-error/page.js +0 -9
  388. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  389. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  390. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  391. package/dist/.next/server/app/_global-error.html +0 -1
  392. package/dist/.next/server/app/_global-error.meta +0 -15
  393. package/dist/.next/server/app/_global-error.rsc +0 -14
  394. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  395. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  396. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  397. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  398. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  399. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  400. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  401. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  402. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  403. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  404. package/dist/.next/server/app/_not-found/page.js +0 -13
  405. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  406. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  407. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  408. package/dist/.next/server/app/_not-found.html +0 -1
  409. package/dist/.next/server/app/_not-found.meta +0 -16
  410. package/dist/.next/server/app/_not-found.rsc +0 -16
  411. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  412. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  413. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  414. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  415. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  416. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  417. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  418. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  419. package/dist/.next/server/app/icon.svg/route.js +0 -6
  420. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  421. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  422. package/dist/.next/server/app/icon.svg.meta +0 -1
  423. package/dist/.next/server/app/index.html +0 -1
  424. package/dist/.next/server/app/index.meta +0 -14
  425. package/dist/.next/server/app/index.rsc +0 -15
  426. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  427. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  428. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  429. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  430. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  431. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  432. package/dist/.next/server/app/page/build-manifest.json +0 -16
  433. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  434. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  435. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  436. package/dist/.next/server/app/page.js +0 -14
  437. package/dist/.next/server/app/page.js.map +0 -5
  438. package/dist/.next/server/app/page.js.nft.json +0 -1
  439. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  440. package/dist/.next/server/app-paths-manifest.json +0 -6
  441. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  442. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  443. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  444. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  445. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  446. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  447. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  448. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  449. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  450. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  451. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  452. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  453. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  454. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  455. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  456. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  457. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  458. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  459. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  460. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  461. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  462. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  463. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  464. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  465. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  466. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  467. package/dist/.next/server/functions-config-manifest.json +0 -4
  468. package/dist/.next/server/middleware-build-manifest.js +0 -20
  469. package/dist/.next/server/middleware-manifest.json +0 -6
  470. package/dist/.next/server/next-font-manifest.js +0 -1
  471. package/dist/.next/server/next-font-manifest.json +0 -13
  472. package/dist/.next/server/pages/404.html +0 -1
  473. package/dist/.next/server/pages/500.html +0 -1
  474. package/dist/.next/server/pages-manifest.json +0 -4
  475. package/dist/.next/server/prefetch-hints.json +0 -1
  476. package/dist/.next/server/server-reference-manifest.js +0 -1
  477. package/dist/.next/server/server-reference-manifest.json +0 -5
  478. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_buildManifest.js +0 -11
  479. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_clientMiddlewareManifest.js +0 -1
  480. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_ssgManifest.js +0 -1
  481. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  482. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  483. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  484. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  485. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  486. package/dist/.next/static/chunks/15~9l5n.~r-.4.css +0 -2
  487. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  488. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  489. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  490. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  491. package/dist/package.json +0 -87
  492. package/dist/server.js +0 -38
  493. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  494. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,2726 @@
1
+ import json
2
+ import threading
3
+ import time
4
+
5
+ import pytest
6
+ from loguru import logger
7
+
8
+ from flowent.agent import (
9
+ Agent,
10
+ ContextPreflight,
11
+ InterruptRequestedError,
12
+ PreparedLLMContext,
13
+ WakeSignal,
14
+ )
15
+ from flowent.events import event_bus
16
+ from flowent.models import (
17
+ AgentState,
18
+ AssistantText,
19
+ AssistantThinking,
20
+ CommandResultEntry,
21
+ ErrorEntry,
22
+ EventType,
23
+ ImagePart,
24
+ LLMResponse,
25
+ LLMUsage,
26
+ Message,
27
+ NodeConfig,
28
+ NodeType,
29
+ ReceivedMessage,
30
+ SentMessage,
31
+ StateEntry,
32
+ SystemEntry,
33
+ Tab,
34
+ TodoItem,
35
+ ToolCall,
36
+ ToolCallResult,
37
+ )
38
+ from flowent.providers.errors import LLMProviderError
39
+ from flowent.registry import registry
40
+ from flowent.settings import ModelSettings, ProviderConfig, Settings
41
+ from flowent.stats_service import stats_store
42
+ from flowent.workspace_store import workspace_store
43
+
44
+
45
+ @pytest.fixture(autouse=True)
46
+ def reset_runtime_state(monkeypatch, tmp_path):
47
+ import flowent.settings as settings_module
48
+
49
+ settings_file = tmp_path / "settings.json"
50
+ settings_file.write_text("{}", encoding="utf-8")
51
+ monkeypatch.setattr(settings_module, "_SETTINGS_FILE", settings_file)
52
+ monkeypatch.setattr(settings_module, "_cached_settings", None)
53
+ registry.reset()
54
+ workspace_store.reset_cache()
55
+ stats_store.reset()
56
+ yield
57
+ registry.reset()
58
+ workspace_store.reset_cache()
59
+ stats_store.reset()
60
+ monkeypatch.setattr(settings_module, "_cached_settings", None)
61
+
62
+
63
+ def _register_tab_leader(*, tab_id: str = "tab-1", leader_id: str = "leader") -> Agent:
64
+ workspace_store.upsert_tab(Tab(id=tab_id, title="Task", leader_id=leader_id))
65
+ leader = Agent(
66
+ NodeConfig(
67
+ node_type=NodeType.AGENT,
68
+ role_name="Conductor",
69
+ name="Leader",
70
+ tab_id=tab_id,
71
+ ),
72
+ uuid=leader_id,
73
+ )
74
+ registry.register(leader)
75
+ return leader
76
+
77
+
78
+ def test_agent_keeps_running_after_pure_text_response(monkeypatch):
79
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
80
+ wait_calls = 0
81
+ llm_messages: list[list[dict]] = []
82
+ responses = iter([LLMResponse(content="working through the task"), LLMResponse()])
83
+
84
+ def fake_wait_for_input() -> None:
85
+ nonlocal wait_calls
86
+ wait_calls += 1
87
+ if wait_calls == 1:
88
+ agent._append_history(
89
+ ReceivedMessage(content="finish the task", from_id="tester")
90
+ )
91
+ agent.set_state(AgentState.RUNNING, "received message from tester")
92
+ return
93
+ raise AssertionError("agent should not auto-idle after pure assistant text")
94
+
95
+ def fake_chat(
96
+ messages,
97
+ tools=None,
98
+ on_chunk=None,
99
+ register_interrupt=None,
100
+ role_name=None,
101
+ ):
102
+ llm_messages.append(messages)
103
+ if len(llm_messages) == 2:
104
+ agent.request_termination("done")
105
+ return next(responses)
106
+
107
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
108
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
109
+
110
+ agent._run()
111
+
112
+ assert wait_calls == 1
113
+ assert len(llm_messages) == 2
114
+ assert agent.state == AgentState.TERMINATED
115
+ assert any(
116
+ isinstance(entry, AssistantText) and entry.content == "working through the task"
117
+ for entry in agent.get_history_snapshot()
118
+ )
119
+ assert any(
120
+ msg.get("role") == "assistant"
121
+ and msg.get("content") == "working through the task"
122
+ for msg in llm_messages[1]
123
+ )
124
+
125
+
126
+ def test_agent_retries_transient_llm_errors_before_succeeding(monkeypatch):
127
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
128
+ wait_calls = 0
129
+ llm_calls = 0
130
+
131
+ def fake_wait_for_input() -> None:
132
+ nonlocal wait_calls
133
+ wait_calls += 1
134
+ if wait_calls == 1:
135
+ agent._append_history(
136
+ ReceivedMessage(content="finish the task", from_id="tester")
137
+ )
138
+ agent.set_state(AgentState.RUNNING, "received message from tester")
139
+ return
140
+ raise AssertionError("agent should not return to idle while retrying")
141
+
142
+ def fake_chat(
143
+ messages,
144
+ tools=None,
145
+ on_chunk=None,
146
+ register_interrupt=None,
147
+ role_name=None,
148
+ ):
149
+ nonlocal llm_calls
150
+ llm_calls += 1
151
+ if llm_calls < 3:
152
+ raise LLMProviderError(
153
+ f"temporary failure {llm_calls}",
154
+ transient=True,
155
+ status_code=429,
156
+ )
157
+ if llm_calls == 4:
158
+ agent.request_termination("done")
159
+ return LLMResponse()
160
+ if llm_calls == 3:
161
+ return LLMResponse(content="Recovered answer")
162
+ raise AssertionError("unexpected extra LLM call")
163
+
164
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
165
+ monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
166
+ monkeypatch.setattr(
167
+ "flowent.agent.get_settings",
168
+ lambda: Settings(model=ModelSettings(max_retries=2)),
169
+ )
170
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
171
+
172
+ agent._run()
173
+
174
+ assert wait_calls == 1
175
+ assert llm_calls == 4
176
+ assert agent.state == AgentState.TERMINATED
177
+ assert not any(
178
+ isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
179
+ )
180
+ assert any(
181
+ isinstance(entry, AssistantText) and entry.content == "Recovered answer"
182
+ for entry in agent.get_history_snapshot()
183
+ )
184
+
185
+
186
+ def test_chat_with_retries_records_single_request_stat(monkeypatch):
187
+ workspace_store.upsert_tab(Tab(id="tab-1", title="Task", leader_id="leader-1"))
188
+ agent = Agent(
189
+ NodeConfig(
190
+ node_type=NodeType.AGENT,
191
+ role_name="Worker",
192
+ name="Planner",
193
+ tab_id="tab-1",
194
+ ),
195
+ uuid="agent-1",
196
+ )
197
+ settings = Settings(
198
+ model=ModelSettings(
199
+ active_provider_id="provider-1",
200
+ active_model="gpt-5.2",
201
+ retry_policy="limited",
202
+ max_retries=2,
203
+ ),
204
+ providers=[
205
+ ProviderConfig(
206
+ id="provider-1",
207
+ name="Primary",
208
+ type="openai_responses",
209
+ base_url="https://api.example.com/v1",
210
+ api_key="secret",
211
+ )
212
+ ],
213
+ )
214
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
215
+ monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
216
+
217
+ llm_calls = 0
218
+
219
+ def fake_chat(
220
+ messages,
221
+ tools=None,
222
+ on_chunk=None,
223
+ register_interrupt=None,
224
+ role_name=None,
225
+ ):
226
+ nonlocal llm_calls
227
+ llm_calls += 1
228
+ if llm_calls == 1:
229
+ raise LLMProviderError(
230
+ "temporary failure",
231
+ transient=True,
232
+ status_code=429,
233
+ )
234
+ return LLMResponse(
235
+ content="Done",
236
+ usage=LLMUsage(
237
+ total_tokens=120,
238
+ input_tokens=90,
239
+ output_tokens=30,
240
+ cache_read_tokens=12,
241
+ ),
242
+ raw_usage={"total_tokens": 120, "input_tokens": 90},
243
+ )
244
+
245
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
246
+
247
+ response, _ = agent._chat_with_retries(
248
+ prepared_context=PreparedLLMContext(
249
+ messages=[{"role": "user", "content": "hello"}],
250
+ system_messages=[],
251
+ execution_context_messages=[],
252
+ runtime_tail_messages=[],
253
+ ),
254
+ tools_schema=None,
255
+ )
256
+
257
+ records = stats_store.list_requests(since=0)
258
+
259
+ assert response.content == "Done"
260
+ assert len(records) == 1
261
+ assert records[0]["node_id"] == "agent-1"
262
+ assert records[0]["node_label"] == "Planner"
263
+ assert records[0]["tab_title"] == "Task"
264
+ assert records[0]["provider_id"] == "provider-1"
265
+ assert records[0]["model"] == "gpt-5.2"
266
+ assert records[0]["retry_count"] == 1
267
+ assert records[0]["result"] == "success"
268
+ assert records[0]["normalized_usage"]["cache_read_tokens"] == 12
269
+ assert records[0]["raw_usage"] == {"total_tokens": 120, "input_tokens": 90}
270
+
271
+
272
+ def test_agent_does_not_retry_transient_llm_errors_when_retry_policy_is_no_retry(
273
+ monkeypatch,
274
+ ):
275
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
276
+ wait_calls = 0
277
+ llm_calls = 0
278
+
279
+ def fake_wait_for_input() -> None:
280
+ nonlocal wait_calls
281
+ wait_calls += 1
282
+ if wait_calls == 1:
283
+ agent._append_history(
284
+ ReceivedMessage(content="finish the task", from_id="tester")
285
+ )
286
+ agent.set_state(AgentState.RUNNING, "received message from tester")
287
+ return
288
+ agent.request_termination("done")
289
+
290
+ def fake_chat(
291
+ messages,
292
+ tools=None,
293
+ on_chunk=None,
294
+ register_interrupt=None,
295
+ role_name=None,
296
+ ):
297
+ nonlocal llm_calls
298
+ llm_calls += 1
299
+ raise LLMProviderError(
300
+ "temporary failure",
301
+ transient=True,
302
+ status_code=429,
303
+ )
304
+
305
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
306
+ monkeypatch.setattr(
307
+ "flowent.agent.get_settings",
308
+ lambda: Settings(
309
+ model=ModelSettings(
310
+ retry_policy="no_retry",
311
+ max_retries=5,
312
+ )
313
+ ),
314
+ )
315
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
316
+
317
+ agent._run()
318
+
319
+ assert llm_calls == 1
320
+ assert wait_calls == 2
321
+ assert any(
322
+ isinstance(entry, ErrorEntry) and entry.content == "temporary failure"
323
+ for entry in agent.get_history_snapshot()
324
+ )
325
+
326
+
327
+ def test_agent_does_not_retry_non_transient_llm_errors(monkeypatch):
328
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
329
+ wait_calls = 0
330
+ llm_calls = 0
331
+ error_summary = (
332
+ "LLM API error\n"
333
+ "Provider: Test Provider\n"
334
+ "Type: openai\n"
335
+ "Model: gpt-5.2\n"
336
+ "Base URL: http://example.invalid\n"
337
+ "Status: 401\n"
338
+ "Detail: Invalid API key"
339
+ )
340
+
341
+ def fake_wait_for_input() -> None:
342
+ nonlocal wait_calls
343
+ wait_calls += 1
344
+ if wait_calls == 1:
345
+ agent._append_history(
346
+ ReceivedMessage(content="finish the task", from_id="tester")
347
+ )
348
+ agent.set_state(AgentState.RUNNING, "received message from tester")
349
+ return
350
+ agent.request_termination("done")
351
+
352
+ def fake_chat(
353
+ messages,
354
+ tools=None,
355
+ on_chunk=None,
356
+ register_interrupt=None,
357
+ role_name=None,
358
+ ):
359
+ nonlocal llm_calls
360
+ llm_calls += 1
361
+ raise LLMProviderError(
362
+ error_summary,
363
+ transient=False,
364
+ status_code=401,
365
+ )
366
+
367
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
368
+ monkeypatch.setattr(
369
+ "flowent.agent.get_settings",
370
+ lambda: Settings(model=ModelSettings(max_retries=5)),
371
+ )
372
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
373
+
374
+ agent._run()
375
+
376
+ assert llm_calls == 1
377
+ assert wait_calls == 2
378
+ assert any(
379
+ isinstance(entry, ErrorEntry) and entry.content == error_summary
380
+ for entry in agent.get_history_snapshot()
381
+ )
382
+ assert any(
383
+ isinstance(entry, StateEntry)
384
+ and entry.state == AgentState.ERROR.value
385
+ and entry.reason == error_summary
386
+ for entry in agent.get_history_snapshot()
387
+ )
388
+ assert not any(
389
+ isinstance(entry, ErrorEntry)
390
+ and (
391
+ "traceback" in entry.content.lower() or "LLMProviderError:" in entry.content
392
+ )
393
+ for entry in agent.get_history_snapshot()
394
+ )
395
+
396
+
397
+ def test_agent_interrupt_stops_retry_backoff(monkeypatch):
398
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
399
+ wait_calls = 0
400
+ llm_calls = 0
401
+ interrupter: threading.Thread | None = None
402
+
403
+ def fake_wait_for_input() -> None:
404
+ nonlocal wait_calls
405
+ wait_calls += 1
406
+ if wait_calls == 1:
407
+ agent._append_history(
408
+ ReceivedMessage(content="finish the task", from_id="tester")
409
+ )
410
+ agent.set_state(AgentState.RUNNING, "received message from tester")
411
+ return
412
+ agent.request_termination("done")
413
+
414
+ def fake_chat(
415
+ messages,
416
+ tools=None,
417
+ on_chunk=None,
418
+ register_interrupt=None,
419
+ role_name=None,
420
+ ):
421
+ nonlocal llm_calls, interrupter
422
+ llm_calls += 1
423
+ if llm_calls == 1:
424
+ interrupter = threading.Thread(
425
+ target=lambda: (time.sleep(0.01), agent.request_interrupt())
426
+ )
427
+ interrupter.start()
428
+ raise LLMProviderError(
429
+ "temporary failure",
430
+ transient=True,
431
+ status_code=429,
432
+ )
433
+ raise AssertionError("interrupt should stop retry before next attempt")
434
+
435
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
436
+ monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 1.0)
437
+ monkeypatch.setattr(
438
+ "flowent.agent.get_settings",
439
+ lambda: Settings(model=ModelSettings(max_retries=5)),
440
+ )
441
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
442
+
443
+ agent._run()
444
+ if interrupter is not None:
445
+ interrupter.join(timeout=1.0)
446
+
447
+ assert llm_calls == 1
448
+ assert wait_calls == 2
449
+ assert not any(
450
+ isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
451
+ )
452
+
453
+
454
+ def test_agent_retries_transient_errors_when_retry_policy_is_unlimited(monkeypatch):
455
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
456
+ wait_calls = 0
457
+ llm_calls = 0
458
+
459
+ def fake_wait_for_input() -> None:
460
+ nonlocal wait_calls
461
+ wait_calls += 1
462
+ if wait_calls == 1:
463
+ agent._append_history(
464
+ ReceivedMessage(content="finish the task", from_id="tester")
465
+ )
466
+ agent.set_state(AgentState.RUNNING, "received message from tester")
467
+ return
468
+ raise AssertionError("agent should not return to idle while retrying")
469
+
470
+ def fake_chat(
471
+ messages,
472
+ tools=None,
473
+ on_chunk=None,
474
+ register_interrupt=None,
475
+ role_name=None,
476
+ ):
477
+ nonlocal llm_calls
478
+ llm_calls += 1
479
+ if llm_calls < 4:
480
+ raise LLMProviderError(
481
+ f"temporary failure {llm_calls}",
482
+ transient=True,
483
+ status_code=429,
484
+ )
485
+ if llm_calls == 5:
486
+ agent.request_termination("done")
487
+ return LLMResponse()
488
+ if llm_calls == 4:
489
+ return LLMResponse(content="Recovered after unlimited retries")
490
+ raise AssertionError("unexpected extra LLM call")
491
+
492
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
493
+ monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
494
+ monkeypatch.setattr(
495
+ "flowent.agent.get_settings",
496
+ lambda: Settings(
497
+ model=ModelSettings(
498
+ retry_policy="unlimited",
499
+ max_retries=1,
500
+ )
501
+ ),
502
+ )
503
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
504
+
505
+ agent._run()
506
+
507
+ assert wait_calls == 1
508
+ assert llm_calls == 5
509
+ assert agent.state == AgentState.TERMINATED
510
+ assert any(
511
+ isinstance(entry, AssistantText)
512
+ and entry.content == "Recovered after unlimited retries"
513
+ for entry in agent.get_history_snapshot()
514
+ )
515
+
516
+
517
+ def test_get_llm_retry_delay_uses_configured_backoff_settings(monkeypatch):
518
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT))
519
+
520
+ monkeypatch.setattr(
521
+ "flowent.agent.get_settings",
522
+ lambda: Settings(
523
+ model=ModelSettings(
524
+ retry_initial_delay_seconds=0.75,
525
+ retry_max_delay_seconds=5.0,
526
+ retry_backoff_cap_retries=3,
527
+ )
528
+ ),
529
+ )
530
+
531
+ assert agent._get_llm_retry_delay(1) == 0.75
532
+ assert agent._get_llm_retry_delay(2) == 1.5
533
+ assert agent._get_llm_retry_delay(3) == 3.0
534
+ assert agent._get_llm_retry_delay(4) == 3.0
535
+
536
+
537
+ def test_get_llm_retry_429_delay_uses_active_provider_only_for_429(monkeypatch):
538
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT, role_name="Worker"))
539
+
540
+ monkeypatch.setattr(
541
+ "flowent.agent.get_settings",
542
+ lambda: Settings(
543
+ model=ModelSettings(
544
+ active_provider_id="provider-1",
545
+ active_model="gpt-test",
546
+ ),
547
+ providers=[
548
+ ProviderConfig(
549
+ id="provider-1",
550
+ name="Primary",
551
+ type="openai_compatible",
552
+ base_url="https://api.example.com/v1",
553
+ api_key="secret",
554
+ retry_429_delay_seconds=4,
555
+ )
556
+ ],
557
+ ),
558
+ )
559
+
560
+ assert agent._get_llm_retry_429_delay(429) == 4.0
561
+ assert agent._get_llm_retry_429_delay(500) == 0.0
562
+
563
+
564
+ def test_prepare_messages_records_auto_compact_stat(monkeypatch):
565
+ workspace_store.upsert_tab(Tab(id="tab-1", title="Task", leader_id="leader-1"))
566
+ agent = Agent(
567
+ NodeConfig(
568
+ node_type=NodeType.AGENT,
569
+ role_name="Worker",
570
+ name="Planner",
571
+ tab_id="tab-1",
572
+ ),
573
+ uuid="agent-1",
574
+ )
575
+ settings = Settings(
576
+ model=ModelSettings(
577
+ active_provider_id="provider-1",
578
+ active_model="gpt-5.2",
579
+ ),
580
+ providers=[
581
+ ProviderConfig(
582
+ id="provider-1",
583
+ name="Primary",
584
+ type="openai_responses",
585
+ base_url="https://api.example.com/v1",
586
+ api_key="secret",
587
+ )
588
+ ],
589
+ )
590
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
591
+
592
+ prepared_context = PreparedLLMContext(
593
+ messages=[{"role": "user", "content": "hello"}],
594
+ system_messages=[],
595
+ execution_context_messages=[],
596
+ runtime_tail_messages=[],
597
+ )
598
+ preflights = iter(
599
+ [
600
+ ContextPreflight(
601
+ estimated_total_tokens=50,
602
+ auto_compact_token_limit=10,
603
+ ),
604
+ ContextPreflight(
605
+ estimated_total_tokens=2,
606
+ auto_compact_token_limit=10,
607
+ ),
608
+ ]
609
+ )
610
+
611
+ monkeypatch.setattr(agent, "_build_prepared_llm_context", lambda: prepared_context)
612
+ monkeypatch.setattr(
613
+ agent,
614
+ "_compute_context_preflight",
615
+ lambda context: next(preflights),
616
+ )
617
+ compact_calls: list[str | None] = []
618
+ monkeypatch.setattr(
619
+ agent,
620
+ "_compact_execution_context",
621
+ lambda focus=None: compact_calls.append(focus) or "",
622
+ )
623
+
624
+ result = agent._prepare_messages_for_llm()
625
+ records = stats_store.list_compacts(since=0)
626
+
627
+ assert result == prepared_context
628
+ assert compact_calls == [None]
629
+ assert len(records) == 1
630
+ assert records[0]["trigger_type"] == "auto"
631
+ assert records[0]["result"] == "success"
632
+ assert records[0]["provider_id"] == "provider-1"
633
+ assert records[0]["model"] == "gpt-5.2"
634
+
635
+
636
+ def test_clear_assistant_chat_history_drops_conversation_entries():
637
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
638
+ assistant.history.extend(
639
+ [
640
+ SystemEntry(content="system prompt"),
641
+ ReceivedMessage(content="hello", from_id="human"),
642
+ AssistantThinking(content="planning"),
643
+ AssistantText(content="hi"),
644
+ ToolCall(
645
+ tool_name="idle",
646
+ tool_call_id="tool-1",
647
+ arguments={},
648
+ result="idle 1.00s",
649
+ ),
650
+ ErrorEntry(content="boom"),
651
+ ]
652
+ )
653
+
654
+ assistant.clear_chat_history()
655
+
656
+ assert all(
657
+ isinstance(entry, (SystemEntry, StateEntry))
658
+ for entry in assistant.get_history_snapshot()
659
+ )
660
+
661
+
662
+ @pytest.mark.parametrize(
663
+ ("state", "reason"),
664
+ [
665
+ (AgentState.RUNNING, "processing"),
666
+ (AgentState.SLEEPING, "waiting for reply"),
667
+ ],
668
+ )
669
+ def test_clear_assistant_chat_history_interrupts_active_agent(
670
+ monkeypatch,
671
+ state,
672
+ reason,
673
+ ):
674
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
675
+ assistant.set_state(state, reason)
676
+ assistant.history.append(ReceivedMessage(content="hello", from_id="human"))
677
+ interrupt_thread = threading.Thread(
678
+ target=assistant._handle_interrupt,
679
+ args=(None,),
680
+ daemon=True,
681
+ )
682
+
683
+ def fake_request_interrupt() -> bool:
684
+ if not interrupt_thread.is_alive():
685
+ interrupt_thread.start()
686
+ return True
687
+
688
+ monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
689
+
690
+ assistant.clear_chat_history()
691
+
692
+ assert assistant.state == AgentState.IDLE
693
+ assert not any(
694
+ isinstance(entry, ReceivedMessage) for entry in assistant.get_history_snapshot()
695
+ )
696
+
697
+ assistant.request_termination("done")
698
+ interrupt_thread.join(timeout=1.0)
699
+ assert interrupt_thread.is_alive() is False
700
+
701
+
702
+ def test_clear_assistant_chat_history_drops_queued_messages_after_interrupt(
703
+ monkeypatch,
704
+ ):
705
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
706
+ assistant.set_state(AgentState.RUNNING, "processing")
707
+ assistant._wake_queue.put(
708
+ WakeSignal(
709
+ reason="message",
710
+ payload={"message": {"content": "queued message", "from": "human"}},
711
+ resume_reason="received message from human",
712
+ )
713
+ )
714
+
715
+ interrupt_thread = threading.Thread(
716
+ target=assistant._handle_interrupt,
717
+ args=(None,),
718
+ daemon=True,
719
+ )
720
+
721
+ def fake_request_interrupt() -> bool:
722
+ if not interrupt_thread.is_alive():
723
+ interrupt_thread.start()
724
+ return True
725
+
726
+ monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
727
+ assistant.clear_chat_history()
728
+
729
+ assert assistant.state == AgentState.IDLE
730
+ assert not any(
731
+ isinstance(entry, ReceivedMessage) and entry.content == "queued message"
732
+ for entry in assistant.get_history_snapshot()
733
+ )
734
+
735
+ assistant.request_termination("done")
736
+ interrupt_thread.join(timeout=1.0)
737
+ assert interrupt_thread.is_alive() is False
738
+
739
+
740
+ def test_execute_clear_command_does_not_append_visible_feedback():
741
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
742
+ assistant.history.extend(
743
+ [
744
+ ReceivedMessage(content="hello", from_id="human"),
745
+ AssistantText(content="hi"),
746
+ ]
747
+ )
748
+
749
+ entry = assistant.execute_assistant_command(command_name="/clear")
750
+ history = assistant.get_history_snapshot()
751
+
752
+ assert isinstance(entry, CommandResultEntry)
753
+ assert entry.command_name == "/clear"
754
+ assert entry.include_in_context is False
755
+ assert not any(isinstance(item, ReceivedMessage) for item in history)
756
+ assert not any(isinstance(item, AssistantText) for item in history)
757
+ assert not any(
758
+ isinstance(item, CommandResultEntry) and item.command_name == "/clear"
759
+ for item in history
760
+ )
761
+
762
+
763
+ def test_retry_human_message_replaces_selected_tail_and_requeues_input(monkeypatch):
764
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
765
+ queued_messages: list[Message] = []
766
+ monkeypatch.setattr(
767
+ assistant,
768
+ "enqueue_message",
769
+ lambda message: queued_messages.append(message),
770
+ )
771
+ assistant.history.extend(
772
+ [
773
+ ReceivedMessage(
774
+ content="Keep this message",
775
+ from_id="human",
776
+ message_id="msg-1",
777
+ ),
778
+ AssistantText(content="Keep this reply"),
779
+ ReceivedMessage(
780
+ content="Retry this request",
781
+ from_id="human",
782
+ message_id="msg-2",
783
+ ),
784
+ AssistantThinking(content="Old thinking"),
785
+ AssistantText(content="Discard this reply"),
786
+ ErrorEntry(content="Old failure"),
787
+ ]
788
+ )
789
+
790
+ retried_message_id = assistant.retry_human_message(message_id="msg-2")
791
+ history = assistant.get_history_snapshot()
792
+
793
+ assert retried_message_id != "msg-2"
794
+ assert any(
795
+ isinstance(entry, ReceivedMessage)
796
+ and entry.message_id == "msg-1"
797
+ and entry.content == "Keep this message"
798
+ for entry in history
799
+ )
800
+ assert any(
801
+ isinstance(entry, AssistantText) and entry.content == "Keep this reply"
802
+ for entry in history
803
+ )
804
+ assert not any(
805
+ isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
806
+ for entry in history
807
+ )
808
+ assert not any(
809
+ isinstance(entry, AssistantText) and entry.content == "Discard this reply"
810
+ for entry in history
811
+ )
812
+ assert not any(
813
+ isinstance(entry, ErrorEntry) and entry.content == "Old failure"
814
+ for entry in history
815
+ )
816
+ assert any(
817
+ isinstance(entry, ReceivedMessage)
818
+ and entry.message_id == retried_message_id
819
+ and entry.content == "Retry this request"
820
+ for entry in history
821
+ )
822
+ assert len(queued_messages) == 1
823
+ assert queued_messages[0].message_id == retried_message_id
824
+ assert queued_messages[0].history_recorded is True
825
+ assert queued_messages[0].content == "Retry this request"
826
+
827
+
828
+ def test_retry_human_message_reuses_image_parts(monkeypatch):
829
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
830
+ queued_messages: list[Message] = []
831
+ monkeypatch.setattr(assistant, "supports_input_image", lambda: True)
832
+ monkeypatch.setattr(
833
+ "flowent.agent.require_image_asset",
834
+ lambda asset_id: object() if asset_id == "asset-1" else None,
835
+ )
836
+ monkeypatch.setattr(
837
+ assistant,
838
+ "enqueue_message",
839
+ lambda message: queued_messages.append(message),
840
+ )
841
+ assistant.history.extend(
842
+ [
843
+ ReceivedMessage(
844
+ from_id="human",
845
+ parts=[
846
+ ImagePart(
847
+ asset_id="asset-1",
848
+ mime_type="image/png",
849
+ width=1,
850
+ height=1,
851
+ )
852
+ ],
853
+ message_id="msg-image",
854
+ ),
855
+ AssistantText(content="Old image reply"),
856
+ ]
857
+ )
858
+
859
+ retried_message_id = assistant.retry_human_message(message_id="msg-image")
860
+
861
+ assert retried_message_id
862
+ assert len(queued_messages) == 1
863
+ assert len(queued_messages[0].parts) == 1
864
+ assert isinstance(queued_messages[0].parts[0], ImagePart)
865
+ assert queued_messages[0].parts[0].asset_id == "asset-1"
866
+
867
+
868
+ def test_retry_human_message_missing_anchor_keeps_queued_messages():
869
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
870
+ assistant._wake_queue.put(
871
+ WakeSignal(
872
+ reason="message",
873
+ payload={"message": {"content": "queued message", "from": "human"}},
874
+ resume_reason="received message from human",
875
+ )
876
+ )
877
+
878
+ with pytest.raises(LookupError):
879
+ assistant.retry_human_message(message_id="missing")
880
+
881
+ signal = assistant._wake_queue.get_nowait()
882
+ assert signal.reason == "message"
883
+ assert signal.payload["message"]["content"] == "queued message"
884
+
885
+
886
+ def test_retry_human_message_rolls_back_when_persist_fails(monkeypatch):
887
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
888
+ assistant.history.extend(
889
+ [
890
+ ReceivedMessage(
891
+ content="Retry this request",
892
+ from_id="human",
893
+ message_id="msg-2",
894
+ ),
895
+ AssistantText(content="Discard this reply"),
896
+ ]
897
+ )
898
+ persist_calls = 0
899
+
900
+ def fake_persist() -> None:
901
+ nonlocal persist_calls
902
+ persist_calls += 1
903
+ if persist_calls == 1:
904
+ raise RuntimeError("persist failed")
905
+
906
+ monkeypatch.setattr(assistant, "_persist_workspace_node", fake_persist)
907
+
908
+ with pytest.raises(RuntimeError, match="persist failed"):
909
+ assistant.retry_human_message(message_id="msg-2")
910
+
911
+ history = assistant.get_history_snapshot()
912
+ assert any(
913
+ isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
914
+ for entry in history
915
+ )
916
+ assert any(
917
+ isinstance(entry, AssistantText) and entry.content == "Discard this reply"
918
+ for entry in history
919
+ )
920
+
921
+
922
+ def test_leader_retry_received_message_requires_human_anchor(monkeypatch):
923
+ leader = _register_tab_leader()
924
+ leader.history.extend(
925
+ [
926
+ ReceivedMessage(
927
+ content="Assistant brief",
928
+ from_id="assistant",
929
+ message_id="brief-1",
930
+ ),
931
+ ReceivedMessage(
932
+ content="Human follow-up",
933
+ from_id="human",
934
+ message_id="msg-human",
935
+ ),
936
+ ]
937
+ )
938
+ monkeypatch.setattr(
939
+ "flowent.graph_service.is_tab_leader",
940
+ lambda **kwargs: kwargs["node_id"] == leader.uuid,
941
+ )
942
+
943
+ with pytest.raises(
944
+ LookupError,
945
+ match=r"Leader human message `brief-1` was not found\.",
946
+ ):
947
+ leader.retry_received_message(message_id="brief-1")
948
+
949
+
950
+ def test_execute_compact_command_replaces_history_with_summary(monkeypatch):
951
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
952
+ assistant.history.extend(
953
+ [
954
+ ReceivedMessage(content="Summarize the rollout", from_id="human"),
955
+ AssistantText(content="Working through the changes."),
956
+ ErrorEntry(content="temporary failure"),
957
+ ]
958
+ )
959
+
960
+ monkeypatch.setattr(
961
+ "flowent.agent.gateway.chat",
962
+ lambda *args, **kwargs: LLMResponse(
963
+ content=(
964
+ "## Current Goal\nShip the command layer.\n\n"
965
+ "## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
966
+ "## Key Constraints\nPreserve persistence.\n\n"
967
+ "## Confirmed Decisions\nUse built-in commands only.\n\n"
968
+ "## Open Questions\nNone.\n\n"
969
+ "## Next Actions\nFinish the UI."
970
+ )
971
+ ),
972
+ )
973
+
974
+ entry = assistant.execute_assistant_command(
975
+ command_name="/compact",
976
+ argument="slash rollout",
977
+ )
978
+
979
+ history = assistant.get_history_snapshot()
980
+
981
+ assert isinstance(entry, CommandResultEntry)
982
+ assert entry.include_in_context is False
983
+ assert history[-1] == entry
984
+ assert any(
985
+ isinstance(item, ReceivedMessage) and item.content == "Summarize the rollout"
986
+ for item in history
987
+ )
988
+ assert any(
989
+ isinstance(item, AssistantText)
990
+ and item.content == "Working through the changes."
991
+ for item in history
992
+ )
993
+ assert (
994
+ assistant.get_execution_context_summary().startswith("## Current Goal\n")
995
+ is True
996
+ )
997
+
998
+ messages = assistant._build_messages()
999
+ serialized = json.dumps(messages)
1000
+
1001
+ assert "Summarize the rollout" not in serialized
1002
+ assert "Compacted execution context" in serialized
1003
+ assert "Ship the command layer." in serialized
1004
+ assert "Compacted the current Assistant execution context." not in serialized
1005
+
1006
+
1007
+ def test_compact_command_excludes_queued_messages_from_summary(monkeypatch):
1008
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1009
+ assistant.history.extend(
1010
+ [
1011
+ ReceivedMessage(content="Existing history", from_id="human"),
1012
+ AssistantText(content="Existing reply"),
1013
+ ]
1014
+ )
1015
+ assistant.set_state(AgentState.RUNNING, "processing")
1016
+ assistant._wake_queue.put(
1017
+ WakeSignal(
1018
+ reason="message",
1019
+ payload={"message": {"content": "queued message", "from": "human"}},
1020
+ resume_reason="received message from human",
1021
+ )
1022
+ )
1023
+
1024
+ captured_messages: list[list[dict]] = []
1025
+
1026
+ def fake_chat(*, messages, **kwargs):
1027
+ captured_messages.append(messages)
1028
+ return LLMResponse(
1029
+ content=(
1030
+ "## Current Goal\nShip the command layer.\n\n"
1031
+ "## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
1032
+ "## Key Constraints\nPreserve persistence.\n\n"
1033
+ "## Confirmed Decisions\nUse built-in commands only.\n\n"
1034
+ "## Open Questions\nNone.\n\n"
1035
+ "## Next Actions\nFinish the UI."
1036
+ )
1037
+ )
1038
+
1039
+ interrupt_thread = threading.Thread(
1040
+ target=assistant._handle_interrupt,
1041
+ args=(None,),
1042
+ daemon=True,
1043
+ )
1044
+
1045
+ def fake_request_interrupt() -> bool:
1046
+ if not interrupt_thread.is_alive():
1047
+ interrupt_thread.start()
1048
+ return True
1049
+
1050
+ monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
1051
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1052
+
1053
+ assistant.compact_chat_history()
1054
+
1055
+ assert captured_messages
1056
+ assert "queued message" not in json.dumps(captured_messages[0])
1057
+
1058
+ assistant.request_termination("done")
1059
+ interrupt_thread.join(timeout=1.0)
1060
+ assert interrupt_thread.is_alive() is False
1061
+
1062
+
1063
+ def test_help_command_result_does_not_reenter_model_context():
1064
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1065
+
1066
+ entry = assistant.execute_assistant_command(command_name="/help")
1067
+ messages = assistant._build_messages()
1068
+ serialized = json.dumps(messages)
1069
+
1070
+ assert isinstance(entry, CommandResultEntry)
1071
+ assert entry.include_in_context is False
1072
+ assert "/compact" in entry.content
1073
+ assert "Built-in Assistant commands" not in serialized
1074
+
1075
+
1076
+ def test_agent_normalizes_think_tags_in_final_content(monkeypatch):
1077
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1078
+ wait_calls = 0
1079
+ llm_calls = 0
1080
+
1081
+ def fake_wait_for_input() -> None:
1082
+ nonlocal wait_calls
1083
+ wait_calls += 1
1084
+ if wait_calls == 1:
1085
+ agent._append_history(
1086
+ ReceivedMessage(content="reply to me", from_id="human")
1087
+ )
1088
+ agent.set_state(AgentState.RUNNING, "received message from human")
1089
+ return
1090
+ agent.request_termination("done")
1091
+
1092
+ def fake_chat(
1093
+ messages,
1094
+ tools=None,
1095
+ on_chunk=None,
1096
+ register_interrupt=None,
1097
+ role_name=None,
1098
+ ):
1099
+ nonlocal llm_calls
1100
+ llm_calls += 1
1101
+ if llm_calls == 2:
1102
+ agent.request_termination("done")
1103
+ return LLMResponse()
1104
+ return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1105
+
1106
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1107
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1108
+
1109
+ agent._run()
1110
+
1111
+ history = agent.get_history_snapshot()
1112
+
1113
+ assert any(
1114
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1115
+ for entry in history
1116
+ )
1117
+ assert any(
1118
+ isinstance(entry, AssistantText) and entry.content == "Hello there"
1119
+ for entry in history
1120
+ )
1121
+ assert not any(
1122
+ isinstance(entry, AssistantText) and "<think>" in entry.content
1123
+ for entry in history
1124
+ )
1125
+
1126
+
1127
+ def test_agent_dedupes_structured_thinking_and_raw_think_tags(monkeypatch):
1128
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1129
+ wait_calls = 0
1130
+ llm_calls = 0
1131
+
1132
+ def fake_wait_for_input() -> None:
1133
+ nonlocal wait_calls
1134
+ wait_calls += 1
1135
+ if wait_calls == 1:
1136
+ agent._append_history(
1137
+ ReceivedMessage(content="reply to me", from_id="human")
1138
+ )
1139
+ agent.set_state(AgentState.RUNNING, "received message from human")
1140
+ return
1141
+ agent.request_termination("done")
1142
+
1143
+ def fake_chat(
1144
+ messages,
1145
+ tools=None,
1146
+ on_chunk=None,
1147
+ register_interrupt=None,
1148
+ role_name=None,
1149
+ ):
1150
+ nonlocal llm_calls
1151
+ llm_calls += 1
1152
+ if llm_calls == 2:
1153
+ agent.request_termination("done")
1154
+ return LLMResponse()
1155
+ return LLMResponse(
1156
+ content="<think>Drafting plan</think>\nHello there",
1157
+ thinking="Drafting plan",
1158
+ )
1159
+
1160
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1161
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1162
+
1163
+ agent._run()
1164
+
1165
+ thinking_entries = [
1166
+ entry.content
1167
+ for entry in agent.get_history_snapshot()
1168
+ if isinstance(entry, AssistantThinking)
1169
+ ]
1170
+
1171
+ assert thinking_entries == ["Drafting plan"]
1172
+
1173
+
1174
+ def test_agent_unregisters_from_registry_after_termination_request(monkeypatch):
1175
+ registry.reset()
1176
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-x")
1177
+ registry.register(agent)
1178
+ events = []
1179
+
1180
+ def fake_wait_for_input() -> None:
1181
+ agent._append_history(
1182
+ ReceivedMessage(content="finish the task", from_id="tester")
1183
+ )
1184
+ agent.set_state(AgentState.RUNNING, "received message from tester")
1185
+
1186
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1187
+
1188
+ def fake_chat(
1189
+ messages,
1190
+ tools=None,
1191
+ on_chunk=None,
1192
+ register_interrupt=None,
1193
+ role_name=None,
1194
+ ):
1195
+ agent.request_termination("done")
1196
+ return LLMResponse()
1197
+
1198
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1199
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1200
+
1201
+ agent._run()
1202
+
1203
+ assert agent.state == AgentState.TERMINATED
1204
+ assert registry.get(agent.uuid) is None
1205
+ assert [event.type for event in events[-2:]] == [
1206
+ EventType.NODE_STATE_CHANGED,
1207
+ EventType.NODE_TERMINATED,
1208
+ ]
1209
+ assert events[-1].data == {"reason": "done"}
1210
+
1211
+
1212
+ def test_finalize_termination_removes_bidirectional_connections():
1213
+ registry.reset()
1214
+ try:
1215
+ assistant = Agent(
1216
+ NodeConfig(node_type=NodeType.ASSISTANT),
1217
+ )
1218
+ worker = Agent(
1219
+ NodeConfig(node_type=NodeType.AGENT),
1220
+ uuid="worker",
1221
+ )
1222
+ registry.register(assistant)
1223
+ registry.register(worker)
1224
+ assistant.add_connection(worker.uuid)
1225
+ worker.add_connection(assistant.uuid)
1226
+
1227
+ worker._finalize_termination("done")
1228
+
1229
+ assert registry.get(worker.uuid) is None
1230
+ assert assistant.get_connections_snapshot() == []
1231
+ assert worker.get_connections_snapshot() == []
1232
+ finally:
1233
+ registry.reset()
1234
+
1235
+
1236
+ def test_agent_interrupts_streaming_response_and_returns_to_idle(monkeypatch):
1237
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1238
+ registry.reset()
1239
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1240
+ registry.register(assistant)
1241
+ events = []
1242
+ wait_calls = 0
1243
+
1244
+ def fake_wait_for_input() -> None:
1245
+ nonlocal wait_calls
1246
+ wait_calls += 1
1247
+ if wait_calls == 1:
1248
+ assistant._append_history(
1249
+ ReceivedMessage(content="start working", from_id="human")
1250
+ )
1251
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1252
+ return
1253
+ assistant.request_termination("done")
1254
+
1255
+ def fake_chat(
1256
+ messages,
1257
+ tools=None,
1258
+ on_chunk=None,
1259
+ register_interrupt=None,
1260
+ role_name=None,
1261
+ ):
1262
+ assert on_chunk is not None
1263
+ on_chunk("thinking", "Drafting plan")
1264
+ on_chunk("content", "Working")
1265
+ assert assistant.request_interrupt() is True
1266
+ on_chunk("content", " on the task")
1267
+ raise AssertionError("interrupt should stop streaming before completion")
1268
+
1269
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1270
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1271
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1272
+
1273
+ try:
1274
+ assistant._run()
1275
+ finally:
1276
+ registry.reset()
1277
+
1278
+ history = assistant.get_history_snapshot()
1279
+
1280
+ assert any(
1281
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1282
+ for entry in history
1283
+ )
1284
+ assert any(
1285
+ isinstance(entry, AssistantText) and entry.content == "Working"
1286
+ for entry in history
1287
+ )
1288
+ assert any(
1289
+ event.type == EventType.NODE_STATE_CHANGED
1290
+ and event.data.get("new_state") == "idle"
1291
+ for event in events
1292
+ )
1293
+
1294
+
1295
+ def test_agent_normalizes_think_tags_in_streaming_content(monkeypatch):
1296
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1297
+ wait_calls = 0
1298
+ events = []
1299
+ llm_calls = 0
1300
+
1301
+ def fake_wait_for_input() -> None:
1302
+ nonlocal wait_calls
1303
+ wait_calls += 1
1304
+ if wait_calls == 1:
1305
+ assistant._append_history(
1306
+ ReceivedMessage(content="reply to me", from_id="human")
1307
+ )
1308
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1309
+ return
1310
+ assistant.request_termination("done")
1311
+
1312
+ def fake_chat(
1313
+ messages,
1314
+ tools=None,
1315
+ on_chunk=None,
1316
+ register_interrupt=None,
1317
+ role_name=None,
1318
+ ):
1319
+ nonlocal llm_calls
1320
+ llm_calls += 1
1321
+ if llm_calls == 2:
1322
+ assistant.request_termination("done")
1323
+ return LLMResponse()
1324
+ assert on_chunk is not None
1325
+ on_chunk("content", "<think>Drafting plan</think>\nHello there")
1326
+ return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1327
+
1328
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1329
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1330
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1331
+
1332
+ assistant._run()
1333
+
1334
+ history = assistant.get_history_snapshot()
1335
+ assistant_content_events = [
1336
+ event.data.get("content")
1337
+ for event in events
1338
+ if event.type == EventType.ASSISTANT_CONTENT
1339
+ ]
1340
+
1341
+ assert any(
1342
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1343
+ for entry in history
1344
+ )
1345
+ assert any(
1346
+ isinstance(entry, AssistantText) and entry.content == "Hello there"
1347
+ for entry in history
1348
+ )
1349
+ assert assistant_content_events == ["Hello there"]
1350
+
1351
+
1352
+ def test_agent_does_not_duplicate_thinking_when_provider_returns_both(monkeypatch):
1353
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1354
+ wait_calls = 0
1355
+ llm_calls = 0
1356
+
1357
+ def fake_wait_for_input() -> None:
1358
+ nonlocal wait_calls
1359
+ wait_calls += 1
1360
+ if wait_calls == 1:
1361
+ assistant._append_history(
1362
+ ReceivedMessage(content="reply to me", from_id="human")
1363
+ )
1364
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1365
+ return
1366
+ assistant.request_termination("done")
1367
+
1368
+ def fake_chat(
1369
+ messages,
1370
+ tools=None,
1371
+ on_chunk=None,
1372
+ register_interrupt=None,
1373
+ role_name=None,
1374
+ ):
1375
+ nonlocal llm_calls
1376
+ llm_calls += 1
1377
+ if llm_calls == 2:
1378
+ assistant.request_termination("done")
1379
+ return LLMResponse()
1380
+ return LLMResponse(
1381
+ content="<think>Drafting plan</think>\nHello there",
1382
+ thinking="Drafting plan",
1383
+ )
1384
+
1385
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1386
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1387
+
1388
+ assistant._run()
1389
+
1390
+ thinking_entries = [
1391
+ entry
1392
+ for entry in assistant.get_history_snapshot()
1393
+ if isinstance(entry, AssistantThinking)
1394
+ ]
1395
+
1396
+ assert [entry.content for entry in thinking_entries] == ["Drafting plan"]
1397
+
1398
+
1399
+ def test_request_sleep_raises_interrupt_when_running_agent_is_interrupted():
1400
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1401
+ agent.set_state(AgentState.RUNNING, "sleeping")
1402
+ assert agent.request_interrupt() is True
1403
+
1404
+ try:
1405
+ agent.request_sleep(seconds=0.2)
1406
+ except InterruptRequestedError:
1407
+ pass
1408
+ else:
1409
+ raise AssertionError("expected interrupt during sleep")
1410
+
1411
+
1412
+ def test_request_sleep_wakes_early_when_new_message_arrives():
1413
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1414
+ agent.set_state(AgentState.RUNNING, "processing")
1415
+
1416
+ def enqueue_message() -> None:
1417
+ time.sleep(0.02)
1418
+ agent.enqueue_message(
1419
+ Message(from_id="tester", to_id=agent.uuid, content="wake up")
1420
+ )
1421
+
1422
+ wake_thread = threading.Thread(target=enqueue_message, daemon=True)
1423
+ wake_thread.start()
1424
+
1425
+ result = agent.request_sleep(seconds=0.3)
1426
+
1427
+ wake_thread.join(timeout=1.0)
1428
+
1429
+ assert result.startswith("woken by message after ")
1430
+ assert agent.state == AgentState.RUNNING
1431
+ received_entries = [
1432
+ entry
1433
+ for entry in agent.get_history_snapshot()
1434
+ if isinstance(entry, ReceivedMessage)
1435
+ ]
1436
+ assert len(received_entries) == 1
1437
+ assert received_entries[0].content == "wake up"
1438
+ assert [
1439
+ entry.state
1440
+ for entry in agent.get_history_snapshot()
1441
+ if isinstance(entry, StateEntry)
1442
+ ][-2:] == ["sleeping", "running"]
1443
+
1444
+
1445
+ def test_request_sleep_timeout_queues_deadline_notice():
1446
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1447
+ agent.set_state(AgentState.RUNNING, "processing")
1448
+
1449
+ result = agent.request_sleep(seconds=0.01)
1450
+
1451
+ assert result.startswith("slept ")
1452
+ assert agent.state == AgentState.RUNNING
1453
+ assert agent._consume_runtime_notices() == [agent._build_sleep_deadline_notice()]
1454
+ assert [
1455
+ entry.state
1456
+ for entry in agent.get_history_snapshot()
1457
+ if isinstance(entry, StateEntry)
1458
+ ][-2:] == ["sleeping", "running"]
1459
+
1460
+
1461
+ def test_agent_interrupts_blocked_provider_without_streaming_output(monkeypatch):
1462
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1463
+ registry.reset()
1464
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1465
+ registry.register(assistant)
1466
+ events = []
1467
+ wait_calls = 0
1468
+ provider_started = threading.Event()
1469
+
1470
+ def fake_wait_for_input() -> None:
1471
+ nonlocal wait_calls
1472
+ wait_calls += 1
1473
+ if wait_calls == 1:
1474
+ assistant._append_history(
1475
+ ReceivedMessage(content="start working", from_id="human")
1476
+ )
1477
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1478
+ return
1479
+ assistant.request_termination("done")
1480
+
1481
+ def fake_chat(
1482
+ messages,
1483
+ tools=None,
1484
+ on_chunk=None,
1485
+ register_interrupt=None,
1486
+ role_name=None,
1487
+ ):
1488
+ closed = threading.Event()
1489
+ assert register_interrupt is not None
1490
+ register_interrupt(closed.set)
1491
+ provider_started.set()
1492
+ while not closed.wait(0.01):
1493
+ continue
1494
+ raise RuntimeError("stream closed")
1495
+
1496
+ def request_interrupt() -> None:
1497
+ provider_started.wait(timeout=1.0)
1498
+ assistant.request_interrupt()
1499
+
1500
+ interrupter = threading.Thread(target=request_interrupt, daemon=True)
1501
+ interrupter.start()
1502
+
1503
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1504
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1505
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1506
+
1507
+ try:
1508
+ assistant._run()
1509
+ finally:
1510
+ registry.reset()
1511
+
1512
+ interrupter.join(timeout=1.0)
1513
+
1514
+ assert any(
1515
+ event.type == EventType.NODE_STATE_CHANGED
1516
+ and event.data.get("new_state") == "idle"
1517
+ for event in events
1518
+ )
1519
+
1520
+
1521
+ def test_provider_resolution_error_is_recorded_in_history(monkeypatch):
1522
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-y")
1523
+ wait_calls = 0
1524
+
1525
+ def fake_wait_for_input() -> None:
1526
+ nonlocal wait_calls
1527
+ wait_calls += 1
1528
+ if wait_calls == 1:
1529
+ agent._append_history(
1530
+ ReceivedMessage(content="do the task", from_id="tester")
1531
+ )
1532
+ agent.set_state(AgentState.RUNNING, "received message from tester")
1533
+ return
1534
+ agent.request_termination("stop")
1535
+
1536
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1537
+ monkeypatch.setattr(
1538
+ "flowent.agent.gateway.chat",
1539
+ lambda messages, tools=None, on_chunk=None, register_interrupt=None, role_name=None: (
1540
+ (_ for _ in ()).throw(RuntimeError("No active provider configured"))
1541
+ ),
1542
+ )
1543
+
1544
+ agent._run()
1545
+
1546
+ assert wait_calls == 2
1547
+ assert agent.state == AgentState.TERMINATED
1548
+ assert any(
1549
+ isinstance(entry, ErrorEntry)
1550
+ and "No active provider configured" in entry.content
1551
+ for entry in agent.get_history_snapshot()
1552
+ )
1553
+
1554
+
1555
+ def test_assistant_content_streams_even_when_response_has_tool_calls(monkeypatch):
1556
+ registry.reset()
1557
+ assistant = Agent(
1558
+ NodeConfig(node_type=NodeType.ASSISTANT, tools=["idle"]),
1559
+ )
1560
+ registry.register(assistant)
1561
+ events = []
1562
+
1563
+ def fake_wait_for_input() -> None:
1564
+ assistant._append_history(
1565
+ ReceivedMessage(content="report progress", from_id="human")
1566
+ )
1567
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1568
+
1569
+ def fake_chat(
1570
+ messages,
1571
+ tools=None,
1572
+ on_chunk=None,
1573
+ register_interrupt=None,
1574
+ role_name=None,
1575
+ ):
1576
+ if on_chunk is not None:
1577
+ on_chunk("content", "Working on it")
1578
+ return LLMResponse(
1579
+ content="Working on it",
1580
+ tool_calls=[
1581
+ ToolCallResult(
1582
+ id="call-idle",
1583
+ name="idle",
1584
+ arguments={},
1585
+ )
1586
+ ],
1587
+ )
1588
+
1589
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1590
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1591
+ monkeypatch.setattr(
1592
+ assistant,
1593
+ "_handle_tool_call",
1594
+ lambda name, arguments, call_id: assistant.request_termination("done"),
1595
+ )
1596
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1597
+
1598
+ assistant._run()
1599
+
1600
+ assistant_events = [
1601
+ event for event in events if event.type == EventType.ASSISTANT_CONTENT
1602
+ ]
1603
+ assert [event.data for event in assistant_events] == [{"content": "Working on it"}]
1604
+
1605
+
1606
+ def test_send_message_delivers_to_single_contact_and_records_histories(monkeypatch):
1607
+ registry.reset()
1608
+ leader = _register_tab_leader()
1609
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1610
+ registry.register(child)
1611
+ events = []
1612
+
1613
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1614
+
1615
+ try:
1616
+ result = json.loads(
1617
+ child.send_message(
1618
+ target_ref="leader",
1619
+ raw_parts=[{"type": "text", "text": "investigate the error"}],
1620
+ )
1621
+ )
1622
+ finally:
1623
+ registry.reset()
1624
+
1625
+ sent_entry = next(
1626
+ entry
1627
+ for entry in child.get_history_snapshot()
1628
+ if isinstance(entry, SentMessage)
1629
+ )
1630
+ received_entry = next(
1631
+ entry
1632
+ for entry in leader.get_history_snapshot()
1633
+ if isinstance(entry, ReceivedMessage)
1634
+ )
1635
+ signal = leader._wake_queue.get_nowait()
1636
+
1637
+ assert result == {"status": "sent", "target_id": "leader"}
1638
+ assert sent_entry.to_id == "leader"
1639
+ assert sent_entry.content == "investigate the error"
1640
+ assert received_entry.from_id == "child"
1641
+ assert received_entry.content == "investigate the error"
1642
+ assert sent_entry.message_id == received_entry.message_id
1643
+ assert signal.payload == {
1644
+ "message": {
1645
+ "from": "child",
1646
+ "content": "investigate the error",
1647
+ "parts": [{"type": "text", "text": "investigate the error"}],
1648
+ "history_recorded": True,
1649
+ "message_id": sent_entry.message_id,
1650
+ }
1651
+ }
1652
+ assert [event.data for event in events if event.type == EventType.NODE_MESSAGE] == [
1653
+ {
1654
+ "to_id": "leader",
1655
+ "content": "investigate the error",
1656
+ "message_id": sent_entry.message_id,
1657
+ }
1658
+ ]
1659
+
1660
+
1661
+ def test_send_message_reports_error_when_target_is_not_in_contacts():
1662
+ registry.reset()
1663
+ _register_tab_leader()
1664
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1665
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1666
+ registry.register(child)
1667
+ registry.register(peer)
1668
+
1669
+ try:
1670
+ with pytest.raises(
1671
+ ValueError,
1672
+ match=r"Send failed: target `peer` is not in contacts\.",
1673
+ ):
1674
+ child.send_message(
1675
+ target_ref="peer",
1676
+ raw_parts=[{"type": "text", "text": "reply with the findings"}],
1677
+ )
1678
+ finally:
1679
+ registry.reset()
1680
+
1681
+
1682
+ def test_send_message_validates_target_before_image_capability():
1683
+ registry.reset()
1684
+ _register_tab_leader()
1685
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1686
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1687
+ registry.register(child)
1688
+ registry.register(peer)
1689
+
1690
+ try:
1691
+ with pytest.raises(
1692
+ ValueError,
1693
+ match=r"Send failed: target `peer` is not in contacts\.",
1694
+ ):
1695
+ child.send_message(
1696
+ target_ref="peer",
1697
+ raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1698
+ )
1699
+ finally:
1700
+ registry.reset()
1701
+
1702
+
1703
+ def test_send_message_reports_error_when_target_lacks_input_image_support():
1704
+ registry.reset()
1705
+ _register_tab_leader()
1706
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1707
+ registry.register(child)
1708
+
1709
+ try:
1710
+ with pytest.raises(
1711
+ ValueError,
1712
+ match=r"Send failed: target `leader` does not support `input_image`\.",
1713
+ ):
1714
+ child.send_message(
1715
+ target_ref="leader",
1716
+ raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1717
+ )
1718
+ finally:
1719
+ registry.reset()
1720
+
1721
+
1722
+ def test_record_content_output_treats_target_like_text_as_plain_output(monkeypatch):
1723
+ registry.reset()
1724
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1725
+ registry.register(assistant)
1726
+ events = []
1727
+
1728
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1729
+
1730
+ try:
1731
+ assistant._record_content_output(
1732
+ "@worker: do the follow-up task",
1733
+ emitted_human_content=False,
1734
+ )
1735
+ finally:
1736
+ registry.reset()
1737
+
1738
+ history = assistant.get_history_snapshot()
1739
+ assert isinstance(history[-1], AssistantText)
1740
+ assert history[-1].content == "@worker: do the follow-up task"
1741
+ assert not any(isinstance(entry, SentMessage) for entry in history)
1742
+ assert any(
1743
+ event.type == EventType.ASSISTANT_CONTENT
1744
+ and event.data == {"content": "@worker: do the follow-up task"}
1745
+ for event in events
1746
+ )
1747
+
1748
+
1749
+ def test_handle_tool_call_send_success_omits_toolcall_history(monkeypatch):
1750
+ registry.reset()
1751
+ _register_tab_leader()
1752
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1753
+ registry.register(child)
1754
+
1755
+ try:
1756
+ result = child._handle_tool_call(
1757
+ "send",
1758
+ {
1759
+ "target": "leader",
1760
+ "parts": [{"type": "text", "text": "reply with the findings"}],
1761
+ },
1762
+ "call-send",
1763
+ )
1764
+ finally:
1765
+ registry.reset()
1766
+
1767
+ assert json.loads(result) == {"status": "sent", "target_id": "leader"}
1768
+ assert not any(
1769
+ isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
1770
+ for entry in child.get_history_snapshot()
1771
+ )
1772
+ assert any(isinstance(entry, SentMessage) for entry in child.get_history_snapshot())
1773
+
1774
+
1775
+ def test_handle_tool_call_send_failure_records_error_without_toolcall():
1776
+ registry.reset()
1777
+ _register_tab_leader()
1778
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1779
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1780
+ registry.register(child)
1781
+ registry.register(peer)
1782
+
1783
+ try:
1784
+ result = child._handle_tool_call(
1785
+ "send",
1786
+ {
1787
+ "target": "peer",
1788
+ "parts": [{"type": "text", "text": "reply with the findings"}],
1789
+ },
1790
+ "call-send",
1791
+ )
1792
+ finally:
1793
+ registry.reset()
1794
+
1795
+ assert json.loads(result) == {
1796
+ "error": "Send failed: target `peer` is not in contacts."
1797
+ }
1798
+ assert not any(
1799
+ isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
1800
+ for entry in child.get_history_snapshot()
1801
+ )
1802
+ assert any(
1803
+ isinstance(entry, ErrorEntry)
1804
+ and entry.content == "Send failed: target `peer` is not in contacts."
1805
+ for entry in child.get_history_snapshot()
1806
+ )
1807
+
1808
+
1809
+ def test_multiple_send_tool_calls_stop_after_first_failure(monkeypatch):
1810
+ registry.reset()
1811
+ leader = _register_tab_leader()
1812
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1813
+ helper = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="helper")
1814
+ registry.register(child)
1815
+ registry.register(helper)
1816
+
1817
+ wait_calls = 0
1818
+ chat_calls = 0
1819
+
1820
+ def fake_wait_for_input() -> None:
1821
+ nonlocal wait_calls
1822
+ wait_calls += 1
1823
+ if wait_calls == 1:
1824
+ child._append_history(ReceivedMessage(content="begin", from_id="human"))
1825
+ child.set_state(AgentState.RUNNING, "received message from human")
1826
+ return
1827
+ child.request_termination("done")
1828
+
1829
+ def fake_chat(
1830
+ messages,
1831
+ tools=None,
1832
+ on_chunk=None,
1833
+ register_interrupt=None,
1834
+ role_name=None,
1835
+ ):
1836
+ nonlocal chat_calls
1837
+ chat_calls += 1
1838
+ if chat_calls == 1:
1839
+ return LLMResponse(
1840
+ tool_calls=[
1841
+ ToolCallResult(
1842
+ id="call-send-1",
1843
+ name="send",
1844
+ arguments={
1845
+ "target": "leader",
1846
+ "parts": [{"type": "text", "text": "first"}],
1847
+ },
1848
+ ),
1849
+ ToolCallResult(
1850
+ id="call-send-2",
1851
+ name="send",
1852
+ arguments={
1853
+ "target": "helper",
1854
+ "parts": [{"type": "text", "text": "second"}],
1855
+ },
1856
+ ),
1857
+ ToolCallResult(
1858
+ id="call-send-3",
1859
+ name="send",
1860
+ arguments={
1861
+ "target": "leader",
1862
+ "parts": [{"type": "text", "text": "third"}],
1863
+ },
1864
+ ),
1865
+ ]
1866
+ )
1867
+ child.request_termination("done")
1868
+ return LLMResponse()
1869
+
1870
+ monkeypatch.setattr(child, "_wait_for_input", fake_wait_for_input)
1871
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1872
+
1873
+ try:
1874
+ child._run()
1875
+ finally:
1876
+ registry.reset()
1877
+
1878
+ sent_entries = [
1879
+ entry
1880
+ for entry in child.get_history_snapshot()
1881
+ if isinstance(entry, SentMessage)
1882
+ ]
1883
+ error_entries = [
1884
+ entry for entry in child.get_history_snapshot() if isinstance(entry, ErrorEntry)
1885
+ ]
1886
+
1887
+ assert [entry.content for entry in sent_entries] == ["first"]
1888
+ assert [
1889
+ entry.content
1890
+ for entry in leader.get_history_snapshot()
1891
+ if isinstance(entry, ReceivedMessage)
1892
+ ] == ["first"]
1893
+ assert helper._wake_queue.empty()
1894
+ assert any(
1895
+ entry.content == "Send failed: target `helper` is not in contacts."
1896
+ for entry in error_entries
1897
+ )
1898
+
1899
+
1900
+ def test_build_messages_replays_sent_messages_as_message_to_context(monkeypatch):
1901
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1902
+
1903
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
1904
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
1905
+ agent._append_history(SentMessage(content="to peer", to_id="peer"))
1906
+ agent._append_history(AssistantText(content="final answer"))
1907
+
1908
+ messages = agent._build_messages()
1909
+
1910
+ assert messages == [
1911
+ {"role": "system", "content": messages[0]["content"]},
1912
+ {"role": "user", "content": '<message from="human">begin</message>'},
1913
+ {"role": "assistant", "content": '<message to="peer">to peer</message>'},
1914
+ {"role": "assistant", "content": "final answer"},
1915
+ {
1916
+ "role": "user",
1917
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
1918
+ },
1919
+ ]
1920
+
1921
+
1922
+ def test_context_preflight_prefers_usage_baseline_and_estimates_only_new_tail(
1923
+ monkeypatch,
1924
+ ):
1925
+ monkeypatch.setattr(
1926
+ "flowent.agent.get_settings",
1927
+ lambda: Settings(
1928
+ providers=[
1929
+ ProviderConfig(
1930
+ id="provider-1",
1931
+ name="Primary",
1932
+ type="openai_responses",
1933
+ base_url="https://api.example.com/v1",
1934
+ api_key="secret",
1935
+ )
1936
+ ],
1937
+ model=ModelSettings(
1938
+ active_provider_id="provider-1",
1939
+ active_model="gpt-5.2",
1940
+ auto_compact_token_limit=48_000,
1941
+ ),
1942
+ ),
1943
+ )
1944
+
1945
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1946
+ agent._append_history(ReceivedMessage(content="first", from_id="human"))
1947
+
1948
+ baseline_context = agent._build_prepared_llm_context()
1949
+ agent._record_context_token_usage_baseline(
1950
+ prepared_context=baseline_context,
1951
+ usage=LLMUsage(
1952
+ total_tokens=4_200,
1953
+ input_tokens=3_000,
1954
+ output_tokens=1_200,
1955
+ ),
1956
+ )
1957
+
1958
+ agent._append_history(
1959
+ ToolCall(
1960
+ tool_name="read",
1961
+ tool_call_id="call-read",
1962
+ arguments={"path": "README.md"},
1963
+ result="done",
1964
+ )
1965
+ )
1966
+ next_context = agent._build_prepared_llm_context()
1967
+ preflight = agent._compute_context_preflight(next_context)
1968
+
1969
+ expected_tail_tokens = agent._estimate_input_tokens(
1970
+ next_context.execution_context_messages[
1971
+ len(baseline_context.execution_context_messages) :
1972
+ ]
1973
+ )
1974
+
1975
+ assert preflight.estimated_total_tokens == 4_200 + expected_tail_tokens
1976
+ assert preflight.auto_compact_token_limit == 48_000
1977
+ assert preflight.context_window_tokens == 128_000
1978
+
1979
+
1980
+ def test_context_preflight_bootstraps_again_when_runtime_tail_changes(monkeypatch):
1981
+ monkeypatch.setattr(
1982
+ "flowent.agent.get_settings",
1983
+ lambda: Settings(
1984
+ providers=[
1985
+ ProviderConfig(
1986
+ id="provider-1",
1987
+ name="Primary",
1988
+ type="openai_responses",
1989
+ base_url="https://api.example.com/v1",
1990
+ api_key="secret",
1991
+ )
1992
+ ],
1993
+ model=ModelSettings(
1994
+ active_provider_id="provider-1",
1995
+ active_model="gpt-5.2",
1996
+ auto_compact_token_limit=48_000,
1997
+ ),
1998
+ ),
1999
+ )
2000
+
2001
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2002
+ agent._append_history(ReceivedMessage(content="first", from_id="human"))
2003
+
2004
+ baseline_context = agent._build_prepared_llm_context()
2005
+ agent._record_context_token_usage_baseline(
2006
+ prepared_context=baseline_context,
2007
+ usage=LLMUsage(total_tokens=4_200),
2008
+ )
2009
+
2010
+ agent.set_todos([TodoItem(text="Inspect files")])
2011
+ next_context = agent._build_prepared_llm_context()
2012
+ preflight = agent._compute_context_preflight(next_context)
2013
+
2014
+ assert preflight.estimated_total_tokens == agent._estimate_input_tokens(
2015
+ next_context.messages
2016
+ )
2017
+
2018
+
2019
+ def test_prepare_messages_for_llm_uses_token_limit_even_without_context_window(
2020
+ monkeypatch,
2021
+ ):
2022
+ monkeypatch.setattr(
2023
+ "flowent.agent.get_settings",
2024
+ lambda: Settings(
2025
+ providers=[
2026
+ ProviderConfig(
2027
+ id="provider-1",
2028
+ name="Primary",
2029
+ type="openai_compatible",
2030
+ base_url="https://api.example.com/v1",
2031
+ api_key="secret",
2032
+ )
2033
+ ],
2034
+ model=ModelSettings(
2035
+ active_provider_id="provider-1",
2036
+ active_model="custom-model",
2037
+ auto_compact_token_limit=1,
2038
+ ),
2039
+ ),
2040
+ )
2041
+
2042
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2043
+ agent._append_history(ReceivedMessage(content="hello", from_id="human"))
2044
+ compact_calls: list[str] = []
2045
+
2046
+ monkeypatch.setattr(
2047
+ agent,
2048
+ "_compact_execution_context",
2049
+ lambda focus=None: compact_calls.append("compact") or "",
2050
+ )
2051
+
2052
+ prepared_context = agent._prepare_messages_for_llm()
2053
+
2054
+ assert compact_calls == ["compact"]
2055
+ assert len(prepared_context.messages) > 0
2056
+
2057
+
2058
+ def test_idle_is_blocked_when_fresh_input_has_no_progress(monkeypatch):
2059
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2060
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2061
+ agent.set_state(AgentState.RUNNING, "processing")
2062
+ agent._turn_started_with_pending_input = True
2063
+ agent._turn_made_progress = False
2064
+
2065
+ idle_result = agent.request_idle()
2066
+ messages = agent._build_messages()
2067
+
2068
+ reminder = (
2069
+ "<system>Idle reminder: you received a new message this turn, but this "
2070
+ "response did not send a reply, call `send`, or use any non-idle "
2071
+ "tool. Do not call `idle` yet. First reply to the Human, dispatch/"
2072
+ "delegate work, or take another concrete step.</system>"
2073
+ )
2074
+
2075
+ assert idle_result == ""
2076
+ assert agent.state == AgentState.RUNNING
2077
+ assert any(msg.get("content") == reminder for msg in messages)
2078
+
2079
+
2080
+ def test_idle_is_blocked_when_first_todo_is_actionable(monkeypatch):
2081
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2082
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2083
+ agent.set_state(AgentState.RUNNING, "processing")
2084
+ agent.set_todos(
2085
+ [
2086
+ TodoItem(text="Forward pyproject summary to Project Synthesizer"),
2087
+ TodoItem(text="Wait for final synthesis"),
2088
+ ]
2089
+ )
2090
+
2091
+ idle_result = agent.request_idle()
2092
+ messages = agent._build_messages()
2093
+
2094
+ reminder = (
2095
+ "<system>Idle reminder: your first remaining TODO still looks actionable "
2096
+ "(`Forward pyproject summary to Project Synthesizer`). Do that next, or "
2097
+ "update the TODO list so the first remaining item is the actual waiting "
2098
+ "step, before calling `idle`.</system>"
2099
+ )
2100
+
2101
+ assert idle_result == ""
2102
+ assert agent.state == AgentState.RUNNING
2103
+ assert any(msg.get("content") == reminder for msg in messages)
2104
+
2105
+
2106
+ def test_build_messages_appends_runtime_todo_context_without_history_entry(monkeypatch):
2107
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2108
+
2109
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2110
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2111
+ agent.set_todos([TodoItem(text="Inspect files"), TodoItem(text="Report results")])
2112
+
2113
+ messages = agent._build_messages()
2114
+ history = agent.get_history_snapshot()
2115
+
2116
+ received_entries = [
2117
+ entry for entry in history if isinstance(entry, ReceivedMessage)
2118
+ ]
2119
+ assert len(received_entries) == 1
2120
+ assert messages == [
2121
+ {"role": "system", "content": messages[0]["content"]},
2122
+ {"role": "user", "content": '<message from="human">begin</message>'},
2123
+ {
2124
+ "role": "user",
2125
+ "content": "<system>Current TODO list:\n - Inspect files\n - Report results</system>",
2126
+ },
2127
+ {
2128
+ "role": "user",
2129
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items.</system>",
2130
+ },
2131
+ ]
2132
+
2133
+
2134
+ def test_build_messages_appends_runtime_post_prompt_and_idle_guidance(monkeypatch):
2135
+ monkeypatch.setattr(
2136
+ "flowent.agent.get_settings",
2137
+ lambda: Settings(custom_post_prompt="Append this after history."),
2138
+ )
2139
+
2140
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2141
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2142
+ agent.set_todos([TodoItem(text="Inspect files")])
2143
+ agent.set_todos([])
2144
+
2145
+ messages = agent._build_messages()
2146
+ history = agent.get_history_snapshot()
2147
+
2148
+ received_entries = [
2149
+ entry for entry in history if isinstance(entry, ReceivedMessage)
2150
+ ]
2151
+ assert len(received_entries) == 1
2152
+ assert messages == [
2153
+ {"role": "system", "content": messages[0]["content"]},
2154
+ {"role": "user", "content": '<message from="human">begin</message>'},
2155
+ {
2156
+ "role": "user",
2157
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
2158
+ },
2159
+ {
2160
+ "role": "user",
2161
+ "content": "<system>Append this after history.</system>",
2162
+ },
2163
+ ]
2164
+
2165
+
2166
+ def test_build_messages_warns_about_newly_created_agents_waiting_for_first_task(
2167
+ monkeypatch,
2168
+ ):
2169
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2170
+
2171
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2172
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2173
+ agent._append_history(
2174
+ ToolCall(
2175
+ tool_name="create_agent",
2176
+ tool_call_id="call-create-agent",
2177
+ arguments={
2178
+ "role_name": "Worker",
2179
+ "name": "Directory Worker",
2180
+ },
2181
+ result=json.dumps(
2182
+ {
2183
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2184
+ "config": {
2185
+ "node_type": "agent",
2186
+ "role_name": "Worker",
2187
+ "tab_id": "tab-1",
2188
+ "name": "Directory Worker",
2189
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2190
+ "write_dirs": [],
2191
+ "allow_network": False,
2192
+ },
2193
+ "state": "initializing",
2194
+ "todos": [],
2195
+ "history": [],
2196
+ "position": None,
2197
+ "created_at": 1.0,
2198
+ "updated_at": 1.0,
2199
+ }
2200
+ ),
2201
+ )
2202
+ )
2203
+
2204
+ messages = agent._build_messages()
2205
+
2206
+ assert messages == [
2207
+ {"role": "system", "content": messages[0]["content"]},
2208
+ {"role": "user", "content": '<message from="human">begin</message>'},
2209
+ {
2210
+ "role": "assistant",
2211
+ "tool_calls": [
2212
+ {
2213
+ "id": "call-create-agent",
2214
+ "type": "function",
2215
+ "function": {
2216
+ "name": "create_agent",
2217
+ "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2218
+ },
2219
+ }
2220
+ ],
2221
+ },
2222
+ {
2223
+ "role": "tool",
2224
+ "tool_call_id": "call-create-agent",
2225
+ "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2226
+ },
2227
+ {
2228
+ "role": "user",
2229
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Directory Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
2230
+ },
2231
+ ]
2232
+
2233
+
2234
+ def test_build_messages_uses_role_name_when_created_agent_has_no_explicit_name(
2235
+ monkeypatch,
2236
+ ):
2237
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2238
+
2239
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2240
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2241
+ agent._append_history(
2242
+ ToolCall(
2243
+ tool_name="create_agent",
2244
+ tool_call_id="call-create-agent",
2245
+ arguments={"role_name": "Worker"},
2246
+ result=json.dumps(
2247
+ {
2248
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2249
+ "config": {
2250
+ "node_type": "agent",
2251
+ "role_name": "Worker",
2252
+ "tab_id": "tab-1",
2253
+ "name": None,
2254
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2255
+ "write_dirs": [],
2256
+ "allow_network": False,
2257
+ },
2258
+ "state": "initializing",
2259
+ "todos": [],
2260
+ "history": [],
2261
+ "position": None,
2262
+ "created_at": 1.0,
2263
+ "updated_at": 1.0,
2264
+ }
2265
+ ),
2266
+ )
2267
+ )
2268
+
2269
+ messages = agent._build_messages()
2270
+
2271
+ assert messages == [
2272
+ {"role": "system", "content": messages[0]["content"]},
2273
+ {"role": "user", "content": '<message from="human">begin</message>'},
2274
+ {
2275
+ "role": "assistant",
2276
+ "tool_calls": [
2277
+ {
2278
+ "id": "call-create-agent",
2279
+ "type": "function",
2280
+ "function": {
2281
+ "name": "create_agent",
2282
+ "arguments": '{"role_name": "Worker"}',
2283
+ },
2284
+ }
2285
+ ],
2286
+ },
2287
+ {
2288
+ "role": "tool",
2289
+ "tool_call_id": "call-create-agent",
2290
+ "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": null, "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2291
+ },
2292
+ {
2293
+ "role": "user",
2294
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
2295
+ },
2296
+ ]
2297
+
2298
+
2299
+ def test_build_messages_clears_new_agent_warning_after_first_sent_message(monkeypatch):
2300
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2301
+
2302
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2303
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2304
+ agent._append_history(
2305
+ ToolCall(
2306
+ tool_name="create_agent",
2307
+ tool_call_id="call-create-agent",
2308
+ arguments={
2309
+ "role_name": "Worker",
2310
+ "name": "Directory Worker",
2311
+ },
2312
+ result=json.dumps(
2313
+ {
2314
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2315
+ "config": {
2316
+ "node_type": "agent",
2317
+ "role_name": "Worker",
2318
+ "tab_id": "tab-1",
2319
+ "name": "Directory Worker",
2320
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2321
+ "write_dirs": [],
2322
+ "allow_network": False,
2323
+ },
2324
+ "state": "initializing",
2325
+ "todos": [],
2326
+ "history": [],
2327
+ "position": None,
2328
+ "created_at": 1.0,
2329
+ "updated_at": 1.0,
2330
+ }
2331
+ ),
2332
+ )
2333
+ )
2334
+ agent._append_history(
2335
+ SentMessage(
2336
+ content="inspect the current directory",
2337
+ to_id="12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2338
+ )
2339
+ )
2340
+
2341
+ messages = agent._build_messages()
2342
+
2343
+ assert messages == [
2344
+ {"role": "system", "content": messages[0]["content"]},
2345
+ {"role": "user", "content": '<message from="human">begin</message>'},
2346
+ {
2347
+ "role": "assistant",
2348
+ "tool_calls": [
2349
+ {
2350
+ "id": "call-create-agent",
2351
+ "type": "function",
2352
+ "function": {
2353
+ "name": "create_agent",
2354
+ "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2355
+ },
2356
+ }
2357
+ ],
2358
+ },
2359
+ {
2360
+ "role": "tool",
2361
+ "tool_call_id": "call-create-agent",
2362
+ "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2363
+ },
2364
+ {
2365
+ "role": "assistant",
2366
+ "content": '<message to="12345678-aaaa-bbbb-cccc-ddddeeeeffff">inspect the current directory</message>',
2367
+ },
2368
+ {
2369
+ "role": "user",
2370
+ "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.\n- Use `contacts` to inspect the node ids and names you can currently message directly.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
2371
+ },
2372
+ ]
2373
+
2374
+
2375
+ def test_build_messages_keeps_sleep_tool_results_in_context(monkeypatch):
2376
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2377
+
2378
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2379
+ agent._append_history(
2380
+ ReceivedMessage(content="pause before continuing", from_id="human")
2381
+ )
2382
+ agent._append_history(
2383
+ ToolCall(
2384
+ tool_name="sleep",
2385
+ tool_call_id="call-sleep",
2386
+ arguments={"seconds": 0.5},
2387
+ result="slept 0.50s",
2388
+ )
2389
+ )
2390
+
2391
+ messages = agent._build_messages()
2392
+
2393
+ assert any(
2394
+ msg.get("role") == "assistant"
2395
+ and msg.get("tool_calls")
2396
+ == [
2397
+ {
2398
+ "id": "call-sleep",
2399
+ "type": "function",
2400
+ "function": {
2401
+ "name": "sleep",
2402
+ "arguments": '{"seconds": 0.5}',
2403
+ },
2404
+ }
2405
+ ]
2406
+ for msg in messages
2407
+ )
2408
+ assert any(
2409
+ msg.get("role") == "tool"
2410
+ and msg.get("tool_call_id") == "call-sleep"
2411
+ and msg.get("content") == "slept 0.50s"
2412
+ for msg in messages
2413
+ )
2414
+
2415
+
2416
+ def test_build_messages_keeps_idle_tool_results_in_context(monkeypatch):
2417
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2418
+
2419
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2420
+ agent._append_history(ReceivedMessage(content="resume after wait", from_id="human"))
2421
+ agent._append_history(
2422
+ ToolCall(
2423
+ tool_name="idle",
2424
+ tool_call_id="call-idle",
2425
+ arguments={},
2426
+ result="idle 1.25s",
2427
+ )
2428
+ )
2429
+
2430
+ messages = agent._build_messages()
2431
+
2432
+ assert any(
2433
+ msg.get("role") == "assistant"
2434
+ and msg.get("tool_calls")
2435
+ == [
2436
+ {
2437
+ "id": "call-idle",
2438
+ "type": "function",
2439
+ "function": {
2440
+ "name": "idle",
2441
+ "arguments": "{}",
2442
+ },
2443
+ }
2444
+ ]
2445
+ for msg in messages
2446
+ )
2447
+ assert any(
2448
+ msg.get("role") == "tool"
2449
+ and msg.get("tool_call_id") == "call-idle"
2450
+ and msg.get("content") == "idle 1.25s"
2451
+ for msg in messages
2452
+ )
2453
+
2454
+
2455
+ def test_build_messages_keeps_error_entries_in_context(monkeypatch):
2456
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2457
+
2458
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2459
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2460
+ agent._append_history(ErrorEntry(content="RuntimeError: boom\n\ntraceback"))
2461
+
2462
+ messages = agent._build_messages()
2463
+
2464
+ assert any(
2465
+ msg.get("role") == "user"
2466
+ and msg.get("content")
2467
+ == "<system>Previous runtime error:\nRuntimeError: boom\n\ntraceback</system>"
2468
+ for msg in messages
2469
+ )
2470
+
2471
+
2472
+ def test_assistant_emits_human_content_for_plain_text_with_target_like_prefix(
2473
+ monkeypatch,
2474
+ ):
2475
+ registry.reset()
2476
+ _register_tab_leader()
2477
+ assistant = Agent(
2478
+ NodeConfig(node_type=NodeType.ASSISTANT),
2479
+ uuid="assistant",
2480
+ )
2481
+ registry.register(assistant)
2482
+ events = []
2483
+ responses = iter(
2484
+ [LLMResponse(content="@leader: investigate the error"), LLMResponse()]
2485
+ )
2486
+
2487
+ def fake_wait_for_input() -> None:
2488
+ assistant._append_history(
2489
+ ReceivedMessage(content="please investigate", from_id="human")
2490
+ )
2491
+ assistant.set_state(AgentState.RUNNING, "received message from human")
2492
+
2493
+ def fake_chat(
2494
+ messages,
2495
+ tools=None,
2496
+ on_chunk=None,
2497
+ register_interrupt=None,
2498
+ role_name=None,
2499
+ ):
2500
+ response = next(responses)
2501
+ if response.content and on_chunk is not None:
2502
+ on_chunk("content", response.content)
2503
+ if response.content is None:
2504
+ assistant.request_termination("done")
2505
+ return response
2506
+
2507
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
2508
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
2509
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2510
+
2511
+ try:
2512
+ assistant._run()
2513
+ finally:
2514
+ registry.reset()
2515
+
2516
+ assert any(event.type == EventType.ASSISTANT_CONTENT for event in events)
2517
+ assert not any(
2518
+ isinstance(entry, SentMessage) for entry in assistant.get_history_snapshot()
2519
+ )
2520
+ assert any(
2521
+ isinstance(entry, AssistantText)
2522
+ and entry.content == "@leader: investigate the error"
2523
+ for entry in assistant.get_history_snapshot()
2524
+ )
2525
+
2526
+
2527
+ def test_idle_tool_records_wakeup_message_as_new_input_block(monkeypatch):
2528
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT, tools=["idle"]))
2529
+ wait_calls = 0
2530
+ llm_messages: list[list[dict]] = []
2531
+ responses = iter(
2532
+ [
2533
+ LLMResponse(
2534
+ tool_calls=[
2535
+ ToolCallResult(
2536
+ id="call-idle",
2537
+ name="idle",
2538
+ arguments={},
2539
+ )
2540
+ ]
2541
+ ),
2542
+ LLMResponse(),
2543
+ ]
2544
+ )
2545
+
2546
+ def fake_wait_for_input() -> None:
2547
+ nonlocal wait_calls
2548
+ wait_calls += 1
2549
+ agent._append_history(
2550
+ ReceivedMessage(content="start waiting", from_id="tester")
2551
+ )
2552
+ agent.set_state(AgentState.RUNNING, "received message from tester")
2553
+
2554
+ def fake_chat(
2555
+ messages,
2556
+ tools=None,
2557
+ on_chunk=None,
2558
+ register_interrupt=None,
2559
+ role_name=None,
2560
+ ):
2561
+ llm_messages.append(messages)
2562
+ if len(llm_messages) == 1:
2563
+ timer = threading.Timer(
2564
+ 0.01,
2565
+ lambda: agent.enqueue_message(
2566
+ Message(
2567
+ from_id="human",
2568
+ to_id=agent.uuid,
2569
+ content="wake up now",
2570
+ )
2571
+ ),
2572
+ )
2573
+ timer.start()
2574
+ if len(llm_messages) == 2:
2575
+ agent.request_termination("done")
2576
+ return next(responses)
2577
+
2578
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2579
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2580
+
2581
+ agent._run()
2582
+
2583
+ assert wait_calls == 1
2584
+ assert agent.state == AgentState.TERMINATED
2585
+ second_round = llm_messages[1]
2586
+ assert any(
2587
+ msg.get("role") == "assistant"
2588
+ and msg.get("tool_calls")
2589
+ == [
2590
+ {
2591
+ "id": "call-idle",
2592
+ "type": "function",
2593
+ "function": {
2594
+ "name": "idle",
2595
+ "arguments": "{}",
2596
+ },
2597
+ }
2598
+ ]
2599
+ for msg in second_round
2600
+ )
2601
+ assert any(
2602
+ msg.get("role") == "tool"
2603
+ and msg.get("tool_call_id") == "call-idle"
2604
+ and isinstance(msg.get("content"), str)
2605
+ and msg.get("content", "").startswith("idle ")
2606
+ for msg in second_round
2607
+ )
2608
+ assert any(
2609
+ msg.get("role") == "user"
2610
+ and msg.get("content") == '<message from="human">wake up now</message>'
2611
+ for msg in second_round
2612
+ )
2613
+ assert any(
2614
+ isinstance(entry, ToolCall)
2615
+ and entry.tool_name == "idle"
2616
+ and isinstance(entry.result, str)
2617
+ and entry.result.startswith("idle ")
2618
+ for entry in agent.get_history_snapshot()
2619
+ )
2620
+
2621
+
2622
+ def test_agent_contextualizes_plain_loguru_calls(monkeypatch):
2623
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-z")
2624
+ captured: list[tuple[str, str | None]] = []
2625
+ sink_id = logger.add(
2626
+ lambda message: captured.append(
2627
+ (message.record["message"], message.record["extra"].get("agent_id"))
2628
+ )
2629
+ )
2630
+
2631
+ def fake_wait_for_input() -> None:
2632
+ agent._append_history(ReceivedMessage(content="do the task", from_id="tester"))
2633
+ agent.set_state(AgentState.RUNNING, "received message from tester")
2634
+
2635
+ def fake_chat(
2636
+ messages,
2637
+ tools=None,
2638
+ on_chunk=None,
2639
+ register_interrupt=None,
2640
+ role_name=None,
2641
+ ):
2642
+ logger.info("plain log inside agent")
2643
+ agent.request_termination("done")
2644
+ return LLMResponse()
2645
+
2646
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2647
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2648
+
2649
+ try:
2650
+ agent._run()
2651
+ finally:
2652
+ logger.remove(sink_id)
2653
+
2654
+ assert ("plain log inside agent", "agent-z") in captured
2655
+
2656
+
2657
+ def test_agent_denies_tool_call_before_edit_execute(monkeypatch, tmp_path):
2658
+ agent = Agent(
2659
+ NodeConfig(node_type=NodeType.AGENT, tools=["edit"]),
2660
+ uuid="agent-security",
2661
+ )
2662
+
2663
+ def fail_execute(*_args, **_kwargs):
2664
+ raise AssertionError("edit execute should not be called")
2665
+
2666
+ monkeypatch.setattr("flowent.tools.edit.EditTool.execute", fail_execute)
2667
+
2668
+ result = agent._handle_tool_call(
2669
+ "edit",
2670
+ {
2671
+ "path": str(tmp_path / "blocked.txt"),
2672
+ "edits": [
2673
+ {
2674
+ "start_line": 1,
2675
+ "end_line": 1,
2676
+ "new_content": "hello\n",
2677
+ }
2678
+ ],
2679
+ },
2680
+ "call-edit",
2681
+ )
2682
+
2683
+ assert result == json.dumps({"error": "Write access is disabled for this agent"})
2684
+ assert isinstance(agent.history[-1], ToolCall)
2685
+ assert agent.history[-1].result == result
2686
+
2687
+
2688
+ def test_handle_tool_call_emits_streaming_tool_result_deltas(monkeypatch):
2689
+ agent = Agent(
2690
+ NodeConfig(node_type=NodeType.AGENT, tools=["streaming_tool"]),
2691
+ uuid="agent-stream",
2692
+ )
2693
+ events = []
2694
+
2695
+ class FakeTool:
2696
+ def execute(self, agent, args, **kwargs):
2697
+ on_output = kwargs.get("on_output")
2698
+ assert on_output is not None
2699
+ on_output("chunk 1\n")
2700
+ on_output("chunk 2\n")
2701
+ return json.dumps({"status": "done"})
2702
+
2703
+ class FakeRegistry:
2704
+ def get(self, name):
2705
+ if name == "streaming_tool":
2706
+ return FakeTool()
2707
+ return None
2708
+
2709
+ monkeypatch.setattr("flowent.agent._get_tool_registry", lambda: FakeRegistry())
2710
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
2711
+
2712
+ result = agent._handle_tool_call("streaming_tool", {}, "call-stream")
2713
+
2714
+ assert result == json.dumps({"status": "done"})
2715
+ assert isinstance(agent.history[-1], ToolCall)
2716
+ assert agent.history[-1].tool_call_id == "call-stream"
2717
+ assert agent.history[-1].result == result
2718
+ assert agent.history[-1].streaming is False
2719
+ assert [
2720
+ event.data["text"]
2721
+ for event in events
2722
+ if event.type == EventType.HISTORY_ENTRY_DELTA
2723
+ ] == [
2724
+ "chunk 1\n",
2725
+ "chunk 2\n",
2726
+ ]