@pheem49/mint 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (421) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +2 -2
  3. package/README.md +8 -2
  4. package/Release_Note.md +222 -71
  5. package/crates/mint-cli/src/agent/diff_render.rs +66 -44
  6. package/crates/mint-cli/src/agent/live_status.rs +21 -4
  7. package/crates/mint-cli/src/agent/markdown_render.rs +342 -5
  8. package/crates/mint-cli/src/agent.rs +25 -10
  9. package/crates/mint-cli/src/commands/agent.rs +4 -92
  10. package/crates/mint-cli/src/commands/browser.rs +2 -15
  11. package/crates/mint-cli/src/commands/integrations.rs +3 -3
  12. package/crates/mint-cli/src/commands/mod.rs +0 -3
  13. package/crates/mint-cli/src/commands/tailnet_web.rs +1 -0
  14. package/crates/mint-cli/src/interactive/chat_tui/dialog.rs +572 -0
  15. package/crates/mint-cli/src/interactive/chat_tui/mod.rs +1427 -0
  16. package/crates/mint-cli/src/interactive/chat_tui/render.rs +1524 -0
  17. package/crates/mint-cli/src/interactive/chat_tui/selection.rs +401 -0
  18. package/crates/mint-cli/src/interactive/chat_tui/state.rs +1833 -0
  19. package/crates/mint-cli/src/interactive/chat_tui/tests.rs +3414 -0
  20. package/crates/mint-cli/src/interactive/chat_tui/thought.rs +202 -0
  21. package/crates/mint-cli/src/interactive/chat_tui/transcript.rs +728 -0
  22. package/crates/mint-cli/src/interactive/chat_tui/watermark.rs +148 -0
  23. package/crates/mint-cli/src/interactive/command_ui.rs +53 -5
  24. package/crates/mint-cli/src/interactive/composer.rs +186 -0
  25. package/crates/mint-cli/src/interactive/format.rs +78 -3
  26. package/crates/mint-cli/src/interactive/input_box.rs +61 -1
  27. package/crates/mint-cli/src/interactive/resume_picker.rs +367 -25
  28. package/crates/mint-cli/src/interactive/slash_commands.rs +648 -371
  29. package/crates/mint-cli/src/interactive/tool_activity.rs +93 -20
  30. package/crates/mint-cli/src/interactive.rs +37 -0
  31. package/crates/mint-cli/src/main.rs +6 -3
  32. package/crates/mint-cli/src/markdown.rs +2 -37
  33. package/crates/mint-cli/src/onboard.rs +700 -282
  34. package/crates/mint-cli/src/setup.rs +1342 -624
  35. package/crates/mint-cli/src/terminal_theme.rs +159 -35
  36. package/crates/mint-cli/tests/library_intake.rs +1 -0
  37. package/crates/mint-core/src/agent/chat/anthropic.rs +318 -0
  38. package/crates/mint-core/src/agent/chat/custom.rs +149 -0
  39. package/crates/mint-core/src/agent/chat/gemini.rs +518 -0
  40. package/crates/mint-core/src/agent/chat/mod.rs +857 -0
  41. package/crates/mint-core/src/agent/chat/ollama.rs +321 -0
  42. package/crates/mint-core/src/agent/chat/openai.rs +582 -0
  43. package/crates/mint-core/src/agent/chat/streaming.rs +469 -0
  44. package/crates/mint-core/src/agent/chat/tests.rs +1479 -0
  45. package/crates/mint-core/src/agent/memory.rs +56 -2
  46. package/crates/mint-core/src/agent/tasks.rs +2 -2
  47. package/crates/mint-core/src/api_server/routes/chat.rs +24 -2
  48. package/crates/mint-core/src/api_server/routes/gemini_live.rs +14 -1
  49. package/crates/mint-core/src/api_server/routes/html_preview.rs +1 -8
  50. package/crates/mint-core/src/api_server/routes/sessions.rs +21 -0
  51. package/crates/mint-core/src/avatar_bridge.rs +0 -22
  52. package/crates/mint-core/src/browser/cdp.rs +10 -4
  53. package/crates/mint-core/src/browser/headless.rs +582 -0
  54. package/crates/mint-core/src/browser/input.rs +7 -62
  55. package/crates/mint-core/src/browser/interact.rs +0 -14
  56. package/crates/mint-core/src/browser/mod.rs +8 -29
  57. package/crates/mint-core/src/browser/navigate.rs +2 -36
  58. package/crates/mint-core/src/browser/runtime.rs +2 -4
  59. package/crates/mint-core/src/browser/session.rs +50 -466
  60. package/crates/mint-core/src/browser/workflow.rs +142 -32
  61. package/crates/mint-core/src/cron/store.rs +2 -2
  62. package/crates/mint-core/src/integrations/mcp.rs +6 -8
  63. package/crates/mint-core/src/lib.rs +0 -5
  64. package/crates/mint-core/src/media/gemini_live.rs +636 -86
  65. package/crates/mint-core/src/media/library/migration.rs +26 -0
  66. package/crates/mint-core/src/media/library/tests.rs +1 -1
  67. package/crates/mint-core/src/media/library/thumbnails.rs +8 -2
  68. package/crates/mint-core/src/media/pictures.rs +1 -1
  69. package/crates/mint-core/src/media/tool_images.rs +92 -1
  70. package/crates/mint-core/src/mods/manager.rs +101 -35
  71. package/crates/mint-core/src/orchestration/context_render.rs +441 -24
  72. package/crates/mint-core/src/orchestration/decision_parsing.rs +531 -16
  73. package/crates/mint-core/src/orchestration/full_skill_result_tests.rs +12 -0
  74. package/crates/mint-core/src/orchestration/loop_guard.rs +145 -0
  75. package/crates/mint-core/src/orchestration/mcp_outcome_tests.rs +864 -0
  76. package/crates/mint-core/src/orchestration/memory_skill.rs +88 -28
  77. package/crates/mint-core/src/orchestration/mod.rs +320 -4249
  78. package/crates/mint-core/src/orchestration/reliability.rs +67 -27
  79. package/crates/mint-core/src/orchestration/review_regressions.rs +5 -0
  80. package/crates/mint-core/src/orchestration/tests.rs +1665 -0
  81. package/crates/mint-core/src/orchestration/tools/dispatch.rs +967 -0
  82. package/crates/mint-core/src/orchestration/tools/files.rs +1 -0
  83. package/crates/mint-core/src/orchestration/tools/mod.rs +5 -1
  84. package/crates/mint-core/src/orchestration/tools/planning.rs +102 -42
  85. package/crates/mint-core/src/orchestration/tools/shell.rs +129 -58
  86. package/crates/mint-core/src/orchestration/truncation.rs +170 -13
  87. package/crates/mint-core/src/orchestration/types.rs +648 -0
  88. package/crates/mint-core/src/orchestration/verification.rs +0 -3
  89. package/crates/mint-core/src/orchestration/workspace_helpers.rs +24 -6
  90. package/crates/mint-core/src/prompts/agent.rs +52 -76
  91. package/crates/mint-core/src/prompts/tool_catalog.rs +8 -151
  92. package/crates/mint-core/src/search/files.rs +20 -0
  93. package/crates/mint-core/src/slash/mod.rs +228 -265
  94. package/crates/mint-core/src/slash/models.rs +6 -1
  95. package/crates/mint-core/src/system/bg_shell.rs +1 -0
  96. package/crates/mint-core/src/system/config.rs +176 -34
  97. package/crates/mint-mod-runtime/src/lib.rs +37 -23
  98. package/out/web/assets/ArtifactPreviewPanel-Cx6BVOOF.js +2 -0
  99. package/out/web/assets/CalculationCard-D2E74u5q.js +1 -0
  100. package/out/web/assets/CliSessionsView-ZbdtSjvK.css +1 -0
  101. package/out/web/assets/{CliSessionsView-MI8kXMAb.js → CliSessionsView-z5i6qGyb.js} +1 -1
  102. package/out/web/assets/CodeReviewPage-BK2bOScd.css +1 -0
  103. package/out/web/assets/CodeReviewPage-Dp6zHuHu.js +2 -0
  104. package/out/web/assets/{CommandPalette-BD8SlTE5.js → CommandPalette-DaKlzbKs.js} +1 -1
  105. package/out/web/assets/ImageGenCard-BMCJHP9O.js +1 -0
  106. package/out/web/assets/ImageSearchCard-Cld_SOUg.js +1 -0
  107. package/out/web/assets/{ImageStudioPanel-BP7N4rLr.js → ImageStudioPanel-B5N7TjeB.js} +1 -1
  108. package/out/web/assets/LibraryPage-CMB89rJ0.css +1 -0
  109. package/out/web/assets/LibraryPage-DbZEi4W_.js +2 -0
  110. package/out/web/assets/LinkedFoldersView-9B9DIPoZ.js +1 -0
  111. package/out/web/assets/{McpRegistryPicker-CoS83vtc.js → McpRegistryPicker-DnXPlzI7.js} +1 -1
  112. package/out/web/assets/McpServersView-CiNBNUl4.js +1 -0
  113. package/out/web/assets/MermaidCard-BBDNEXbY.js +2 -0
  114. package/out/web/assets/MintDashboard-C_UYPoNV.js +119 -0
  115. package/out/web/assets/MintDashboard-DTd8C_c8.css +1 -0
  116. package/out/web/assets/PlanCard-C2x5pajq.js +1 -0
  117. package/out/web/assets/{PluginsView-jBYmcKh4.js → PluginsView-DoTg9MVa.js} +1 -1
  118. package/out/web/assets/RewindModal-B5oowrR0.js +1 -0
  119. package/out/web/assets/{ScheduledTasksView-5uw9-V9H.js → ScheduledTasksView-BD5DVPkZ.js} +1 -1
  120. package/out/web/assets/{SelectField-DXBQtrUK.js → SelectField-Cy6CghrU.js} +1 -1
  121. package/out/web/assets/SettingsWindow-BYl_oCFh.js +4 -0
  122. package/out/web/assets/SettingsWindow-Ckcgnjt8.css +1 -0
  123. package/out/web/assets/{SkillsView-C-gp9J7m.js → SkillsView-CIeWwq5z.js} +1 -1
  124. package/out/web/assets/StockCard-DxUciKyp.js +1 -0
  125. package/out/web/assets/ToolSurfacePage-qBgs_VHq.css +1 -0
  126. package/out/web/assets/ToolSurfacePage-saVbVrkE.js +38 -0
  127. package/out/web/assets/{UiFeatureCard-6BwxTOCe.js → UiFeatureCard-CdY0hGWy.js} +1 -1
  128. package/out/web/assets/{UiGridCard-DvZcLNxb.js → UiGridCard-BBK3I5hC.js} +1 -1
  129. package/out/web/assets/UiMockupWidget-CpR9EDuc.js +1 -0
  130. package/out/web/assets/{VeoStudioPanel-DOj7_WR3.css → VeoStudioPanel-9kU9YLEN.css} +1 -1
  131. package/out/web/assets/{VeoStudioPanel-DfsOJKv0.js → VeoStudioPanel-Co6-wEWM.js} +2 -2
  132. package/out/web/assets/WeatherCard-Buzi7Vfu.js +1 -0
  133. package/out/web/assets/WorkspacePdfPreview-Bi8Zzyir.js +2 -0
  134. package/out/web/assets/{abnfDiagram-VCTEODGH-BOo3Ckng.js → abnfDiagram-VCTEODGH-CMfR95N2.js} +1 -1
  135. package/out/web/assets/{architecture-7GRP2DOG-ZOwheW3n.js → architecture-7GRP2DOG-DhAM4HyO.js} +1 -1
  136. package/out/web/assets/{architectureDiagram-5GKGNRK7-DC2FDzVd.js → architectureDiagram-5GKGNRK7-Yk3TUJ_F.js} +1 -1
  137. package/out/web/assets/{blockDiagram-I7D4REHJ-BrKFWdAH.js → blockDiagram-I7D4REHJ-X9fBgJik.js} +1 -1
  138. package/out/web/assets/{c4Diagram-7LVT6UL2-BHTHK1Yd.js → c4Diagram-7LVT6UL2-Bk9x7N-8.js} +1 -1
  139. package/out/web/assets/{chunk-4HAMMTFA-BYFkZwNj.js → chunk-4HAMMTFA-Dx5fk1_0.js} +1 -1
  140. package/out/web/assets/{chunk-75Z2AOVW-BSKfSKSA.js → chunk-75Z2AOVW-BszTQoM4.js} +1 -1
  141. package/out/web/assets/{chunk-DU6HZSFF-vIhsiW6n.js → chunk-DU6HZSFF-DK-As3rk.js} +1 -1
  142. package/out/web/assets/{chunk-F27PBJKO-BlYpW9xq.js → chunk-F27PBJKO-LmncZZIS.js} +1 -1
  143. package/out/web/assets/{chunk-GMAD6QVW-BIBz_Ug5.js → chunk-GMAD6QVW-DSQ_sDRJ.js} +1 -1
  144. package/out/web/assets/{chunk-GVQU2GXP-NPSHxeqC.js → chunk-GVQU2GXP-Cno3ulje.js} +1 -1
  145. package/out/web/assets/{chunk-IMKFNOWR-YlJK8_Fp.js → chunk-IMKFNOWR-bIzfY9Jc.js} +1 -1
  146. package/out/web/assets/{chunk-L3NEJ4N5-DMYi4-af.js → chunk-L3NEJ4N5-Dl6eOa0D.js} +1 -1
  147. package/out/web/assets/{chunk-OSK3NFVY-Begd3-jA.js → chunk-OSK3NFVY-BaUGJ-cM.js} +1 -1
  148. package/out/web/assets/{chunk-P2QGCYS3-D1I4xFHN.js → chunk-P2QGCYS3-_066ndhH.js} +1 -1
  149. package/out/web/assets/{chunk-POPQ4Y6H-CafZZzNE.js → chunk-POPQ4Y6H-YT1AKhTv.js} +1 -1
  150. package/out/web/assets/{chunk-PWAF6VOD-Dwh6_Lnt.js → chunk-PWAF6VOD-CrEgDsxh.js} +1 -1
  151. package/out/web/assets/{chunk-SHT3W25Y-B0XrCSEb.js → chunk-SHT3W25Y-_KwaRLOl.js} +1 -1
  152. package/out/web/assets/{chunk-SVP7TREG-CxLsddxC.js → chunk-SVP7TREG-BQ1qb32L.js} +1 -1
  153. package/out/web/assets/{chunk-TICWLB2K-y156QInb.js → chunk-TICWLB2K-BDyehCtg.js} +1 -1
  154. package/out/web/assets/classDiagram-ZZMXUADV-BoSpzRCJ.js +1 -0
  155. package/out/web/assets/classDiagram-v2-VYDZK3BY-BoSpzRCJ.js +1 -0
  156. package/out/web/assets/{config-DqXJXeZM.css → config-2suVFPZg.css} +1 -1
  157. package/out/web/assets/config-CkRCdkxn.js +2 -0
  158. package/out/web/assets/{cynefin-OW5HDTMX-SMVvrLf-.js → cynefin-OW5HDTMX-Dl_31aaN.js} +1 -1
  159. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-rZznN24T.js → cynefinDiagram-5FMLGOSQ-Bpir8jha.js} +1 -1
  160. package/out/web/assets/{dagre-GXQ25YYZ-BOToydFu.js → dagre-GXQ25YYZ-D9xu3nsq.js} +1 -1
  161. package/out/web/assets/{diagram-S7CK7UJ4-TMVaGQEJ.js → diagram-S7CK7UJ4-DeveoEeB.js} +1 -1
  162. package/out/web/assets/{diagram-UQ7AKVKN-C2JK-GP2.js → diagram-UQ7AKVKN-p4xWRESV.js} +1 -1
  163. package/out/web/assets/{diagram-VSXAHHWV-Cw8VSi2K.js → diagram-VSXAHHWV-C7qz9NDF.js} +1 -1
  164. package/out/web/assets/{diagram-VX7I27RA-DXp4fKk9.js → diagram-VX7I27RA-DRiCa3Vd.js} +1 -1
  165. package/out/web/assets/{diagram-Z3DM3KII-CzSicYiW.js → diagram-Z3DM3KII-Bt7zduQX.js} +1 -1
  166. package/out/web/assets/{ebnfDiagram-PWID7BFC-Dqj-zqPu.js → ebnfDiagram-PWID7BFC-DV9WpwdR.js} +1 -1
  167. package/out/web/assets/{erDiagram-RLTQ6QDP-DZwFZAuD.js → erDiagram-RLTQ6QDP-BJ_nwG2m.js} +1 -1
  168. package/out/web/assets/{eventmodeling-NTZA5JFV-C0O-Lvgp.js → eventmodeling-NTZA5JFV-BTBMrfVa.js} +1 -1
  169. package/out/web/assets/flowDiagram-HODETNUW-Cdlq3cJL.js +1 -0
  170. package/out/web/assets/{ganttDiagram-EL5Y4UJY-Da4ULXTl.js → ganttDiagram-EL5Y4UJY-DQLdsXrC.js} +1 -1
  171. package/out/web/assets/{gitGraph-4MIJSDKK-B9wNFZIP.js → gitGraph-4MIJSDKK-CL1OEOQ_.js} +1 -1
  172. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-RjrhB0MP.js → gitGraphDiagram-WWUBYQGX-8cSi42H7.js} +1 -1
  173. package/out/web/assets/index-BocxnrDG.js +168 -0
  174. package/out/web/assets/index-yeh_r55a.css +1 -0
  175. package/out/web/assets/{info-A6RAGUB7-B3dfCpeY.js → info-A6RAGUB7-C1zsX5sc.js} +1 -1
  176. package/out/web/assets/{infoDiagram-27XIBGKW-QVWkLVx7.js → infoDiagram-27XIBGKW-DsRYtIyI.js} +1 -1
  177. package/out/web/assets/{ishikawaDiagram-5VMMS53U-CLFNIRI9.js → ishikawaDiagram-5VMMS53U-B2crwIXj.js} +1 -1
  178. package/out/web/assets/{journeyDiagram-3NMN7TZE-fgOQadYF.js → journeyDiagram-3NMN7TZE-BTH-GyVR.js} +1 -1
  179. package/out/web/assets/{kanban-definition-UXKFOSKX-G3Q_9g9O.js → kanban-definition-UXKFOSKX-BeRfCnuA.js} +1 -1
  180. package/out/web/assets/{mermaid-parser.core-v3Jm7nco.js → mermaid-parser.core-BJwgCBBc.js} +3 -3
  181. package/out/web/assets/{mermaid.core-DTEjLUA1.js → mermaid.core-CsDDodN8.js} +4 -4
  182. package/out/web/assets/{mindmap-definition-YA3MSWOX-C_D42_fF.js → mindmap-definition-YA3MSWOX-B42efOQ1.js} +1 -1
  183. package/out/web/assets/{modelManager-BPLycQrN.js → modelManager-B3ZFMh8v.js} +2 -2
  184. package/out/web/assets/{oauthManager-BUK7ic7q.js → oauthManager-Ch5Du2fF.js} +1 -1
  185. package/out/web/assets/{packet-AYTQ26CC-B-R-SHlF.js → packet-AYTQ26CC-B6xUyE6r.js} +1 -1
  186. package/out/web/assets/{pegDiagram-XKGWAZYB-Da2q_giz.js → pegDiagram-XKGWAZYB-1LabVycL.js} +1 -1
  187. package/out/web/assets/{pie-WAS4IAKB-BC8F0N7-.js → pie-WAS4IAKB-DJwo-nf_.js} +1 -1
  188. package/out/web/assets/{pieDiagram-E7YTZNPT-Iy9REJNC.js → pieDiagram-E7YTZNPT-icmAqzy4.js} +1 -1
  189. package/out/web/assets/{quadrantDiagram-AXDQQJYC-ztsiYw7h.js → quadrantDiagram-AXDQQJYC-CZ-wEx41.js} +1 -1
  190. package/out/web/assets/{radar-RG4KPBEZ-B_1P8Z_z.js → radar-RG4KPBEZ-D7d-Xriw.js} +1 -1
  191. package/out/web/assets/{railroad-74A4TZTK-DuTqczSo.js → railroad-74A4TZTK-viuhdYbs.js} +1 -1
  192. package/out/web/assets/{railroad-abnf-HS5TGJTU-Df29WLmp.js → railroad-abnf-HS5TGJTU-DNXiCNea.js} +1 -1
  193. package/out/web/assets/{railroad-ebnf-LZEXJU2U-ePFMo-qp.js → railroad-ebnf-LZEXJU2U-nkSu1-0C.js} +1 -1
  194. package/out/web/assets/{railroad-peg-WCYAUIDC-DYXLW4_E.js → railroad-peg-WCYAUIDC-BhbMeQzz.js} +1 -1
  195. package/out/web/assets/{railroadDiagram-O6MQD6OU-LStnZf2S.js → railroadDiagram-O6MQD6OU-CLpG1jUX.js} +1 -1
  196. package/out/web/assets/{requirementDiagram-BXWQKSXE-DmzonMH-.js → requirementDiagram-BXWQKSXE-kRDrvWrc.js} +1 -1
  197. package/out/web/assets/{sankeyDiagram-P5KCCOFB-B8vjmUU1.js → sankeyDiagram-P5KCCOFB-DxAwmq4x.js} +1 -1
  198. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-CTnplOAX.js → sequenceDiagram-WJ2MYXX4-aLgY-6Wz.js} +1 -1
  199. package/out/web/assets/{stateDiagram-D77RDMKH-BFrIaLdI.js → stateDiagram-D77RDMKH-D5QWfOhp.js} +1 -1
  200. package/out/web/assets/stateDiagram-v2-MP3YSRHH-D6OiZoAP.js +1 -0
  201. package/out/web/assets/{swimlanes-42K2YHIH-BXGngUNU.js → swimlanes-42K2YHIH-CRs5UyA-.js} +1 -1
  202. package/out/web/assets/swimlanesDiagram-VR7AAH4N-UgEXIsV0.js +8 -0
  203. package/out/web/assets/syntaxHighlight-C-wAGT84.js +3 -0
  204. package/out/web/assets/{timeline-definition-24CTP7MA-CegH7n43.js → timeline-definition-24CTP7MA-Denqd9qb.js} +1 -1
  205. package/out/web/assets/{treeView-Q6P3EWNA-DTpJv2Tw.js → treeView-Q6P3EWNA-0my3Z3s4.js} +1 -1
  206. package/out/web/assets/{treemap-WGGIJYW6-i8M9iJU6.js → treemap-WGGIJYW6-B_EjkLYq.js} +1 -1
  207. package/out/web/assets/{vennDiagram-4TSXK5OY-Dpzo1S8U.js → vennDiagram-4TSXK5OY-DxVg3Ewr.js} +1 -1
  208. package/out/web/assets/{wardley-WFR3VGLG-BrNsgtfw.js → wardley-WFR3VGLG-CGD-Tj4p.js} +1 -1
  209. package/out/web/assets/{wardleyDiagram-VM6X3IG4-lQ92LFE1.js → wardleyDiagram-VM6X3IG4-CmfS39Xm.js} +1 -1
  210. package/out/web/assets/{xychartDiagram-S5SC5T6Z-BaOaEoBd.js → xychartDiagram-S5SC5T6Z-BVM9bKis.js} +1 -1
  211. package/out/web/index-web.html +2 -2
  212. package/out/web/index.html +2 -2
  213. package/package.json +2 -2
  214. package/shared-modules/CODEOWNERS +3 -3
  215. package/shared-modules/SDL2/SDL2_mixer.json +27 -0
  216. package/shared-modules/appstream/README.md +13 -0
  217. package/shared-modules/appstream/appstream-compose.json +111 -0
  218. package/shared-modules/dbus-glib/dbus-glib.json +2 -2
  219. package/shared-modules/libportal/libportal.json +23 -0
  220. package/shared-modules/linux-audio/fluidsynth2-static.json +2 -2
  221. package/shared-modules/linux-audio/fluidsynth2.json +2 -2
  222. package/shared-modules/linux-audio/libinstpatch.json +2 -2
  223. package/shared-modules/linux-audio/liblo-static.json +3 -0
  224. package/shared-modules/luajit/luajit.json +2 -2
  225. package/shared-modules/squashfs-tools/squashfs-tools.json +0 -3
  226. package/slash-commands.json +7 -2
  227. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  228. package/src/renderer/shared/agentProgress.ts +51 -3
  229. package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -56
  230. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +15 -1
  231. package/src/renderer/shared/components/Settings/GeneralTab.tsx +25 -12
  232. package/src/renderer/shared/components/Settings/ThemeTab.test.tsx +45 -0
  233. package/src/renderer/shared/components/Settings/ThemeTab.tsx +106 -3
  234. package/src/renderer/shared/components/activity/AgentActivityDrawer.tsx +67 -17
  235. package/src/renderer/shared/components/activity/AgentActivityTable.tsx +376 -134
  236. package/src/renderer/shared/components/activity/AskUserActivityCard.test.tsx +122 -0
  237. package/src/renderer/shared/components/activity/AskUserActivityCard.tsx +70 -0
  238. package/src/renderer/shared/components/activity/McpToolResults.tsx +39 -2
  239. package/src/renderer/shared/components/activity/SubagentDagView.tsx +7 -7
  240. package/src/renderer/shared/components/activity/ViewedImages.tsx +41 -2
  241. package/src/renderer/shared/components/app/ChunkErrorBoundary.tsx +3 -3
  242. package/src/renderer/shared/components/app/CommandPalette.tsx +7 -7
  243. package/src/renderer/shared/components/app/DesktopTitlebar.tsx +2 -2
  244. package/src/renderer/shared/components/app/MintDashboard.tsx +27 -17
  245. package/src/renderer/shared/components/app/ModalErrorBoundary.tsx +3 -3
  246. package/src/renderer/shared/components/cards/CalculationCard.tsx +3 -3
  247. package/src/renderer/shared/components/cards/ImageGenCard.tsx +2 -2
  248. package/src/renderer/shared/components/cards/ImageSearchCard.tsx +2 -2
  249. package/src/renderer/shared/components/cards/MermaidCard.tsx +1 -1
  250. package/src/renderer/shared/components/cards/PlanCard.test.tsx +41 -0
  251. package/src/renderer/shared/components/cards/PlanCard.tsx +32 -0
  252. package/src/renderer/shared/components/cards/ReleaseNotesCard.test.tsx +29 -0
  253. package/src/renderer/shared/components/cards/ReleaseNotesCard.tsx +137 -0
  254. package/src/renderer/shared/components/cards/StockCard.tsx +7 -7
  255. package/src/renderer/shared/components/cards/ToolResultCards.tsx +11 -3
  256. package/src/renderer/shared/components/cards/UiMockupWidget.tsx +4 -4
  257. package/src/renderer/shared/components/cards/WeatherCard.tsx +5 -5
  258. package/src/renderer/shared/components/chat/ApprovalCard.test.tsx +11 -0
  259. package/src/renderer/shared/components/chat/ApprovalCard.tsx +21 -2
  260. package/src/renderer/shared/components/chat/ArtifactCards.test.tsx +69 -0
  261. package/src/renderer/shared/components/chat/ArtifactCards.tsx +238 -0
  262. package/src/renderer/shared/components/chat/AttachedDocumentCard.test.tsx +39 -0
  263. package/src/renderer/shared/components/chat/AttachedDocumentCard.tsx +220 -0
  264. package/src/renderer/shared/components/chat/ChatCodeBlock.test.tsx +33 -0
  265. package/src/renderer/shared/components/chat/ChatCodeBlock.tsx +15 -1
  266. package/src/renderer/shared/components/chat/ChatMessageItem.test.tsx +40 -2
  267. package/src/renderer/shared/components/chat/ChatMessageItem.tsx +180 -34
  268. package/src/renderer/shared/components/chat/ChatModeSelector.css +227 -0
  269. package/src/renderer/shared/components/chat/ChatModeSelector.test.tsx +45 -0
  270. package/src/renderer/shared/components/chat/ChatModeSelector.tsx +198 -0
  271. package/src/renderer/shared/components/chat/ChatPanel.test.tsx +22 -0
  272. package/src/renderer/shared/components/chat/ChatPanel.tsx +227 -53
  273. package/src/renderer/shared/components/chat/DiffReviewModal.tsx +6 -6
  274. package/src/renderer/shared/components/chat/GeminiLiveOverlay.test.tsx +142 -1
  275. package/src/renderer/shared/components/chat/GeminiLiveOverlay.tsx +45 -3
  276. package/src/renderer/shared/components/chat/ModelSelectorPopover.tsx +42 -14
  277. package/src/renderer/shared/components/chat/RewindModal.tsx +10 -10
  278. package/src/renderer/shared/components/chat/SlashSuggestions.test.tsx +14 -0
  279. package/src/renderer/shared/components/chat/SourcesBlock.test.tsx +53 -0
  280. package/src/renderer/shared/components/chat/SourcesBlock.tsx +236 -90
  281. package/src/renderer/shared/components/chat/TurnFileChanges.test.tsx +45 -1
  282. package/src/renderer/shared/components/chat/TurnFileChanges.tsx +202 -169
  283. package/src/renderer/shared/components/extensions/CliSessionsView.tsx +5 -4
  284. package/src/renderer/shared/components/extensions/McpRegistryPicker.tsx +3 -3
  285. package/src/renderer/shared/components/studio/LibraryPage.tsx +48 -5
  286. package/src/renderer/shared/components/workspace/ArtifactPreviewPanel.tsx +125 -13
  287. package/src/renderer/shared/components/workspace/CodeReviewPage.test.tsx +50 -0
  288. package/src/renderer/shared/components/workspace/CodeReviewPage.tsx +77 -16
  289. package/src/renderer/shared/components/workspace/GitBranchSelector.tsx +78 -22
  290. package/src/renderer/shared/components/workspace/ToolSurfacePage.test.tsx +13 -0
  291. package/src/renderer/shared/components/workspace/ToolSurfacePage.tsx +48 -28
  292. package/src/renderer/shared/components/workspace/WorkspacePdfPreview.tsx +266 -47
  293. package/src/renderer/shared/components/workspace/WorkspacePreviewActions.test.tsx +18 -6
  294. package/src/renderer/shared/components/workspace/background-terminals.css +11 -11
  295. package/src/renderer/shared/constants/config.ts +1 -5
  296. package/src/renderer/shared/constants/models.test.ts +51 -0
  297. package/src/renderer/shared/constants/models.ts +139 -32
  298. package/src/renderer/shared/conversation/lifecycle.test.ts +91 -3
  299. package/src/renderer/shared/conversation/lifecycle.ts +104 -5
  300. package/src/renderer/shared/conversation/progressiveStream.test.ts +83 -0
  301. package/src/renderer/shared/conversation/progressiveStream.ts +219 -0
  302. package/src/renderer/shared/css/agent-observability.css +133 -0
  303. package/src/renderer/shared/css/chat-ui-cards.css +13 -13
  304. package/src/renderer/shared/css/code-review.css +63 -34
  305. package/src/renderer/shared/css/code-sessions.css +47 -18
  306. package/src/renderer/shared/css/collapsed-paste.css +1 -1
  307. package/src/renderer/shared/css/error-notice.css +1 -1
  308. package/src/renderer/shared/css/file-changes.css +165 -80
  309. package/src/renderer/shared/css/library.css +105 -10
  310. package/src/renderer/shared/css/management-views.css +1 -1
  311. package/src/renderer/shared/css/mcp-results.css +5 -5
  312. package/src/renderer/shared/css/model-selector.css +18 -18
  313. package/src/renderer/shared/css/mods.css +7 -7
  314. package/src/renderer/shared/css/notification-notice.css +2 -2
  315. package/src/renderer/shared/css/queued-messages.css +5 -5
  316. package/src/renderer/shared/css/settings/general.css +18 -13
  317. package/src/renderer/shared/css/settings/profile.css +5 -5
  318. package/src/renderer/shared/css/settings/theme.css +5 -5
  319. package/src/renderer/shared/css/slash-choice.css +2 -2
  320. package/src/renderer/shared/css/slash-suggestions.css +3 -3
  321. package/src/renderer/shared/css/sources-block.css +41 -2
  322. package/src/renderer/shared/css/tools-panel.css +2 -2
  323. package/src/renderer/shared/css/ui-tailwind.css +8 -0
  324. package/src/renderer/shared/css/veo-studio.css +2 -2
  325. package/src/renderer/shared/css/viewed-images.css +7 -7
  326. package/src/renderer/shared/platform.ts +2 -1
  327. package/src/renderer/shared/theme/themeManager.test.ts +17 -0
  328. package/src/renderer/shared/theme/themeManager.ts +21 -4
  329. package/src/renderer/shared/theme/themes.ts +54 -0
  330. package/src/renderer/shared/theme/tokens.css +6 -4
  331. package/src/renderer/shared/types.ts +1 -1
  332. package/src/renderer/shared/utils/agentActivity.test.ts +468 -2
  333. package/src/renderer/shared/utils/agentActivity.ts +602 -35
  334. package/src/renderer/shared/utils/artifactBrowser.test.ts +37 -32
  335. package/src/renderer/shared/utils/artifactBrowser.ts +17 -19
  336. package/src/renderer/shared/utils/attachedDocument.test.ts +73 -0
  337. package/src/renderer/shared/utils/attachedDocument.ts +49 -0
  338. package/src/renderer/shared/utils/fileIcons.test.ts +59 -0
  339. package/src/renderer/shared/utils/fileIcons.ts +149 -16
  340. package/src/renderer/shared/utils/markdown.tsx +72 -19
  341. package/src/renderer/shared/utils/markdown.webImages.test.tsx +16 -0
  342. package/src/renderer/shared/utils/mcpResults.test.tsx +29 -0
  343. package/src/renderer/shared/utils/slashCommandProcessor.ts +4 -1
  344. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +11 -1
  345. package/src/renderer/shared/utils/syntaxHighlight.tsx +51 -3
  346. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +324 -51
  347. package/src/renderer/shared/utils/workspaceDrag.test.ts +153 -0
  348. package/src/renderer/shared/utils/workspaceDrag.ts +142 -0
  349. package/src/renderer/shared/utils/workspaceMedia.ts +10 -2
  350. package/src/renderer/src/App.tsx +14 -8
  351. package/src/renderer/src/agentProgress.ts +1 -0
  352. package/src/renderer/src/components/ScreenPicker.css +1 -1
  353. package/src/renderer/src/components/WorkspacePanel.tsx +6 -5
  354. package/src/renderer/src/css/app-shell.css +4 -4
  355. package/src/renderer/src/css/chat.css +1261 -241
  356. package/src/renderer/src/css/desktop-tools.css +11 -11
  357. package/src/renderer/src/css/sidebar-workspace.css +2 -2
  358. package/src/renderer/src/css/tool-surfaces.css +12 -12
  359. package/src/renderer/src/index.css +38 -20
  360. package/src/renderer/src/tauri.ts +22 -2
  361. package/src/renderer/src-web/App.tsx +14 -0
  362. package/src/renderer/src-web/agentProgress.ts +1 -0
  363. package/src/renderer/src-web/css/app-shell.css +4 -4
  364. package/src/renderer/src-web/css/chat.css +1278 -190
  365. package/src/renderer/src-web/css/mobile.css +29 -13
  366. package/src/renderer/src-web/css/settings/mobile.css +2 -2
  367. package/src/renderer/src-web/css/sidebar-workspace.css +1 -1
  368. package/src/renderer/src-web/index.css +38 -20
  369. package/src/renderer/src-web/tauri.ts +22 -2
  370. package/src/renderer/tests/browserArtifacts.browser.tsx +1 -1
  371. package/src/renderer/tests/library.browser.tsx +3 -0
  372. package/src/renderer/tests/liveVoice.browser.html +1 -0
  373. package/src/renderer/tests/workspaceDrop.browser.tsx +1 -1
  374. package/src-tauri/src/app/lifecycle.rs +1 -14
  375. package/src-tauri/src/background/headless.rs +14 -8
  376. package/src-tauri/src/commands/conversation.rs +38 -0
  377. package/src-tauri/src/native/mod.rs +0 -1
  378. package/src-tauri/src/native/system.rs +4 -47
  379. package/crates/mint-cli/src/interactive/chat_tui.rs +0 -8855
  380. package/crates/mint-core/src/agent/chat.rs +0 -4394
  381. package/crates/mint-core/src/browser/lifecycle.rs +0 -291
  382. package/crates/mint-core/src/browser/logging.rs +0 -20
  383. package/crates/mint-core/src/orchestration/tools/browser.rs +0 -31
  384. package/out/web/assets/ArtifactPreviewPanel-S0dPfYFx.js +0 -2
  385. package/out/web/assets/CalculationCard-CRjP3E1j.js +0 -1
  386. package/out/web/assets/CliSessionsView-C3Br2v5Y.css +0 -1
  387. package/out/web/assets/CodeReviewPage-CUJceQme.css +0 -1
  388. package/out/web/assets/CodeReviewPage-VCUgPla_.js +0 -2
  389. package/out/web/assets/ImageGenCard-BCXxkVJX.js +0 -1
  390. package/out/web/assets/ImageSearchCard-N3yQOr8Z.js +0 -1
  391. package/out/web/assets/LibraryPage-DAxUTAg7.css +0 -1
  392. package/out/web/assets/LibraryPage-WlviRdR8.js +0 -2
  393. package/out/web/assets/LinkedFoldersView-CKRgroWK.js +0 -1
  394. package/out/web/assets/McpServersView-D3wHnuHg.js +0 -1
  395. package/out/web/assets/MermaidCard-NqrHdyJW.js +0 -2
  396. package/out/web/assets/MintDashboard-B2DJsDDO.css +0 -1
  397. package/out/web/assets/MintDashboard-COw_xol7.js +0 -63
  398. package/out/web/assets/RewindModal-DempuDod.js +0 -1
  399. package/out/web/assets/SettingsWindow-2YP3yj7s.js +0 -4
  400. package/out/web/assets/SettingsWindow-Dz8LQVgD.css +0 -1
  401. package/out/web/assets/StockCard-BDMCe7mx.js +0 -1
  402. package/out/web/assets/ToolSurfacePage-B0J_q0Jl.js +0 -38
  403. package/out/web/assets/ToolSurfacePage-CRIQ1owD.css +0 -1
  404. package/out/web/assets/UiMockupWidget-DD73QWg1.js +0 -1
  405. package/out/web/assets/WeatherCard-CPucIfq8.js +0 -1
  406. package/out/web/assets/WorkspacePdfPreview-Dx_lkjNu.js +0 -2
  407. package/out/web/assets/classDiagram-ZZMXUADV-BgJ5WtSp.js +0 -1
  408. package/out/web/assets/classDiagram-v2-VYDZK3BY-BgJ5WtSp.js +0 -1
  409. package/out/web/assets/config-CvIO5hkn.js +0 -2
  410. package/out/web/assets/flowDiagram-HODETNUW-uqCHJond.js +0 -1
  411. package/out/web/assets/index-BmpulIkg.js +0 -168
  412. package/out/web/assets/index-CkvBwhX_.css +0 -1
  413. package/out/web/assets/stateDiagram-v2-MP3YSRHH-uf3kdNW4.js +0 -1
  414. package/out/web/assets/swimlanesDiagram-VR7AAH4N-Y8q5bQce.js +0 -8
  415. package/shared-modules/libsecret/libsecret.json +0 -23
  416. package/shared-modules/libusb/libusb.json +0 -27
  417. package/shared-modules/lzo/lzo.json +0 -27
  418. package/src/renderer/src/components/BrowserShell.tsx +0 -108
  419. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +0 -91
  420. package/src/renderer/src/components/browser-shell.css +0 -185
  421. package/src-tauri/src/native/browser.rs +0 -252
@@ -1,6 +1,4 @@
1
1
  use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
2
- use futures_util::StreamExt;
3
- use serde::{Deserialize, Serialize};
4
2
  use serde_json::Value;
5
3
  use std::collections::BTreeMap;
6
4
  use std::future::Future;
@@ -9,7 +7,6 @@ use std::pin::Pin;
9
7
  use std::process::Command;
10
8
  use std::sync::Arc;
11
9
  use std::time::Instant;
12
- use thiserror::Error;
13
10
 
14
11
  pub mod approvals;
15
12
  #[cfg(test)]
@@ -24,7 +21,9 @@ pub mod diagnostics;
24
21
  pub mod loop_guard;
25
22
  pub mod trajectory_schema;
26
23
  pub mod truncation;
24
+ pub mod types;
27
25
  pub use trajectory_schema::{StandardTrajectoryRun, StandardTrajectoryStep, StepTokens};
26
+ pub use types::*;
28
27
  mod repeated_failures;
29
28
  #[cfg(all(test, unix))]
30
29
  mod review_regressions;
@@ -36,8 +35,8 @@ use crate::chat::{
36
35
  stream_chat_events_with_fallback, stream_chat_with_fallback,
37
36
  };
38
37
  use crate::code_tools::{
39
- CodeEdit, CodePatchHunk, apply_code_edits, build_code_patch, list_code_files,
40
- propose_code_edits, read_code_file, search_code,
38
+ CodeEdit, apply_code_edits, build_code_patch, list_code_files, propose_code_edits,
39
+ read_code_file, search_code,
41
40
  };
42
41
  use crate::config::ToolCallingMode;
43
42
  use crate::knowledge::KnowledgeStore;
@@ -51,197 +50,14 @@ use crate::{
51
50
  stream_chat,
52
51
  };
53
52
 
54
- const CONTEXT_LIMIT: usize = 3;
53
+ pub(crate) const CONTEXT_LIMIT: usize = 15;
55
54
  /// Per-message cap (in `chars`, not bytes — Thai text is multi-byte UTF-8)
56
55
  /// applied to each recalled `user_text`/`ai_text` when building the "recent
57
- /// conversation context" injected into a new task's opening system prompt.
58
- /// Without this, a single unusually long past answer (a multi-KB agent
59
- /// summary, a big code dump) gets replayed *in full* into every subsequent
60
- /// unrelated turn for as long as it stays within the last `CONTEXT_LIMIT`
61
- /// interactions — this exists purely to nudge the model with recent
62
- /// continuity, not to re-litigate a whole previous answer.
63
- const MAX_CONTEXT_MESSAGE_CHARS: usize = 200;
64
-
65
- struct TurnStartListener {
66
- chat_id: String,
67
- callback: Arc<dyn Fn(i64) + Send + Sync>,
68
- }
69
-
70
- tokio::task_local! {
71
- static TURN_START_LISTENER: TurnStartListener;
72
- }
73
-
74
- /// Reports the persisted turn ID to the initiating transport without changing
75
- /// the agent/chat interfaces used by CLI, jobs, and background integrations.
76
- pub async fn with_turn_start_listener<F, R>(
77
- chat_id: String,
78
- callback: impl Fn(i64) + Send + Sync + 'static,
79
- future: F,
80
- ) -> R
81
- where
82
- F: Future<Output = R>,
83
- {
84
- TURN_START_LISTENER
85
- .scope(
86
- TurnStartListener {
87
- chat_id,
88
- callback: Arc::new(callback),
89
- },
90
- future,
91
- )
92
- .await
93
- }
94
-
95
- /// Owns one persisted turn, including its cross-process queue lease. Dropping a
96
- /// cancelled task marks its prompt interrupted instead of leaving it running.
97
- struct TurnLease {
98
- chat_id: String,
99
- memory: MemoryStore,
100
- id: i64,
101
- heartbeat: tokio::task::JoinHandle<()>,
102
- finished: bool,
103
- activity: Arc<std::sync::Mutex<Vec<AgentProgress>>>,
104
- }
105
-
106
- impl TurnLease {
107
- async fn start(memory: &MemoryStore, chat_id: &str, text: &str) -> Result<Self, MemoryError> {
108
- let id = memory.start_turn(chat_id, text)?;
109
- crate::tts::playback::cancel_chat(memory, chat_id);
110
- crate::companion::queued(chat_id, id, text);
111
- let _ = TURN_START_LISTENER.try_with(|listener| {
112
- if listener.chat_id == chat_id {
113
- (listener.callback)(id);
114
- }
115
- });
116
- let heartbeat_memory = memory.clone();
117
- let heartbeat = tokio::spawn(async move {
118
- loop {
119
- tokio::time::sleep(std::time::Duration::from_secs(10)).await;
120
- if heartbeat_memory.renew_turn(id).is_err() {
121
- break;
122
- }
123
- }
124
- });
125
- let mut lease = Self {
126
- chat_id: chat_id.to_owned(),
127
- memory: memory.clone(),
128
- id,
129
- heartbeat,
130
- finished: false,
131
- activity: Arc::new(std::sync::Mutex::new(Vec::new())),
132
- };
133
- loop {
134
- match memory.claim_turn(chat_id, id) {
135
- Ok(true) => {
136
- crate::companion::update(
137
- chat_id,
138
- id,
139
- mint_companion_protocol::Status::Thinking,
140
- None,
141
- );
142
- return Ok(lease);
143
- }
144
- Ok(false) => tokio::time::sleep(std::time::Duration::from_millis(250)).await,
145
- Err(error) => {
146
- lease.fail();
147
- return Err(error);
148
- }
149
- }
150
- }
151
- }
152
-
153
- fn persist_activity(&self) {
154
- if let Ok(lock) = self.activity.lock() {
155
- let compacted = compact_agent_progress(&lock);
156
- if !compacted.is_empty() {
157
- if let Ok(activity_json) = serde_json::to_string(&compacted) {
158
- let _ = self
159
- .memory
160
- .set_interaction_agent_activity_json(self.id, &activity_json);
161
- }
162
- }
163
- }
164
- }
165
-
166
- fn complete_with_summary(
167
- &mut self,
168
- summary: &str,
169
- provider: &str,
170
- model: &str,
171
- fallback_provider: Option<&str>,
172
- ) -> Result<(), MemoryError> {
173
- self.persist_activity();
174
- self.memory
175
- .finish_turn(self.id, summary, provider, model, fallback_provider)?;
176
- self.finished = true;
177
- crate::companion::update(
178
- &self.chat_id,
179
- self.id,
180
- mint_companion_protocol::Status::Completed,
181
- Some(summary.to_owned()),
182
- );
183
- Ok(())
184
- }
185
-
186
- fn complete(&mut self, response: &ChatResponse) -> Result<(), MemoryError> {
187
- self.complete_with_summary(
188
- &response.text,
189
- &response.provider,
190
- &response.model,
191
- response.fallback_provider.as_deref(),
192
- )
193
- }
194
-
195
- fn fail(&mut self) {
196
- self.persist_activity();
197
- let _ = self.memory.end_turn(self.id, "failed");
198
- crate::companion::update(
199
- &self.chat_id,
200
- self.id,
201
- mint_companion_protocol::Status::Failed,
202
- None,
203
- );
204
- self.finished = true;
205
- }
206
-
207
- fn interrupt(&mut self) {
208
- self.persist_activity();
209
- let _ = self.memory.end_turn(self.id, "interrupted");
210
- crate::companion::update(
211
- &self.chat_id,
212
- self.id,
213
- mint_companion_protocol::Status::Interrupted,
214
- None,
215
- );
216
- self.finished = true;
217
- }
218
- }
219
-
220
- impl Drop for TurnLease {
221
- fn drop(&mut self) {
222
- self.heartbeat.abort();
223
- if !self.finished {
224
- self.persist_activity();
225
- let _ = self.memory.end_turn(self.id, "interrupted");
226
- crate::companion::update(
227
- &self.chat_id,
228
- self.id,
229
- mint_companion_protocol::Status::Interrupted,
230
- None,
231
- );
232
- }
233
- }
234
- }
235
-
236
- #[derive(Debug, Error)]
237
- pub enum OrchestrationError {
238
- #[error(transparent)]
239
- Chat(#[from] ChatError),
240
- #[error(transparent)]
241
- Memory(#[from] MemoryError),
242
- #[error("agent error: {0}")]
243
- Agent(String),
244
- }
56
+ /// conversation context" injected into a new task's opening system prompt or
57
+ /// structured `native_messages`.
58
+ /// Sized at 4,000 characters to comfortably accommodate rich code snippets,
59
+ /// full multi-line instructions, and Thai conversational text without truncation.
60
+ pub(crate) const MAX_CONTEXT_MESSAGE_CHARS: usize = 4000;
245
61
 
246
62
  pub async fn resolve_github_links(message: &str, config: &MintConfig) -> String {
247
63
  // Check if a GitHub MCP server is configured in Settings
@@ -481,23 +297,35 @@ where
481
297
  Ok((response, fallback))
482
298
  }
483
299
 
300
+ /// Read-only tools whose *outcome* (not just input) identifies a stuck loop:
301
+ /// image/page observations and headless-Chrome captures run through the shell.
302
+ fn is_observation_action(action: &str, command: &str) -> bool {
303
+ if action == "view_image" {
304
+ return true;
305
+ }
306
+ action == "run_shell" && {
307
+ let command = command.to_ascii_lowercase();
308
+ command.contains("chrome") || command.contains("chromium")
309
+ }
310
+ }
311
+
484
312
  // Full skill reads must reach the model exactly; other tools keep bounded output.
485
- fn model_tool_result(action: &str, result: &str) -> String {
486
- if action.starts_with("browser_")
487
- || (action == "read_file" && result.starts_with("[Loaded skill: "))
488
- {
313
+ // Large outputs (> 3,000 chars) are safely offloaded to .mint/scratch/tool_outputs/ when root is available.
314
+ pub(super) fn model_tool_result(root: Option<&Path>, action: &str, result: &str) -> String {
315
+ if action == "read_file" && result.starts_with("[Loaded skill: ") {
489
316
  result.to_owned()
490
317
  } else {
491
- truncation::head_tail_truncate(result, truncation::TruncationConfig::default())
318
+ truncation::truncate_tool_output(
319
+ root,
320
+ action,
321
+ result,
322
+ truncation::TruncationConfig::default(),
323
+ )
492
324
  }
493
325
  }
494
326
 
495
- fn tool_progress_input(root: &Path, decision: &AgentDecision) -> Value {
327
+ pub(super) fn tool_progress_input(root: &Path, decision: &AgentDecision) -> Value {
496
328
  let mut input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
497
- if matches!(decision.action.as_str(), "browser_type" | "browser_fill") {
498
- input["text"] = "[redacted input]".into();
499
- input["query"] = "[redacted input]".into();
500
- }
501
329
  if decision.action == "read_file"
502
330
  && let Some(skill) = crate::skills::skill_for_read_path(root, &decision.input.path)
503
331
  && let Some(object) = input.as_object_mut()
@@ -519,70 +347,119 @@ pub(super) fn truncate_for_context(text: &str, max_chars: usize) -> String {
519
347
  truncated
520
348
  }
521
349
 
350
+ /// Deduplicates building user content blocks across plain chat enrichment and agent turn initialization.
351
+ pub(crate) fn build_user_content_blocks(
352
+ text: String,
353
+ image_data: Option<&str>,
354
+ audio_data: Option<&str>,
355
+ video_data: Option<&str>,
356
+ ) -> Vec<ContentBlock> {
357
+ let mut content = vec![ContentBlock::Text { text }];
358
+ if let Some(image_data) = image_data {
359
+ for img in image_data.split_whitespace() {
360
+ content.push(ContentBlock::Image {
361
+ data_uri: img.to_owned(),
362
+ });
363
+ }
364
+ }
365
+ if let Some(audio_data) = audio_data {
366
+ for aud in audio_data.split_whitespace() {
367
+ content.push(ContentBlock::Audio {
368
+ data_uri: aud.to_owned(),
369
+ });
370
+ }
371
+ }
372
+ if let Some(video_data) = video_data {
373
+ for vid in video_data.split_whitespace() {
374
+ content.push(ContentBlock::Video {
375
+ data_uri: vid.to_owned(),
376
+ });
377
+ }
378
+ }
379
+ content
380
+ }
381
+
382
+ fn construct_system_prompt(
383
+ config: &MintConfig,
384
+ memory: &MemoryStore,
385
+ chat_id: &str,
386
+ root: &Path,
387
+ resolved_task: &str,
388
+ plan_mode: bool,
389
+ system_prompt_native: bool,
390
+ user_name: Option<&str>,
391
+ pinned_mcp_server: Option<&str>,
392
+ ) -> String {
393
+ let mut system_prompt = build_system_prompt(
394
+ config,
395
+ plan_mode,
396
+ system_prompt_native,
397
+ user_name,
398
+ pinned_mcp_server,
399
+ );
400
+ append_memory_context(
401
+ &mut system_prompt,
402
+ memory,
403
+ chat_id,
404
+ Some(root.to_string_lossy().as_ref()),
405
+ Some(resolved_task),
406
+ config.semantic_fact_recall,
407
+ !system_prompt_native,
408
+ );
409
+ if config.memory_recall
410
+ && let Some(recall) = render_recalled_messages(memory, chat_id, resolved_task)
411
+ {
412
+ system_prompt = format!("{}\n\n{}", system_prompt.trim(), recall);
413
+ }
414
+ system_prompt
415
+ }
416
+
522
417
  fn enrich_request(
523
418
  config: &MintConfig,
524
419
  memory: &MemoryStore,
525
420
  request: &ChatRequest,
526
421
  ) -> Result<ChatRequest, MemoryError> {
527
- let mut interactions =
528
- memory.recent_completed_interactions_for_chat(&request_chat_id(request), CONTEXT_LIMIT)?;
529
- interactions.reverse();
530
- let transcript = interactions
531
- .into_iter()
532
- .map(|item| {
533
- format!(
534
- "User: {}\nAssistant: {}",
535
- truncate_for_context(&item.user_text, MAX_CONTEXT_MESSAGE_CHARS),
536
- truncate_for_context(&item.ai_text, MAX_CONTEXT_MESSAGE_CHARS)
537
- )
538
- })
539
- .collect::<Vec<_>>()
540
- .join("\n\n");
541
422
  let mut enriched = request.clone();
542
423
 
543
- let mut profile_instructions = String::new();
544
- if let Ok(Some(name)) = memory.get_profile("name")
545
- && !name.trim().is_empty()
546
- {
547
- profile_instructions.push_str(&format!("User Name: {}\n", name.trim()));
548
- }
549
- if let Ok(Some(preferences)) = memory.get_profile("preferences")
550
- && !preferences.trim().is_empty()
551
- {
552
- profile_instructions.push_str(&format!(
553
- "User Preferences & Profile:\n{}\n",
554
- preferences.trim()
555
- ));
556
- }
557
-
558
- if !profile_instructions.is_empty() {
559
- enriched.system_instruction = format!(
560
- "{}\n\nUser Profile Information:\n{}",
561
- enriched.system_instruction.trim(),
562
- profile_instructions.trim()
563
- )
564
- .trim()
565
- .to_owned();
424
+ // If structured messages were not explicitly provided, build conversation history messages
425
+ // so multi-turn context is preserved natively in plain chat mode across all native providers.
426
+ let is_native = config.tool_calling_mode() == ToolCallingMode::Native;
427
+ if is_native && enriched.messages.is_none() {
428
+ let history = build_conversation_history_messages(
429
+ memory,
430
+ &request_chat_id(request),
431
+ CONTEXT_LIMIT,
432
+ MAX_CONTEXT_MESSAGE_CHARS,
433
+ );
434
+ if !history.is_empty() {
435
+ let mut messages = history;
436
+ let content = build_user_content_blocks(
437
+ request.message.clone(),
438
+ request.image_data_uri.as_deref(),
439
+ request.audio_data_uri.as_deref(),
440
+ request.video_data_uri.as_deref(),
441
+ );
442
+ messages.push(ChatMessage {
443
+ role: ChatRole::User,
444
+ content,
445
+ });
446
+ enriched.messages = Some(messages);
447
+ }
566
448
  }
567
449
 
568
- if let Some(facts) = render_memory_facts(
450
+ append_memory_context(
451
+ &mut enriched.system_instruction,
569
452
  memory,
453
+ &request_chat_id(request),
570
454
  request.workspace_path.as_deref(),
571
- crate::subagent_name(&request_chat_id(request)),
572
455
  Some(&request.message),
573
456
  config.semantic_fact_recall,
574
- ) {
575
- enriched.system_instruction = format!(
576
- "{}\n\nRemembered facts:\n{}",
577
- enriched.system_instruction.trim(),
578
- facts
579
- )
580
- .trim()
581
- .to_owned();
582
- }
457
+ enriched.messages.is_none(),
458
+ );
583
459
 
584
460
  if config.memory_recall
585
- && let Some(recall) = render_recalled_messages(&request_chat_id(request), &request.message)
461
+ && let Some(recall) =
462
+ render_recalled_messages(memory, &request_chat_id(request), &request.message)
586
463
  {
587
464
  enriched.system_instruction =
588
465
  format!("{}\n\n{}", enriched.system_instruction.trim(), recall)
@@ -600,15 +477,6 @@ fn enrich_request(
600
477
  );
601
478
  enriched.system_instruction.push_str(&active_model_info);
602
479
 
603
- if !transcript.is_empty() {
604
- enriched.system_instruction = format!(
605
- "{}\n\nRecent conversation context:\n{}",
606
- enriched.system_instruction.trim(),
607
- transcript
608
- )
609
- .trim()
610
- .to_owned();
611
- }
612
480
  Ok(enriched)
613
481
  }
614
482
 
@@ -625,14 +493,19 @@ fn request_chat_id(request: &ChatRequest) -> String {
625
493
  use crate::prompts::agent::build_system_prompt;
626
494
 
627
495
  mod context_render;
628
- mod decision_parsing;
496
+ pub mod decision_parsing;
629
497
  mod memory_skill;
630
498
  mod tools;
631
499
  mod verification;
632
500
  mod workspace_helpers;
501
+
633
502
  use context_render::*;
634
503
  use decision_parsing::*;
504
+ pub use decision_parsing::{SwarmTaskInput, is_dummy_value};
635
505
  pub(crate) use memory_skill::*;
506
+ pub use tools::execute_mod_requests;
507
+ pub(crate) use tools::execute_tool_from_json;
508
+ use tools::*;
636
509
  use verification::*;
637
510
  use workspace_helpers::*;
638
511
 
@@ -642,7 +515,6 @@ use workspace_helpers::*;
642
515
  /// tasks earlier, but 24 turned out too tight for legitimate multi-file
643
516
  /// work, cutting it off mid-task — now configurable via `MintConfig::agent_max_steps` (default 40).
644
517
  // A tool gets its own correction budget, renewed after successful execution.
645
- const MAX_TOOL_ARGUMENT_RETRIES: usize = 5;
646
518
  const MAX_OBSERVATION_BYTES: usize = 16_000;
647
519
  /// Compact `native_messages` once a step's reported token usage crosses this
648
520
  /// fraction of `MintConfig::context_window_tokens`. Every step resends the whole
@@ -670,862 +542,6 @@ const ADAPTIVE_COMPACTION_MIN_STEPS: usize = 20;
670
542
  const NETWORK_RETRY_ATTEMPTS: usize = 4;
671
543
  const NETWORK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
672
544
 
673
- /// A single pick-a-choice option offered by the `ask_user` tool, with an
674
- /// optional one-line explanation shown under the label in both the CLI
675
- /// picker and the desktop/web approval card.
676
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677
- pub struct AskUserOption {
678
- pub label: String,
679
- #[serde(default)]
680
- pub description: Option<String>,
681
- }
682
-
683
- #[derive(Debug, Clone, Serialize, Deserialize)]
684
- pub enum AgentApproval {
685
- /// Persistent agent instructions need a decision for each concrete diff.
686
- SkillWrite {
687
- path: String,
688
- content: String,
689
- diff: String,
690
- },
691
- WriteFile {
692
- path: String,
693
- content: String,
694
- diff: String,
695
- },
696
- ApplyPatch {
697
- path: String,
698
- hunks: Vec<CodePatchHunk>,
699
- diff: String,
700
- },
701
- RunShell {
702
- command: String,
703
- mode: String,
704
- background: bool,
705
- },
706
- NoteWrite {
707
- path: String,
708
- content: String,
709
- },
710
- RunPlugin {
711
- name: String,
712
- instruction: String,
713
- },
714
- McpTool {
715
- server: String,
716
- tool: String,
717
- arguments: Value,
718
- },
719
- UserApproval {
720
- title: String,
721
- prompt: String,
722
- },
723
- AskUser {
724
- question: String,
725
- #[serde(default)]
726
- options: Vec<AskUserOption>,
727
- #[serde(default)]
728
- header: Option<String>,
729
- #[serde(default, rename = "multiSelect")]
730
- multi_select: bool,
731
- },
732
- ExitPlanMode {
733
- plan: String,
734
- },
735
- EnterPlanMode {
736
- reason: String,
737
- },
738
- }
739
-
740
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
741
- pub enum ApprovalOutcome {
742
- Approved,
743
- Denied,
744
- Intercepted(String),
745
- }
746
-
747
- /// The `answer` string a surface returns from an [`AgentApproval::McpTool`]
748
- /// prompt to mean "run this call **and** persist `allowedMcpTools[server] =
749
- /// ["*"]`, so this server never prompts again". Travels through the normal
750
- /// `Intercepted(answer)` channel. The CLI approval picker
751
- /// (`crates/mint-cli/src/agent.rs`) and the web/desktop `ApprovalCard`
752
- /// (`src/renderer/shared/components/ApprovalCard.tsx`) send this exact string.
753
- pub const MCP_ALLOW_ALL_SENTINEL: &str = "__mcp_allow_all__";
754
-
755
- #[derive(Debug, Clone, Serialize, Deserialize)]
756
- #[serde(tag = "type", content = "data")]
757
- pub enum AgentProgress {
758
- ToolDiscovery {
759
- server: String,
760
- tool: String,
761
- message: String,
762
- },
763
- DeviceState {
764
- #[serde(rename = "callId")]
765
- call_id: String,
766
- server: String,
767
- tool: String,
768
- update: crate::mcp::device::DeviceUpdate,
769
- },
770
- ToolProgress {
771
- #[serde(rename = "callId")]
772
- call_id: String,
773
- server: String,
774
- tool: String,
775
- update: crate::mcp::McpProgress,
776
- },
777
- ToolArtifacts {
778
- #[serde(rename = "callId")]
779
- call_id: String,
780
- artifacts: Vec<crate::integrations::mcp_result::McpArtifact>,
781
- warnings: Vec<String>,
782
- },
783
- ExecutionUpdate {
784
- #[serde(rename = "callId")]
785
- call_id: String,
786
- executions: Vec<reliability::ExecutionEvidence>,
787
- },
788
- Thinking {
789
- elapsed_secs: u64,
790
- #[serde(skip_serializing_if = "Option::is_none")]
791
- agent_name: Option<String>,
792
- #[serde(skip_serializing_if = "Option::is_none")]
793
- model_name: Option<String>,
794
- /// Context-window usage as of the last completed step, 0-100 — the
795
- /// last thing we actually know, since usage is only reported in a
796
- /// step's *response*, not predictable mid-generation. `None` until
797
- /// the first step of a task has completed at least once.
798
- #[serde(skip_serializing_if = "Option::is_none")]
799
- context_pct: Option<u8>,
800
- /// Running sum of `total_tokens` across every step completed so far
801
- /// this turn — same accumulator as `AgentResult::total_tokens`, just
802
- /// surfaced live instead of only once the turn finishes, so the CLI
803
- /// can count it up next to the "Thinking…" label the way Claude Code
804
- /// does. 0 until the first step has completed.
805
- #[serde(default)]
806
- tokens_used: u64,
807
- /// Prompt/context tokens the *most recent* completed step processed
808
- /// (system prompt + tool schemas + the whole resent history) — the
809
- /// `↑` half of the live counter. Latest-step, not summed: it tracks
810
- /// how full the context window is, and summing would count the resent
811
- /// history once per step. 0 until the first step has completed.
812
- #[serde(default)]
813
- input_tokens: u64,
814
- /// Running sum of completion tokens the model generated across every
815
- /// step this turn — the `↓` half. Summed (unlike `input_tokens`),
816
- /// since each step's output is distinct work. 0 until the first step
817
- /// has completed.
818
- #[serde(default)]
819
- generated_tokens: u64,
820
- /// Rough char-count-based estimate of the very first request's size
821
- /// — constant across every step of the turn. Only meaningful to a
822
- /// consumer while `tokens_used` is still 0 (i.e. step 1's response
823
- /// hasn't come back yet), as a stand-in until the real number
824
- /// exists; ignored once `tokens_used` is nonzero.
825
- #[serde(default)]
826
- estimated_tokens: u64,
827
- },
828
- Thought {
829
- thought: String,
830
- },
831
- /// Incremental model reasoning for the currently active agent step. These
832
- /// events are live-only; consumers replace them with the matching final
833
- /// `ExtendedThinking` record instead of persisting every delta.
834
- ThinkingDelta {
835
- id: String,
836
- delta: String,
837
- elapsed_ms: u64,
838
- },
839
- /// Extended reasoning / chain-of-thought from the model's API thinking
840
- /// tokens (e.g. Claude `<thinking>`, Gemini Thinking, DeepSeek Reasoner,
841
- /// or `<think>` tags). Semantically distinct from `Thought`, which
842
- /// carries short, one-line agent notes (fallback warnings, decision
843
- /// summaries). The CLI renders this as a collapsible block; the web/
844
- /// desktop UI shows it in a dedicated "Extended Thinking" panel.
845
- ExtendedThinking {
846
- #[serde(default, skip_serializing_if = "Option::is_none")]
847
- id: Option<String>,
848
- thought: String,
849
- #[serde(default, skip_serializing_if = "Option::is_none")]
850
- elapsed_ms: Option<u64>,
851
- },
852
- /// Lifecycle of native context summarization, including actionable failures.
853
- ContextCompaction {
854
- #[serde(default, skip_serializing_if = "Option::is_none")]
855
- subagent: Option<String>,
856
- status: String,
857
- message: String,
858
- },
859
- /// Emitted while retrying a step after every configured provider was
860
- /// unreachable (`ChatError::NetworkUnavailable`) — distinct from
861
- /// `Thinking` because there's nothing to wait *on* here except the
862
- /// network itself coming back, not a model generating a reply.
863
- WaitingForNetwork {
864
- attempt: usize,
865
- max_attempts: usize,
866
- },
867
- ToolStart {
868
- #[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
869
- call_id: Option<String>,
870
- action: String,
871
- input: Value,
872
- /// Name of the subagent this tool call happened inside, if any —
873
- /// `None` for the top-level agent's own calls. Set by
874
- /// `dispatch_one_subagent` wrapping the nested loop's `progress` so
875
- /// the CLI/GUI can render a subagent's own tool calls nested under
876
- /// its `dispatch_subagent` call instead of indistinguishable from
877
- /// the parent's.
878
- #[serde(default, skip_serializing_if = "Option::is_none")]
879
- subagent: Option<String>,
880
- },
881
- ToolEnd {
882
- #[serde(default, alias = "elapsedMs", skip_serializing_if = "Option::is_none")]
883
- elapsed_ms: Option<u64>,
884
- #[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
885
- call_id: Option<String>,
886
- #[serde(default, skip_serializing_if = "Option::is_none")]
887
- status: Option<crate::integrations::mcp_result::ToolStatus>,
888
- action: String,
889
- input: Value,
890
- result: String,
891
- #[serde(default, skip_serializing_if = "Option::is_none")]
892
- subagent: Option<String>,
893
- },
894
- PlanUpdated {
895
- plan: ActivePlan,
896
- },
897
- RunCompleted {
898
- summary: RunTelemetrySummary,
899
- },
900
- SubagentStarted {
901
- subagent: String,
902
- profile: String,
903
- task: String,
904
- },
905
- SubagentFinished {
906
- subagent: String,
907
- status: String,
908
- #[serde(default, alias = "elapsed_ms", rename = "elapsedMs")]
909
- elapsed_ms: u64,
910
- steps: u32,
911
- #[serde(default, alias = "summary_preview", rename = "summaryPreview")]
912
- summary_preview: String,
913
- },
914
- }
915
-
916
- impl From<crate::swarm::SwarmEvent> for AgentProgress {
917
- fn from(event: crate::swarm::SwarmEvent) -> Self {
918
- match event {
919
- crate::swarm::SwarmEvent::Started {
920
- label,
921
- profile,
922
- task,
923
- } => AgentProgress::SubagentStarted {
924
- subagent: label,
925
- profile,
926
- task,
927
- },
928
- crate::swarm::SwarmEvent::Progress(p) => p,
929
- crate::swarm::SwarmEvent::Finished {
930
- label,
931
- status,
932
- elapsed_ms,
933
- steps,
934
- summary_preview,
935
- } => AgentProgress::SubagentFinished {
936
- subagent: label,
937
- status: status.as_str().to_string(),
938
- elapsed_ms,
939
- steps,
940
- summary_preview,
941
- },
942
- }
943
- }
944
- }
945
-
946
- /// Determines whether a thought string represents long-form / internal chain-of-thought
947
- /// (reasoning tokens, <think> tags, or multi-line reasoning) versus a brief agent note.
948
- pub fn is_internal_cot(text: &str) -> bool {
949
- let trimmed = text.trim();
950
- if trimmed.starts_with("<think>") || trimmed.contains("</think>") {
951
- return true;
952
- }
953
- let line_count = trimmed.lines().count();
954
- let char_count = trimmed.chars().count();
955
- if trimmed.contains("\n\n") || line_count > 2 || char_count > 180 {
956
- return true;
957
- }
958
- let lower = trimmed.to_lowercase();
959
- if lower.starts_with("the user ")
960
- || lower.starts_with("let's think")
961
- || lower.starts_with("let me analyze")
962
- || lower.starts_with("per rule")
963
- || lower.starts_with("note the ")
964
- || lower.contains("thinking process:")
965
- {
966
- return true;
967
- }
968
- false
969
- }
970
-
971
- /// Compacts an agent progress event stream for storage, removing transport-only
972
- /// streaming deltas (`ThinkingDelta`) while preserving complete `ExtendedThinking`
973
- /// blocks, or synthesizing an `ExtendedThinking` block for any interrupted delta sequence.
974
- pub fn compact_agent_progress(progress: &[AgentProgress]) -> Vec<AgentProgress> {
975
- let completed_ids: std::collections::HashSet<String> = progress
976
- .iter()
977
- .filter_map(|event| match event {
978
- AgentProgress::ExtendedThinking { id: Some(id), .. } => Some(id.clone()),
979
- _ => None,
980
- })
981
- .collect();
982
-
983
- let mut result = Vec::new();
984
- let mut interrupted_order: Vec<String> = Vec::new();
985
- let mut interrupted: std::collections::HashMap<String, (String, Option<u64>)> =
986
- std::collections::HashMap::new();
987
-
988
- let latest_progress: std::collections::HashMap<(u8, &str), usize> = progress
989
- .iter()
990
- .enumerate()
991
- .filter_map(|(i, event)| match event {
992
- AgentProgress::ToolProgress { call_id, .. } => Some(((0, call_id.as_str()), i)),
993
- AgentProgress::DeviceState {
994
- call_id, update, ..
995
- } if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
996
- Some(((1, call_id.as_str()), i))
997
- }
998
- _ => None,
999
- })
1000
- .collect();
1001
- for (index, event) in progress.iter().enumerate() {
1002
- let key = match event {
1003
- AgentProgress::ToolProgress { call_id, .. } => Some((0, call_id.as_str())),
1004
- AgentProgress::DeviceState {
1005
- call_id, update, ..
1006
- } if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
1007
- Some((1, call_id.as_str()))
1008
- }
1009
- _ => None,
1010
- };
1011
- if key.is_some_and(|key| latest_progress.get(&key) != Some(&index)) {
1012
- continue;
1013
- }
1014
- match event {
1015
- AgentProgress::ThinkingDelta {
1016
- id,
1017
- delta,
1018
- elapsed_ms,
1019
- } => {
1020
- if !completed_ids.contains(id) {
1021
- if !interrupted.contains_key(id) {
1022
- interrupted_order.push(id.clone());
1023
- }
1024
- interrupted
1025
- .entry(id.clone())
1026
- .and_modify(|(text, elapsed)| {
1027
- text.push_str(delta);
1028
- *elapsed = Some(*elapsed_ms);
1029
- })
1030
- .or_insert_with(|| (delta.clone(), Some(*elapsed_ms)));
1031
- }
1032
- }
1033
- _ => {
1034
- result.push(event.clone());
1035
- }
1036
- }
1037
- }
1038
-
1039
- for id in interrupted_order {
1040
- if let Some((thought, elapsed_ms)) = interrupted.remove(&id) {
1041
- result.push(AgentProgress::ExtendedThinking {
1042
- id: Some(id),
1043
- thought,
1044
- elapsed_ms,
1045
- });
1046
- }
1047
- }
1048
-
1049
- result
1050
- }
1051
-
1052
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1053
- #[serde(rename_all = "camelCase")]
1054
- pub struct ToolExecutionRecord {
1055
- pub step: usize,
1056
- pub action: String,
1057
- pub target: String,
1058
- pub success: bool,
1059
- pub retried: bool,
1060
- }
1061
-
1062
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1063
- #[serde(rename_all = "camelCase")]
1064
- pub struct RunTelemetrySummary {
1065
- pub run_id: String,
1066
- pub task: String,
1067
- pub outcome: String, // "SUCCESS" | "FAILED" | "ROLLED_BACK"
1068
- pub total_tokens: usize,
1069
- pub tool_calls_count: usize,
1070
- pub files_changed: Vec<String>,
1071
- #[serde(default)]
1072
- pub files_created: Vec<String>,
1073
- pub duration_secs: f64,
1074
- pub tool_timeline: Vec<ToolExecutionRecord>,
1075
- pub retries_count: usize,
1076
- #[serde(default, skip_serializing_if = "Option::is_none")]
1077
- pub trajectory: Option<trajectory_schema::StandardTrajectoryRun>,
1078
- }
1079
-
1080
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1081
- #[serde(rename_all = "camelCase")]
1082
- pub struct PlanTaskItem {
1083
- pub id: String,
1084
- pub title: String,
1085
- pub status: String, // "pending", "in_progress", "completed", "failed"
1086
- }
1087
-
1088
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1089
- #[serde(rename_all = "camelCase")]
1090
- pub struct ActivePlan {
1091
- pub objective: String,
1092
- pub tasks: Vec<PlanTaskItem>,
1093
- }
1094
-
1095
- #[derive(Debug, Clone, Serialize, Deserialize)]
1096
- #[serde(rename_all = "camelCase")]
1097
- pub struct AgentResult {
1098
- pub provider: String,
1099
- pub model: String,
1100
- pub summary: String,
1101
- pub verification: String,
1102
- #[serde(default)]
1103
- pub completion_status: String,
1104
- pub fallback: Option<String>,
1105
- #[serde(default)]
1106
- pub fallback_reason: Option<String>,
1107
- /// Sum of `total_tokens` across every step's API response this turn —
1108
- /// see the doc comment on `turn_total_tokens` where it's accumulated.
1109
- pub total_tokens: u64,
1110
- /// Prompt/context tokens the final step processed (latest-step, not summed
1111
- /// — see `AgentProgress::Thinking::input_tokens`). The `↑` half of the
1112
- /// turn footer's token counter.
1113
- #[serde(default)]
1114
- pub input_tokens: u64,
1115
- /// Sum of completion tokens generated across every step this turn — the
1116
- /// `↓` half of the turn footer's token counter.
1117
- #[serde(default)]
1118
- pub generated_tokens: u64,
1119
- }
1120
-
1121
- #[derive(Debug, Serialize)]
1122
- #[serde(rename_all = "camelCase")]
1123
- struct AgentDirectoryEntry {
1124
- name: String,
1125
- path: PathBuf,
1126
- kind: &'static str,
1127
- size: Option<u64>,
1128
- }
1129
-
1130
- #[derive(Debug, Deserialize)]
1131
- struct AgentDecision {
1132
- #[serde(default)]
1133
- thought: String,
1134
- action: String,
1135
- #[serde(default, deserialize_with = "deserialize_agent_input")]
1136
- input: AgentInput,
1137
- }
1138
-
1139
- #[derive(Debug, Clone, Default, Deserialize, Serialize)]
1140
- #[serde(rename_all = "camelCase")]
1141
- pub struct SwarmTaskInput {
1142
- #[serde(default)]
1143
- pub role: Option<String>,
1144
- #[serde(default)]
1145
- pub base: Option<String>,
1146
- #[serde(default)]
1147
- pub instruction: String,
1148
- }
1149
-
1150
- impl From<SwarmTaskInput> for crate::swarm::SwarmTask {
1151
- fn from(input: SwarmTaskInput) -> Self {
1152
- Self {
1153
- role: input.role,
1154
- base: input.base,
1155
- instruction: input.instruction,
1156
- }
1157
- }
1158
- }
1159
-
1160
- #[derive(Debug, Default, Deserialize, Serialize)]
1161
- #[serde(rename_all = "camelCase")]
1162
- struct AgentInput {
1163
- #[serde(default)]
1164
- tasks: Vec<SwarmTaskInput>,
1165
- #[serde(default)]
1166
- path: String,
1167
- #[serde(default)]
1168
- artifact_id: String,
1169
- #[serde(
1170
- default,
1171
- alias = "q",
1172
- alias = "keyword",
1173
- alias = "search",
1174
- alias = "searchTerm",
1175
- alias = "search_term"
1176
- )]
1177
- query: String,
1178
- #[serde(default, alias = "ticker")]
1179
- symbol: String,
1180
- #[serde(default)]
1181
- filter: String,
1182
- #[serde(default)]
1183
- options: Vec<AskUserOptionInput>,
1184
- #[serde(default)]
1185
- header: String,
1186
- #[serde(default, alias = "multi_select")]
1187
- multi_select: bool,
1188
- #[serde(default, alias = "location", alias = "place")]
1189
- city: String,
1190
- #[serde(default, alias = "expr", alias = "math")]
1191
- expression: String,
1192
- #[serde(
1193
- default,
1194
- alias = "cmd",
1195
- alias = "script",
1196
- alias = "exec",
1197
- alias = "code",
1198
- alias = "bash",
1199
- alias = "sh"
1200
- )]
1201
- command: String,
1202
- #[serde(default)]
1203
- background: bool,
1204
- #[serde(default, alias = "retryReason")]
1205
- retry_reason: String,
1206
- #[serde(default, alias = "job_id")]
1207
- job_id: String,
1208
- #[serde(default)]
1209
- commands: Vec<String>,
1210
- #[serde(default)]
1211
- steps: Vec<String>,
1212
- #[serde(default, alias = "file_content")]
1213
- file_content: String,
1214
- #[serde(default)]
1215
- summary: String,
1216
- #[serde(default)]
1217
- verification: String,
1218
- #[serde(default)]
1219
- completion_status: String,
1220
- #[serde(default)]
1221
- plan: String,
1222
- #[serde(default)]
1223
- reason: String,
1224
- #[serde(default, alias = "start_line")]
1225
- start_line: Option<usize>,
1226
- #[serde(default, alias = "end_line")]
1227
- end_line: Option<usize>,
1228
- #[serde(default)]
1229
- limit: Option<usize>,
1230
- #[serde(default)]
1231
- patch: Option<AgentPatch>,
1232
- #[serde(default)]
1233
- server: String,
1234
- #[serde(default)]
1235
- tool: String,
1236
- #[serde(default)]
1237
- arguments: Value,
1238
- #[serde(default, alias = "note_path")]
1239
- note_path: String,
1240
- #[serde(default)]
1241
- name: String,
1242
- #[serde(default)]
1243
- instruction: String,
1244
- #[serde(default)]
1245
- title: String,
1246
- #[serde(default)]
1247
- status: String,
1248
- #[serde(default)]
1249
- step: Option<usize>,
1250
- #[serde(default)]
1251
- kind: String,
1252
- #[serde(default)]
1253
- content: String,
1254
- #[serde(default)]
1255
- url: String,
1256
- #[serde(default)]
1257
- selector: String,
1258
- #[serde(default)]
1259
- text: String,
1260
- #[serde(default)]
1261
- x: Option<f64>,
1262
- #[serde(default)]
1263
- y: Option<f64>,
1264
- #[serde(default)]
1265
- button: String,
1266
- #[serde(default)]
1267
- key: String,
1268
- #[serde(default)]
1269
- element_ref: String,
1270
- #[serde(default)]
1271
- tab_id: String,
1272
- #[serde(default)]
1273
- operation: String,
1274
- #[serde(default)]
1275
- mode: String,
1276
- #[serde(default)]
1277
- condition: String,
1278
- #[serde(default)]
1279
- value: String,
1280
- #[serde(default)]
1281
- timeout_ms: Option<u64>,
1282
- #[serde(default)]
1283
- text_offset: Option<u64>,
1284
- #[serde(default)]
1285
- element_offset: Option<u64>,
1286
- #[serde(default)]
1287
- takeover_complete: bool,
1288
- // Video tools input fields
1289
- #[serde(default)]
1290
- input: String,
1291
- #[serde(default)]
1292
- output: String,
1293
- #[serde(default)]
1294
- start: Option<f64>,
1295
- #[serde(default)]
1296
- end: Option<f64>,
1297
- #[serde(default)]
1298
- width: Option<i32>,
1299
- #[serde(default)]
1300
- height: Option<i32>,
1301
- #[serde(default)]
1302
- frame_count: Option<u32>,
1303
- #[serde(default)]
1304
- columns: Option<u32>,
1305
- #[serde(default)]
1306
- threshold_db: Option<f64>,
1307
- #[serde(default)]
1308
- min_silence_secs: Option<f64>,
1309
- #[serde(default)]
1310
- language: Option<String>,
1311
- #[serde(default)]
1312
- target_language: Option<String>,
1313
- #[serde(default)]
1314
- srt_content: Option<String>,
1315
- #[serde(default)]
1316
- preset: Option<String>,
1317
- #[serde(default)]
1318
- max_clips: Option<u32>,
1319
- #[serde(default)]
1320
- target_duration: Option<f64>,
1321
- #[serde(default)]
1322
- inputs: Vec<String>,
1323
- #[serde(default)]
1324
- order: Vec<usize>,
1325
- #[serde(default)]
1326
- music_input: Option<String>,
1327
- #[serde(default)]
1328
- video_input: Option<String>,
1329
- #[serde(default)]
1330
- music_volume: Option<f32>,
1331
- #[serde(default)]
1332
- zoom_factor: Option<f32>,
1333
- // Image & Video generation input fields
1334
- #[serde(default)]
1335
- prompt: String,
1336
- #[serde(default)]
1337
- aspect_ratio: String,
1338
- #[serde(default)]
1339
- provider: String,
1340
- #[serde(default)]
1341
- duration: Option<f64>,
1342
- // avatar_signal input fields — Rust-side `avatar_` prefix only to read
1343
- // unambiguously in this shared flat struct; explicit renames keep the
1344
- // wire/JSON keys unprefixed (matching the relay's AvatarEvent schema —
1345
- // see `crate::avatar_bridge`).
1346
- #[serde(default, rename = "emotions")]
1347
- avatar_emotions: std::collections::HashMap<String, String>,
1348
- #[serde(default, rename = "action")]
1349
- avatar_action: String,
1350
- #[serde(default, rename = "prop")]
1351
- avatar_prop: String,
1352
- #[serde(default, rename = "intensity")]
1353
- avatar_intensity: String,
1354
- #[serde(default, rename = "color")]
1355
- avatar_color: String,
1356
- #[serde(default, rename = "talking")]
1357
- avatar_talking: Option<bool>,
1358
- }
1359
-
1360
- /// Decode shim for `ask_user`'s `options`: accepts either a bare string
1361
- /// (the legacy shape, and what a model may still emit even after the schema
1362
- /// documents the object shape) or `{label, description}`.
1363
- #[derive(Debug, Clone, Deserialize, Serialize)]
1364
- #[serde(untagged)]
1365
- enum AskUserOptionInput {
1366
- Plain(String),
1367
- Detailed {
1368
- label: String,
1369
- #[serde(default)]
1370
- description: Option<String>,
1371
- },
1372
- }
1373
-
1374
- #[derive(Debug, Deserialize, Serialize)]
1375
- #[serde(rename_all = "camelCase")]
1376
- struct AgentPatch {
1377
- path: PathBuf,
1378
- #[serde(default)]
1379
- hunks: Vec<CodePatchHunk>,
1380
- }
1381
-
1382
- fn deserialize_agent_input<'de, D>(deserializer: D) -> Result<AgentInput, D::Error>
1383
- where
1384
- D: serde::Deserializer<'de>,
1385
- {
1386
- let value = Option::<Value>::deserialize(deserializer)?;
1387
- value
1388
- .map(decode_agent_input)
1389
- .transpose()
1390
- .map_err(serde::de::Error::custom)
1391
- .map(|input| input.unwrap_or_default())
1392
- }
1393
-
1394
- /// Returns true if a string is an AI dummy placeholder such as `"null"`, `"undefined"`, `"none"`, etc.
1395
- pub fn is_dummy_value(s: &str) -> bool {
1396
- let lower = s.trim().to_ascii_lowercase();
1397
- matches!(
1398
- lower.as_str(),
1399
- "null" | "undefined" | "none" | "nil" | "(null)" | "\"\"" | "''"
1400
- )
1401
- }
1402
-
1403
- fn prune_null_values(value: &mut Value) {
1404
- if let Value::Object(map) = value {
1405
- map.retain(|k, v| {
1406
- if v.is_null() && k != "path" {
1407
- return false;
1408
- }
1409
- if let Value::String(s) = v {
1410
- if is_dummy_value(s) {
1411
- return false;
1412
- }
1413
- }
1414
- true
1415
- });
1416
- for v in map.values_mut() {
1417
- prune_null_values(v);
1418
- }
1419
- } else if let Value::Array(arr) = value {
1420
- for v in arr.iter_mut() {
1421
- prune_null_values(v);
1422
- }
1423
- }
1424
- }
1425
-
1426
- // Some providers encode the structured patch twice or double-wrap arguments.
1427
- // Decode those fields, prune nulls for robust serde defaults, and retain strict
1428
- // typing for all other arguments.
1429
- fn decode_agent_input(mut value: Value) -> Result<AgentInput, String> {
1430
- // 1. If value is a string, check if it's an encoded JSON object
1431
- if let Value::String(s) = &value {
1432
- let trimmed = s.trim();
1433
- if trimmed.starts_with('{') && trimmed.ends_with('}') {
1434
- if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1435
- if parsed.is_object() {
1436
- value = parsed;
1437
- }
1438
- }
1439
- }
1440
- }
1441
-
1442
- // 2. Prune null fields so serde default values apply for missing keys
1443
- prune_null_values(&mut value);
1444
-
1445
- // 3. Unpack nested "arguments" if top-level command is missing
1446
- if let Value::Object(map) = &mut value {
1447
- let has_command = map
1448
- .get("command")
1449
- .or_else(|| map.get("cmd"))
1450
- .or_else(|| map.get("script"))
1451
- .or_else(|| map.get("exec"))
1452
- .or_else(|| map.get("code"))
1453
- .or_else(|| map.get("bash"))
1454
- .or_else(|| map.get("sh"))
1455
- .and_then(Value::as_str)
1456
- .is_some_and(|s| !s.trim().is_empty());
1457
-
1458
- if !has_command {
1459
- if let Some(args_val) = map.get("arguments").cloned() {
1460
- let inner_obj = match args_val {
1461
- Value::Object(inner_map) => Some(inner_map),
1462
- Value::String(inner_str) => serde_json::from_str::<Value>(&inner_str)
1463
- .ok()
1464
- .and_then(|v| match v {
1465
- Value::Object(m) => Some(m),
1466
- _ => None,
1467
- }),
1468
- _ => None,
1469
- };
1470
- if let Some(inner) = inner_obj {
1471
- let mut wrapped = Value::Object(inner);
1472
- prune_null_values(&mut wrapped);
1473
- if let Value::Object(cleaned) = wrapped {
1474
- for (k, v) in cleaned {
1475
- if !map.contains_key(&k) && !v.is_null() {
1476
- map.insert(k, v);
1477
- }
1478
- }
1479
- }
1480
- }
1481
- }
1482
- }
1483
- }
1484
-
1485
- let outer_path = value.get("path").and_then(Value::as_str).map(str::to_owned);
1486
- if let Some(patch) = value.get_mut("patch").filter(|patch| !patch.is_null()) {
1487
- if let Value::String(encoded) = patch {
1488
- *patch = serde_json::from_str(encoded).map_err(|_| {
1489
- "patch must contain a valid JSON object with path and hunks".to_owned()
1490
- })?;
1491
- }
1492
- let object = patch
1493
- .as_object_mut()
1494
- .ok_or_else(|| "patch must be an object with path and hunks".to_owned())?;
1495
- if !object.contains_key("path") {
1496
- if let Some(path) = &outer_path {
1497
- object.insert("path".into(), Value::String(path.clone()));
1498
- }
1499
- }
1500
- let path = object
1501
- .get("path")
1502
- .and_then(Value::as_str)
1503
- .filter(|path| !path.trim().is_empty())
1504
- .ok_or_else(|| {
1505
- "patch.path is required; supply the target file explicitly".to_owned()
1506
- })?;
1507
- if outer_path
1508
- .as_deref()
1509
- .is_some_and(|outer| !outer.is_empty() && outer != path)
1510
- {
1511
- return Err("path and patch.path must name the same target file".into());
1512
- }
1513
- serde_json::from_value::<AgentPatch>(patch.clone())
1514
- .map_err(|_| "patch requires a string path and hunks containing string oldText/newText and optional boolean replaceAll".to_owned())?;
1515
- }
1516
- serde_json::from_value(value).map_err(|error| {
1517
- let message = error.to_string();
1518
- if message.chars().count() > 400 {
1519
- format!(
1520
- "{}… (argument value omitted)",
1521
- message.chars().take(400).collect::<String>()
1522
- )
1523
- } else {
1524
- message
1525
- }
1526
- })
1527
- }
1528
-
1529
545
  fn resolve_agent_config(
1530
546
  config: &MintConfig,
1531
547
  agent_id: Option<&str>,
@@ -1635,8 +651,12 @@ async fn stream_chat_with_network_retry(
1635
651
  on_stream_chunk: &mut (dyn FnMut(AgentStreamChunk) + Send),
1636
652
  ) -> Result<(ChatResponse, Option<String>), ChatError> {
1637
653
  let mut attempt = 0;
1638
- let mut summary_stream = FinishSummaryStream::default();
654
+ let tools_enabled = request
655
+ .tools
656
+ .as_ref()
657
+ .is_some_and(|tools| !tools.is_empty());
1639
658
  loop {
659
+ let mut summary_stream = FinishSummaryStream::default();
1640
660
  let result = stream_chat_events_with_fallback(config, request, |event| match event {
1641
661
  ChatStreamEvent::ReasoningDelta { delta } if !fast_mode && !delta.is_empty() => {
1642
662
  progress(AgentProgress::ThinkingDelta {
@@ -1650,6 +670,10 @@ async fn stream_chat_with_network_retry(
1650
670
  summary_stream.push_json_prompt(&delta, &mut |chunk| {
1651
671
  on_stream_chunk(AgentStreamChunk::FinalSummary(chunk));
1652
672
  });
673
+ } else {
674
+ summary_stream.push_native_text(&delta, tools_enabled, &mut |chunk| {
675
+ on_stream_chunk(AgentStreamChunk::DirectText(chunk));
676
+ });
1653
677
  }
1654
678
  }
1655
679
  ChatStreamEvent::ToolCallDelta {
@@ -1686,11 +710,11 @@ async fn stream_chat_with_network_retry(
1686
710
  .is_none_or(|calls| calls.is_empty())
1687
711
  && !response.text.is_empty()
1688
712
  {
1689
- // Plain text from a native tool-calling response is only a final
1690
- // answer if the completed model turn contains no tool calls. Buffer
1691
- // it until then so an assistant preamble doesn't appear under
1692
- // "Mint:" while the requested tools are still about to run.
1693
- on_stream_chunk(AgentStreamChunk::DirectText(response.text.clone()));
713
+ // Flush any remaining unstreamed direct text (such as a short
714
+ // single-line reply held while checking if a tool call followed).
715
+ summary_stream.finish_native_text(&response.text, &mut |chunk| {
716
+ on_stream_chunk(AgentStreamChunk::DirectText(chunk));
717
+ });
1694
718
  }
1695
719
  return Ok((response, fallback));
1696
720
  }
@@ -1705,6 +729,8 @@ struct FinishSummaryStream {
1705
729
  tool_arguments: std::collections::HashMap<usize, String>,
1706
730
  json_prompt: String,
1707
731
  emitted: String,
732
+ native_text: String,
733
+ native_emitted: String,
1708
734
  }
1709
735
 
1710
736
  impl FinishSummaryStream {
@@ -1732,10 +758,17 @@ impl FinishSummaryStream {
1732
758
  .tool_names
1733
759
  .get(&index)
1734
760
  .is_some_and(|name| name == "finish")
1735
- && let Some(arguments) = self.tool_arguments.get(&index)
1736
- && let Some(summary) = json_string_field_prefix(arguments, "summary")
1737
761
  {
1738
- self.emit_new_text(&summary, on_chunk);
762
+ if let Some(summary) = input
763
+ .and_then(|val| val.get("summary"))
764
+ .and_then(Value::as_str)
765
+ {
766
+ self.emit_new_text(summary, on_chunk);
767
+ } else if let Some(arguments) = self.tool_arguments.get(&index)
768
+ && let Some(summary) = json_string_field_prefix(arguments, "summary")
769
+ {
770
+ self.emit_new_text(&summary, on_chunk);
771
+ }
1739
772
  }
1740
773
  }
1741
774
 
@@ -1748,6 +781,43 @@ impl FinishSummaryStream {
1748
781
  }
1749
782
  }
1750
783
 
784
+ fn push_native_text(
785
+ &mut self,
786
+ delta: &str,
787
+ tools_enabled: bool,
788
+ on_chunk: &mut (dyn FnMut(String) + Send),
789
+ ) {
790
+ if delta.is_empty() {
791
+ return;
792
+ }
793
+ self.native_text.push_str(delta);
794
+ if !self.tool_names.is_empty() {
795
+ return;
796
+ }
797
+ let trimmed = self.native_text.trim_start();
798
+ if trimmed.is_empty() {
799
+ return;
800
+ }
801
+ let ready_to_stream = !tools_enabled
802
+ || !self.native_emitted.is_empty()
803
+ || trimmed
804
+ .find('\n')
805
+ .is_some_and(|nl| !trimmed[nl + 1..].trim().is_empty())
806
+ || trimmed.chars().count() > 160;
807
+ if ready_to_stream {
808
+ let current = trimmed.to_owned();
809
+ self.emit_new_native_text(&current, on_chunk);
810
+ }
811
+ }
812
+
813
+ fn finish_native_text(&mut self, full_text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
814
+ let trimmed = full_text.trim_start();
815
+ if !self.tool_names.is_empty() || trimmed.is_empty() {
816
+ return;
817
+ }
818
+ self.emit_new_native_text(trimmed, on_chunk);
819
+ }
820
+
1751
821
  fn emit_new_text(&mut self, text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
1752
822
  if !text.starts_with(&self.emitted) || text.len() == self.emitted.len() {
1753
823
  return;
@@ -1756,7 +826,16 @@ impl FinishSummaryStream {
1756
826
  self.emitted.push_str(&delta);
1757
827
  on_chunk(delta);
1758
828
  }
1759
- }
829
+
830
+ fn emit_new_native_text(&mut self, text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
831
+ if !text.starts_with(&self.native_emitted) || text.len() == self.native_emitted.len() {
832
+ return;
833
+ }
834
+ let delta = text[self.native_emitted.len()..].to_owned();
835
+ self.native_emitted.push_str(&delta);
836
+ on_chunk(delta);
837
+ }
838
+ }
1760
839
 
1761
840
  fn unstreamed_summary_remainder<'a>(
1762
841
  summary: &'a str,
@@ -1964,7 +1043,7 @@ where
1964
1043
  );
1965
1044
  let mut mod_usage = crate::mods::UsageTracker::new(&root, chat_id, companion_turn_id);
1966
1045
  let resolved_task = resolve_github_links(task, config).await;
1967
- let agent_result = reliability::scope(Box::pin(crate::browser::run_session(async {
1046
+ let agent_result = reliability::scope(Box::pin(async {
1968
1047
  // Subagent runs use a synthetic `{parent_chat_id}::subagent::{name}` chat id
1969
1048
  // (see the `dispatch_subagent` arm in `execute_tool`) so their own memory
1970
1049
  // interaction doesn't leak into the parent conversation's history. That
@@ -1993,8 +1072,12 @@ where
1993
1072
  // multi-agent collaboration can substitute) — matches how `system_prompt`
1994
1073
  // itself is only rebuilt on plan-mode transitions, not every step.
1995
1074
  let system_prompt_native = config.tool_calling_mode() == ToolCallingMode::Native;
1996
- let mut system_prompt = build_system_prompt(
1075
+ let mut system_prompt = construct_system_prompt(
1997
1076
  config,
1077
+ &memory,
1078
+ chat_id,
1079
+ &root,
1080
+ &resolved_task,
1998
1081
  plan_mode,
1999
1082
  system_prompt_native,
2000
1083
  user_name,
@@ -2002,18 +1085,18 @@ where
2002
1085
  );
2003
1086
  let hooks = crate::hooks::list_hooks(config);
2004
1087
 
2005
- append_memory_context(
2006
- &mut system_prompt,
1088
+ // Structured history for native tool-calling providers, pre-populated with
1089
+ // completed conversation turns so multi-turn context (e.g. earlier questions/answers)
1090
+ // is preserved natively in the model's message sequence.
1091
+ let mut native_messages = build_conversation_history_messages(
1092
+ &memory,
2007
1093
  chat_id,
2008
- Some(root.to_string_lossy().as_ref()),
2009
- Some(&resolved_task),
2010
- config.semantic_fact_recall,
1094
+ CONTEXT_LIMIT,
1095
+ MAX_CONTEXT_MESSAGE_CHARS,
2011
1096
  );
2012
- if config.memory_recall
2013
- && let Some(recall) = render_recalled_messages(chat_id, &resolved_task)
2014
- {
2015
- system_prompt = format!("{}\n\n{}", system_prompt.trim(), recall);
2016
- }
1097
+ let history_prefix_len = native_messages.len();
1098
+ let mut current_turn_added_to_native = false;
1099
+
2017
1100
  // Rough token estimate for the very first request going out — lets
2018
1101
  // the CLI seed its live counter before step 1's real response comes
2019
1102
  // back and there's nothing else to go on yet (~4 chars/token, a
@@ -2022,8 +1105,16 @@ where
2022
1105
  // this — so it undercounts a bit for native tool-calling, but only
2023
1106
  // needs to be in the right ballpark for a still-updating live label,
2024
1107
  // not exact).
1108
+ let history_chars: usize = native_messages
1109
+ .iter()
1110
+ .flat_map(|m| &m.content)
1111
+ .map(|b| match b {
1112
+ ContentBlock::Text { text } => text.chars().count(),
1113
+ _ => 0,
1114
+ })
1115
+ .sum();
2025
1116
  let estimated_first_step_tokens =
2026
- ((system_prompt.chars().count() + observation.chars().count()) / 4) as u64;
1117
+ ((system_prompt.chars().count() + observation.chars().count() + history_chars) / 4) as u64;
2027
1118
 
2028
1119
  #[allow(unused_assignments)]
2029
1120
  let mut final_provider = config.ai_provider.clone();
@@ -2047,11 +1138,6 @@ where
2047
1138
  &config.ai_provider,
2048
1139
  );
2049
1140
  let mut trajectory: Vec<String> = Vec::new();
2050
- // Structured history for native tool-calling providers, maintained alongside
2051
- // `trajectory`/`observation` (which remain the source of truth for the
2052
- // JSON-prompt fallback path and for the web-search/media-append scans in the
2053
- // finish handler below, which operate on flattened text either way).
2054
- let mut native_messages: Vec<ChatMessage> = Vec::new();
2055
1141
  // Gemini's `thoughtSignature` (see `ToolCall::thought_signature`) arrives on
2056
1142
  // `response.tool_calls`, keyed by call id, but the `ContentBlock::ToolUse`
2057
1143
  // that gets pushed onto `native_messages` for this call is only rebuilt
@@ -2121,7 +1207,20 @@ where
2121
1207
  if !mcp_context.is_empty() { active_system_prompt.push_str(&format!("\n\n{mcp_context}")); }
2122
1208
  // Pin the catalog and only successfully read, unchanged skill bodies
2123
1209
  // to every request, including after JSON history rebuilds/compaction.
2124
- let visible_history = if active_config.tool_calling_mode() == ToolCallingMode::Native { render_messages_as_text(&native_messages) } else { observation.clone() };
1210
+ let visible_history = if active_config.tool_calling_mode() == ToolCallingMode::Native {
1211
+ if !current_turn_added_to_native {
1212
+ let history = render_messages_as_text(&native_messages);
1213
+ if history.is_empty() {
1214
+ observation.clone()
1215
+ } else {
1216
+ format!("{history}\n\nUser: {observation}")
1217
+ }
1218
+ } else {
1219
+ render_messages_as_text(&native_messages)
1220
+ }
1221
+ } else {
1222
+ observation.clone()
1223
+ };
2125
1224
  let skill_context = crate::skills::learned_skills_context_with_history(Some(&root), Some(chat_id), &visible_history)
2126
1225
  .unwrap_or_default();
2127
1226
  if !skill_context.is_empty() {
@@ -2186,37 +1285,20 @@ where
2186
1285
  }
2187
1286
 
2188
1287
  let (response, fallback) = if tool_mode == ToolCallingMode::Native {
2189
- if native_messages.is_empty() {
1288
+ if !current_turn_added_to_native {
1289
+ current_turn_added_to_native = true;
2190
1290
  // Native tool-calling providers build the request from `messages`
2191
1291
  // and ignore ChatRequest.image_data_uri/audio_data_uri/video_data_uri
2192
1292
  // entirely, so any pending attachment must be embedded as content
2193
1293
  // blocks on the first user turn here, not left for the (unused)
2194
1294
  // top-level fields — otherwise Agent Mode would silently drop
2195
1295
  // attachments that work fine with Agent Mode off.
2196
- let mut content = vec![ContentBlock::Text {
2197
- text: observation.clone(),
2198
- }];
2199
- if let Some(image_data) = pending_image.take() {
2200
- for img in image_data.split_whitespace() {
2201
- content.push(ContentBlock::Image {
2202
- data_uri: img.to_owned(),
2203
- });
2204
- }
2205
- }
2206
- if let Some(audio_data) = pending_audio.take() {
2207
- for aud in audio_data.split_whitespace() {
2208
- content.push(ContentBlock::Audio {
2209
- data_uri: aud.to_owned(),
2210
- });
2211
- }
2212
- }
2213
- if let Some(video_data) = pending_video.take() {
2214
- for vid in video_data.split_whitespace() {
2215
- content.push(ContentBlock::Video {
2216
- data_uri: vid.to_owned(),
2217
- });
2218
- }
2219
- }
1296
+ let content = build_user_content_blocks(
1297
+ observation.clone(),
1298
+ pending_image.take().as_deref(),
1299
+ pending_audio.take().as_deref(),
1300
+ pending_video.take().as_deref(),
1301
+ );
2220
1302
  native_messages.push(ChatMessage {
2221
1303
  role: ChatRole::User,
2222
1304
  content,
@@ -2377,6 +1459,7 @@ where
2377
1459
  let decisions: Vec<(String, AgentDecision)> = if tool_mode == ToolCallingMode::Native {
2378
1460
  match response.tool_calls.clone() {
2379
1461
  Some(calls) if !calls.is_empty() => {
1462
+ streamed_direct_text.clear();
2380
1463
  let (decisions, errors) = prepare_native_tool_calls(
2381
1464
  calls, response.text.trim(), &mut invalid_tool_calls,
2382
1465
  )?;
@@ -2553,7 +1636,19 @@ where
2553
1636
  }
2554
1637
 
2555
1638
  if decision.action == "finish" {
2556
- let mut summary = decision.input.summary.trim().to_owned();
1639
+ let (inline_summary_thought, cleaned_summary) =
1640
+ crate::chat::extract_think_tag(decision.input.summary.trim());
1641
+ if let Some(th) = inline_summary_thought
1642
+ && !fast_mode
1643
+ && !extended_emitted
1644
+ {
1645
+ progress(AgentProgress::ExtendedThinking {
1646
+ id: Some(thought_id.clone()),
1647
+ thought: th,
1648
+ elapsed_ms: Some(thought_started.elapsed().as_millis() as u64),
1649
+ });
1650
+ }
1651
+ let mut summary = cleaned_summary.trim().to_owned();
2557
1652
  if summary.is_empty() {
2558
1653
  let err_msg = "Error: Your finish action summary was empty. \
2559
1654
  You MUST provide a final answer, explanation, or response to the user's query \
@@ -2573,13 +1668,6 @@ where
2573
1668
  );
2574
1669
  continue 'steps;
2575
1670
  }
2576
- if crate::browser::session_finish_evidence().await && meaningful_verification(&decision.input.verification).is_empty() {
2577
- let error = "Browser tasks require observed evidence in finish.verification. Describe the observed outcome, or explicitly state that completion is unverified or blocked.";
2578
- trajectory.push(format_trajectory_step(step, &decision.thought, &decision.action, error));
2579
- rebuild_observation(task, &root, &trajectory, &mut observation);
2580
- reject_native_finish(tool_mode, &mut native_messages, &response.text, error);
2581
- continue 'steps;
2582
- }
2583
1671
  let completion_status = match reliability::completion(&decision.input.completion_status, &decision.input.verification) {
2584
1672
  Ok(status) => status,
2585
1673
  Err(error) => {
@@ -2797,8 +1885,12 @@ where
2797
1885
  }) {
2798
1886
  Ok(ApprovalOutcome::Approved) => {
2799
1887
  plan_mode = true;
2800
- system_prompt = build_system_prompt(
1888
+ system_prompt = construct_system_prompt(
2801
1889
  config,
1890
+ &memory,
1891
+ chat_id,
1892
+ &root,
1893
+ &resolved_task,
2802
1894
  plan_mode,
2803
1895
  system_prompt_native,
2804
1896
  user_name,
@@ -2834,7 +1926,17 @@ where
2834
1926
  }) {
2835
1927
  Ok(ApprovalOutcome::Approved) => {
2836
1928
  plan_mode = false;
2837
- system_prompt = build_system_prompt(config, plan_mode, system_prompt_native, user_name, pinned_mcp_server);
1929
+ system_prompt = construct_system_prompt(
1930
+ config,
1931
+ &memory,
1932
+ chat_id,
1933
+ &root,
1934
+ &resolved_task,
1935
+ plan_mode,
1936
+ system_prompt_native,
1937
+ user_name,
1938
+ pinned_mcp_server,
1939
+ );
2838
1940
  "Plan approved by the user. Plan mode is now OFF — write_file, apply_patch, run_shell, and other previously blocked tools are now available. Proceed with implementing the plan.".to_string()
2839
1941
  }
2840
1942
  Ok(ApprovalOutcome::Denied) => {
@@ -3057,8 +2159,7 @@ where
3057
2159
  let mut final_result = if result.starts_with("data:image/")
3058
2160
  && matches!(
3059
2161
  decision.action.as_str(),
3060
- "browser_screenshot"
3061
- | "video_filmstrip"
2162
+ "video_filmstrip"
3062
2163
  | "video_waveform"
3063
2164
  | "view_image"
3064
2165
  ) {
@@ -3070,11 +2171,10 @@ where
3070
2171
  "video_waveform" => "[Waveform generated — see attached image: \
3071
2172
  audio amplitude over time]"
3072
2173
  .to_string(),
3073
- "view_image" => "[Image loaded — see attached image]".to_string(),
3074
- _ => "[Screenshot captured — see attached image]".to_string(),
2174
+ _ => "[Image loaded — see attached image]".to_string(),
3075
2175
  }
3076
2176
  } else {
3077
- model_tool_result(&decision.action, &result)
2177
+ model_tool_result(Some(&root), &decision.action, &result)
3078
2178
  };
3079
2179
  if matches!(
3080
2180
  decision.action.as_str(),
@@ -3103,7 +2203,19 @@ where
3103
2203
  concise verification read.]",
3104
2204
  );
3105
2205
  }
3106
- if action_count >= 3 && !decision.action.starts_with("browser_") {
2206
+ if is_observation_action(&decision.action, &decision.input.command) {
2207
+ let mut observed = result.clone();
2208
+ if let Some(images) = step_images.get(&call_id) {
2209
+ for image in images {
2210
+ observed.push_str(image);
2211
+ }
2212
+ }
2213
+ if let Some(notice) = repetition_guard.observe_result(&decision.action, &observed) {
2214
+ final_result.push_str("\n\n");
2215
+ final_result.push_str(&notice);
2216
+ }
2217
+ }
2218
+ if action_count >= 3 {
3107
2219
  final_result.push_str(
3108
2220
  "\n\n[System Tip: You repeated the same tool action three or more times. \
3109
2221
  Stop repeating it. If you already have enough information or the requested edit is done, \
@@ -3182,6 +2294,15 @@ where
3182
2294
  &mut native_messages,
3183
2295
  MAX_RETAINED_IMAGE_STEPS,
3184
2296
  );
2297
+ let modified = extract_modified_paths(&step_tool_results);
2298
+ if !modified.is_empty() {
2299
+ supersede_stale_file_reads(&mut native_messages, &modified, step);
2300
+ }
2301
+ prune_historic_tool_results_from_native_messages(
2302
+ &mut native_messages,
2303
+ 3,
2304
+ 7,
2305
+ );
3185
2306
 
3186
2307
  if let Some(total_tokens) = response.total_tokens {
3187
2308
  let window = active_config.context_window_tokens();
@@ -3191,12 +2312,12 @@ where
3191
2312
  let compaction_needed = (total_tokens as f64) >= (window as f64) * COMPACTION_TRIGGER_RATIO
3192
2313
  || (step >= ADAPTIVE_COMPACTION_MIN_STEPS && total_tokens >= ADAPTIVE_COMPACTION_TOKEN_CEILING);
3193
2314
  if compaction_needed {
3194
- let eligible = native_messages.len().saturating_sub(1) / 2 > COMPACTION_KEEP_RECENT_STEPS;
2315
+ let eligible = native_messages.len().saturating_sub(history_prefix_len + 1) / 2 > COMPACTION_KEEP_RECENT_STEPS;
3195
2316
  if let Ok(Some((compacted, _))) = run_control::run(report_context_compaction(
3196
2317
  eligible,
3197
2318
  total_tokens,
3198
2319
  window,
3199
- compact_native_messages(&active_config, &native_messages),
2320
+ compact_native_messages(&active_config, &native_messages, history_prefix_len),
3200
2321
  &mut progress,
3201
2322
  )).await {
3202
2323
  native_messages = compacted;
@@ -3366,7 +2487,7 @@ where
3366
2487
  input_tokens: last_input_tokens,
3367
2488
  generated_tokens: turn_generated_tokens,
3368
2489
  });
3369
- }))).await;
2490
+ })).await;
3370
2491
  if agent_result.is_err() && !turn.finished {
3371
2492
  if run_control::cancelled() {
3372
2493
  turn.interrupt();
@@ -3406,7 +2527,7 @@ fn provider_truncated_response(stop_reason: Option<&str>) -> bool {
3406
2527
  )
3407
2528
  }
3408
2529
 
3409
- fn plan_mode_allows(action: &str, input: &AgentInput) -> bool {
2530
+ pub(super) fn plan_mode_allows(action: &str, input: &AgentInput) -> bool {
3410
2531
  if action == "run_shell" {
3411
2532
  return classify_shell_command(&input.command).mode.as_str() == "readOnly";
3412
2533
  }
@@ -3575,3061 +2696,11 @@ pub(super) async fn dispatch_swarm(
3575
2696
  Ok(crate::swarm::format_batch_result(&outcomes))
3576
2697
  }
3577
2698
 
3578
- #[allow(clippy::too_many_arguments)]
3579
- async fn run_parallel_read_only_batch(
3580
- decisions: &[(String, AgentDecision)],
3581
- step: usize,
3582
- root: &Path,
3583
- config: &MintConfig,
3584
- chat_id: &str,
3585
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3586
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3587
- action_counts: &mut BTreeMap<String, usize>,
3588
- trajectory: &mut Vec<String>,
3589
- step_images: &mut std::collections::HashMap<String, Vec<String>>,
3590
- hooks: &[crate::hooks::HookEntry],
3591
- pinned_mcp_server: Option<&str>,
3592
- fast_mode: bool,
3593
- ) -> Vec<ToolResultEntry> {
3594
- let approve_mutex = std::sync::Mutex::new(approve);
3595
- let progress_mutex = std::sync::Mutex::new(progress);
3596
-
3597
- let mut tasks = Vec::with_capacity(decisions.len());
3598
- for (index, (call_id, decision)) in decisions.iter().enumerate() {
3599
- let call_id = call_id.clone();
3600
- let thought = decision.thought.clone();
3601
- let action = decision.action.clone();
3602
- let input_val = tool_progress_input(&root, &decision);
3603
- let action_key = action_fingerprint(decision);
3604
- let approve_mutex = &approve_mutex;
3605
- let progress_mutex = &progress_mutex;
3606
- let decision = decision;
3607
-
3608
- tasks.push(async move {
3609
- let activity_call_id=call_id.clone();
3610
- if !fast_mode && !thought.trim().is_empty() {
3611
- let t = thought.trim();
3612
- let mut guard = progress_mutex.lock().unwrap();
3613
- if is_internal_cot(t) {
3614
- (*guard)(AgentProgress::ExtendedThinking {
3615
- id: None,
3616
- thought: t.to_owned(),
3617
- elapsed_ms: None,
3618
- });
3619
- } else {
3620
- (*guard)(AgentProgress::Thought {
3621
- thought: t.to_owned(),
3622
- });
3623
- }
3624
- }
3625
-
3626
- {
3627
- let mut guard = progress_mutex.lock().unwrap();
3628
- (*guard)(AgentProgress::ToolStart { call_id: Some(activity_call_id.clone()),
3629
- action: action.clone(),
3630
- input: input_val.clone(),
3631
- subagent: None,
3632
- });
3633
- }
3634
-
3635
- let mut images=Vec::new();
3636
- let mut typed_status=None;
3637
- let result: String = if pinned_mcp_server.is_some_and(|p| {
3638
- (action == "mcp_tool" || action == "mcp_list_tools") && decision.input.server != p
3639
- }) {
3640
- let p = pinned_mcp_server.unwrap();
3641
- format!(
3642
- "Blocked: this turn is pinned to the \"{p}\" MCP server only (selected via @{p} in the composer). Retry with \"server\":\"{p}\", or use a different (non-MCP) tool."
3643
- )
3644
- } else {
3645
- match crate::hooks::run_pre_tool_hooks(hooks, &action, &input_val, root) {
3646
- crate::hooks::PreHookOutcome::Blocked(reason) => {
3647
- format!(
3648
- "Blocked by hook: {}\n\n[System Tip: A configured PreToolUse hook rejected this action. Adjust your approach or ask the user for guidance.]",
3649
- reason
3650
- )
3651
- }
3652
- crate::hooks::PreHookOutcome::Allowed => {
3653
- let mut approve_adapter =
3654
- |approval: &AgentApproval| -> Result<ApprovalOutcome, String> {
3655
- let mut guard = approve_mutex.lock().unwrap();
3656
- (*guard)(approval)
3657
- };
3658
- let mut progress_adapter = |event: AgentProgress| {
3659
- let mut guard = progress_mutex.lock().unwrap();
3660
- (*guard)(event);
3661
- };
3662
-
3663
- let (tool_result, success) = match execute_tool_outcome(
3664
- root,
3665
- config,
3666
- &decision,
3667
- chat_id,
3668
- &mut approve_adapter,
3669
- &mut progress_adapter,
3670
- &activity_call_id,
3671
- )
3672
- .await
3673
- {
3674
- Ok(outcome) => {images=outcome.images;typed_status=Some(outcome.status);(outcome.text,outcome.status==crate::mcp_result::ToolStatus::Success)},
3675
- Err(err) => (format!("Error: {}", err), false),
3676
- };
3677
-
3678
- let hook_messages = crate::hooks::run_post_tool_hooks(
3679
- hooks,
3680
- &action,
3681
- &input_val,
3682
- &tool_result,
3683
- success,
3684
- root,
3685
- );
3686
-
3687
- if hook_messages.is_empty() {
3688
- tool_result
3689
- } else {
3690
- format!(
3691
- "{}\n\n{}",
3692
- tool_result,
3693
- hook_messages
3694
- .iter()
3695
- .map(|msg| format!("[Hook] {}", msg))
3696
- .collect::<Vec<_>>()
3697
- .join("\n")
3698
- )
3699
- }
3700
- }
3701
- }
3702
- };
3703
-
3704
- {
3705
- let mut guard = progress_mutex.lock().unwrap();
3706
- (*guard)(AgentProgress::ToolEnd { elapsed_ms: None, call_id: Some(activity_call_id.clone()), status: typed_status,
3707
- action: action.clone(),
3708
- input: input_val.clone(),
3709
- result: result.clone(),
3710
- subagent: None,
3711
- });
3712
- }
3713
-
3714
- (
3715
- index, call_id, thought, action, input_val, action_key, result, images, typed_status,
3716
- )
3717
- });
3718
- }
3719
-
3720
- let mut results = futures_util::stream::iter(tasks)
3721
- .buffer_unordered(PARALLEL_READ_ONLY_LIMIT)
3722
- .collect::<Vec<_>>()
3723
- .await;
3724
-
3725
- results.sort_by_key(|(index, ..)| *index);
3726
-
3727
- let mut step_tool_results = Vec::with_capacity(results.len());
3728
- for (_, call_id, thought, action, input_val, action_key, result, images, typed_status) in
3729
- results
3730
- {
3731
- if !images.is_empty() {
3732
- step_images.insert(call_id.clone(), images);
3733
- }
3734
- let action_count = {
3735
- let count = action_counts.entry(action_key).or_insert(0);
3736
- *count += 1;
3737
- *count
3738
- };
3739
-
3740
- let mut final_result = if result.starts_with("data:image/")
3741
- && matches!(
3742
- action.as_str(),
3743
- "browser_screenshot" | "video_filmstrip" | "video_waveform" | "view_image"
3744
- ) {
3745
- step_images.insert(call_id.clone(), vec![result.clone()]);
3746
- match action.as_str() {
3747
- "video_filmstrip" => {
3748
- "[Filmstrip generated — see attached image: sampled frames across the video timeline]".to_string()
3749
- }
3750
- "video_waveform" => {
3751
- "[Waveform generated — see attached image: audio amplitude over time]".to_string()
3752
- }
3753
- "view_image" => "[Image loaded — see attached image]".to_string(),
3754
- _ => "[Screenshot captured — see attached image]".to_string(),
3755
- }
3756
- } else {
3757
- model_tool_result(&action, &result)
3758
- };
3759
-
3760
- if action_count >= 3 && !action.starts_with("browser_") {
3761
- final_result.push_str(
3762
- "\n\n[System Tip: You repeated the same tool action three or more times. \
3763
- Stop repeating it. If you already have enough information or the requested edit is done, \
3764
- use the finish action now. Otherwise choose a different necessary action.]",
3765
- );
3766
- }
3767
-
3768
- trajectory.push(format_trajectory_step(
3769
- step,
3770
- &thought,
3771
- &action,
3772
- &final_result,
3773
- ));
3774
-
3775
- let mut entry = ToolResultEntry::from((call_id, action, input_val, final_result));
3776
- if let Some(status) = typed_status {
3777
- entry.status = status;
3778
- }
3779
- step_tool_results.push(entry);
3780
- }
3781
-
3782
- step_tool_results
3783
- }
3784
-
3785
- // Takes a trait object rather than being generic over `Approve` like it used
3786
- // to be: `dispatch_subagent` recurses back into `orchestrate_agent_loop`
3787
- // (which is itself generic over its own `Approve`), and reborrowing a generic
3788
- // `&mut Approve` into that recursive call makes the type grow by one `&mut`
3789
- // layer per nesting level during monomorphization — `&mut Approve`, then
3790
- // `&mut &mut Approve`, then `&mut &mut &mut Approve`, forever, since the type
3791
- // system has no way to know the runtime depth limit that
3792
- // `tool_catalog`/`allow_subagent_dispatch` enforces. A `&mut dyn FnMut(...)`
3793
- // trait object is a single fixed type regardless of recursion depth, so it
3794
- // doesn't hit that.
3795
-
3796
- async fn execute_tool(
3797
- root: &Path,
3798
- config: &MintConfig,
3799
- decision: &AgentDecision,
3800
- chat_id: &str,
3801
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3802
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3803
- ) -> Result<String, OrchestrationError> {
3804
- Ok(execute_tool_outcome(
3805
- root,
3806
- config,
3807
- decision,
3808
- chat_id,
3809
- approve,
3810
- progress,
3811
- &uuid::Uuid::new_v4().to_string(),
3812
- )
3813
- .await?
3814
- .text)
3815
- }
3816
-
3817
- async fn execute_tool_outcome(
3818
- root: &Path,
3819
- config: &MintConfig,
3820
- decision: &AgentDecision,
3821
- chat_id: &str,
3822
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3823
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3824
- call_id: &str,
3825
- ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3826
- execute_tool_outcome_reviewed(
3827
- root, config, decision, chat_id, approve, progress, call_id, None,
3828
- )
3829
- .await
3830
- }
3831
-
3832
- #[allow(clippy::too_many_arguments)]
3833
- async fn execute_tool_outcome_reviewed(
3834
- root: &Path,
3835
- config: &MintConfig,
3836
- decision: &AgentDecision,
3837
- chat_id: &str,
3838
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3839
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3840
- call_id: &str,
3841
- expected: Option<&mcp_review::McpReview>,
3842
- ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3843
- run_control::check()?;
3844
- let origin = MOD_ORIGIN.try_with(Clone::clone).ok().flatten();
3845
- let input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3846
- crate::mods::event(
3847
- root,
3848
- chat_id,
3849
- "pre_tool_use",
3850
- serde_json::json!({"callId":call_id,"action":decision.action,"input":input}),
3851
- origin.as_deref(),
3852
- )
3853
- .map_err(OrchestrationError::Agent)?;
3854
- let execution = execute_tool_outcome_reviewed_inner(
3855
- root, config, decision, chat_id, approve, progress, call_id, expected,
3856
- );
3857
- let result = if matches!(
3858
- decision.action.as_str(),
3859
- "run_shell" | "verify" | "run_tests" | "run_typecheck" | "run_linter"
3860
- ) {
3861
- // Foreground shell cancellation joins process cleanup and imports any
3862
- // checkpointed screenshots before the turn is marked interrupted.
3863
- execution.await
3864
- } else {
3865
- run_control::run(Box::pin(execution)).await
3866
- };
3867
- let _ = crate::mods::event(
3868
- root,
3869
- chat_id,
3870
- "post_tool_use",
3871
- serde_json::json!({"callId":call_id,"action":decision.action,"input":input,"result":result.as_ref().map(|r|r.text.clone()).unwrap_or_else(|e|e.to_string()),"success":result.is_ok()}),
3872
- origin.as_deref(),
3873
- );
3874
- result
3875
- }
3876
-
3877
- tokio::task_local! { static MOD_ORIGIN: Option<String>; }
3878
-
3879
- /// Run queued requests through the same policy and approval path as agent tools.
3880
- pub async fn execute_mod_requests(
3881
- root: &Path,
3882
- chat: &str,
3883
- config: &MintConfig,
3884
- plan_mode: bool,
3885
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3886
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3887
- selected: Option<&[String]>,
3888
- ) -> Result<(), String> {
3889
- let requests = match selected {
3890
- Some(ids) => crate::mods::take_selected_requests(root, chat, ids)?,
3891
- None => crate::mods::take_requests(root, chat)?,
3892
- };
3893
- let (remembered_plan, pin) = crate::mods::turn_policy(root, chat);
3894
- let plan_mode = plan_mode || remembered_plan;
3895
- for request in requests {
3896
- let action = request["action"].as_str().unwrap_or("");
3897
- let id = request["modId"].as_str().unwrap_or("");
3898
- let input = match decode_agent_input(request["input"].clone()) {
3899
- Ok(input) => input,
3900
- Err(error) => {
3901
- crate::mods::complete_request(
3902
- root,
3903
- chat,
3904
- id,
3905
- request["hash"].as_str().unwrap_or(""),
3906
- serde_json::json!({"requestId":request["id"],"action":action,"error":error}),
3907
- )?;
3908
- continue;
3909
- }
3910
- };
3911
- let allowed = crate::prompts::agent::base_allowed_actions().contains(&action)
3912
- && !action.starts_with("mod_")
3913
- && !config.disabled_tools.iter().any(|t| t == action)
3914
- && (!plan_mode || plan_mode_allows(action, &input))
3915
- && pin.as_deref().is_none_or(|p| {
3916
- !matches!(action, "mcp_tool" | "mcp_list_tools") || input.server == p
3917
- });
3918
- let result = if allowed {
3919
- let decision = AgentDecision {
3920
- thought: String::new(),
3921
- action: action.into(),
3922
- input,
3923
- };
3924
- MOD_ORIGIN
3925
- .scope(
3926
- Some(id.into()),
3927
- execute_tool_outcome_reviewed(
3928
- root,
3929
- config,
3930
- &decision,
3931
- chat,
3932
- approve,
3933
- progress,
3934
- request["id"].as_str().unwrap_or(""),
3935
- None,
3936
- ),
3937
- )
3938
- .await
3939
- .map(|r| r.text)
3940
- .map_err(|e| e.to_string())
3941
- } else {
3942
- Err("This tool is unavailable to mods in the current mode".into())
3943
- };
3944
- crate::mods::complete_request(
3945
- root,
3946
- chat,
3947
- id,
3948
- request["hash"].as_str().unwrap_or(""),
3949
- serde_json::json!({"requestId":request["id"],"action":action,"result":result.as_ref().ok(),"error":result.as_ref().err()}),
3950
- )?;
3951
- }
3952
- Ok(())
3953
- }
2699
+ #[cfg(test)]
2700
+ mod tests;
3954
2701
 
3955
- #[allow(clippy::too_many_arguments)]
3956
- async fn execute_tool_outcome_reviewed_inner(
3957
- root: &Path,
3958
- config: &MintConfig,
3959
- decision: &AgentDecision,
3960
- chat_id: &str,
3961
- approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3962
- progress: &mut (dyn FnMut(AgentProgress) + Send),
3963
- call_id: &str,
3964
- expected: Option<&mcp_review::McpReview>,
3965
- ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3966
- run_control::check()?;
3967
- if decision.action == "view_image" {
3968
- if decision.input.path.is_empty() == decision.input.artifact_id.is_empty() {
3969
- return Err(OrchestrationError::Agent(
3970
- "Provide exactly one of path or artifactId".into(),
3971
- ));
3972
- }
3973
- let root = root.to_path_buf();
3974
- let config = config.clone();
3975
- let chat = chat_id.to_owned();
3976
- let call = call_id.to_owned();
3977
- let path = decision.input.path.clone();
3978
- let artifact_id = decision.input.artifact_id.clone();
3979
- let out = tokio::task::spawn_blocking(
3980
- move || -> Result<crate::mcp_result::ToolOutcome, String> {
3981
- if !artifact_id.is_empty() {
3982
- return crate::media::tool_images::view_artifact(&artifact_id, &chat);
3983
- }
3984
- let path = agent_read_path(&root, &path, &config).map_err(|e| e.to_string())?;
3985
- let path = assert_path_capability(&path, Capability::Read, &config)
3986
- .map_err(|e| e.to_string())?;
3987
- if std::fs::metadata(&path).map_err(|e| e.to_string())?.len()
3988
- > crate::media::tool_images::ORIGINAL_LIMIT as u64
3989
- {
3990
- return Err("Image exceeds 20 MiB".into());
3991
- }
3992
- let mime = match path
3993
- .extension()
3994
- .and_then(|e| e.to_str())
3995
- .unwrap_or("")
3996
- .to_ascii_lowercase()
3997
- .as_str()
3998
- {
3999
- "png" => "image/png",
4000
- "jpg" | "jpeg" => "image/jpeg",
4001
- "gif" => "image/gif",
4002
- "webp" => "image/webp",
4003
- "svg" => "image/svg+xml",
4004
- _ => return Err("Unsupported image type".into()),
4005
- };
4006
- let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
4007
- crate::media::tool_images::save(
4008
- &bytes,
4009
- mime,
4010
- path.file_name().and_then(|n| n.to_str()).unwrap_or("Image"),
4011
- &chat,
4012
- &call,
4013
- "view_image",
4014
- None,
4015
- )
4016
- },
4017
- )
4018
- .await
4019
- .map_err(|e| OrchestrationError::Agent(e.to_string()))?
4020
- .map_err(OrchestrationError::Agent)?;
4021
- progress(AgentProgress::ToolArtifacts {
4022
- call_id: call_id.into(),
4023
- artifacts: out.artifacts.clone(),
4024
- warnings: out.warnings.clone(),
4025
- });
4026
- return Ok(out);
4027
- }
4028
- if matches!(decision.action.as_str(), "mcp_tool" | "mcp_list_tools") {
4029
- let mut out = tools::plugins_mcp::execute_reviewed(
4030
- &decision.action,
4031
- &decision.input,
4032
- config,
4033
- chat_id,
4034
- call_id,
4035
- expected,
4036
- approve,
4037
- progress,
4038
- )
4039
- .await?;
4040
- if !out.images.is_empty()
4041
- && config.extra.get("mcpImageInput").and_then(Value::as_bool) == Some(false)
4042
- {
4043
- out.images.clear();
4044
- let warning="Model image input is disabled for MCP. Use attachment metadata only; do not claim visual inspection.".to_string();
4045
- out.text.push_str(&format!("\nWarning: {warning}"));
4046
- out.warnings.push(warning);
4047
- }
4048
- if !out.artifacts.is_empty() || !out.warnings.is_empty() {
4049
- progress(AgentProgress::ToolArtifacts {
4050
- call_id: call_id.into(),
4051
- artifacts: out.artifacts.clone(),
4052
- warnings: out.warnings.clone(),
4053
- });
4054
- }
4055
- Ok(out)
4056
- } else {
4057
- let mut tool_config = config.clone();
4058
- let bridge = if decision.action == "run_shell" && !decision.input.background {
4059
- crate::browser::workflow::ShellBridge::prepare(
4060
- &decision.input.command,
4061
- root,
4062
- &mut tool_config,
4063
- )
4064
- .map_err(OrchestrationError::Agent)?
4065
- } else {
4066
- None
4067
- };
4068
- let checkpoint = reliability::checkpoint();
4069
- let text = match execute_tool_text(root, &tool_config, decision, chat_id, approve, progress)
4070
- .await
4071
- {
4072
- Ok(text) => text,
4073
- Err(error) => {
4074
- if decision.action.starts_with("browser_") {
4075
- reliability::record_browser(&decision.action, None, false);
4076
- }
4077
- reliability::complete_tool(&decision.action, call_id, checkpoint, false);
4078
- return Err(error);
4079
- }
4080
- };
4081
- let mut out = if decision.action == "browser_screenshot"
4082
- && text.starts_with("data:image/png;base64,")
4083
- {
4084
- let bytes = BASE64_STANDARD
4085
- .decode(text.trim_start_matches("data:image/png;base64,"))
4086
- .map_err(|e| OrchestrationError::Agent(e.to_string()))?;
4087
- let metadata = crate::browser::capture_metadata().await;
4088
- let out = crate::media::tool_images::save(
4089
- &bytes,
4090
- "image/png",
4091
- &format!("screenshot-{call_id}.png"),
4092
- chat_id,
4093
- call_id,
4094
- "browser",
4095
- Some(metadata),
4096
- )
4097
- .map_err(OrchestrationError::Agent)?;
4098
- progress(AgentProgress::ToolArtifacts {
4099
- call_id: call_id.into(),
4100
- artifacts: out.artifacts.clone(),
4101
- warnings: out.warnings.clone(),
4102
- });
4103
- out
4104
- } else {
4105
- crate::mcp_result::ToolOutcome::from(text)
4106
- };
4107
- let mut workflow_verified = None;
4108
- if let Some(bridge) = bridge {
4109
- match bridge.images(chat_id, call_id) {
4110
- Ok((images, verified)) => {
4111
- workflow_verified = Some(verified);
4112
- out.text.push_str(&images.text);
4113
- out.images.extend(images.images);
4114
- out.artifacts.extend(images.artifacts);
4115
- out.warnings.extend(images.warnings);
4116
- progress(AgentProgress::ToolArtifacts {
4117
- call_id: call_id.into(),
4118
- artifacts: out.artifacts.clone(),
4119
- warnings: out.warnings.clone(),
4120
- });
4121
- }
4122
- Err(error) => {
4123
- out.text.push_str(&format!("\nWarning: {error}"));
4124
- out.warnings.push(error);
4125
- workflow_verified = Some(false);
4126
- }
4127
- }
4128
- }
4129
- if decision.action.starts_with("browser_") {
4130
- let report = serde_json::from_str::<Value>(&out.text).ok();
4131
- if report
4132
- .as_ref()
4133
- .is_some_and(|r| r.get("error").is_some_and(|e| !e.is_null()))
4134
- {
4135
- out.status = crate::mcp_result::ToolStatus::Failed;
4136
- }
4137
- reliability::record_browser(
4138
- &decision.action,
4139
- report.as_ref(),
4140
- out.status == crate::mcp_result::ToolStatus::Success,
4141
- );
4142
- }
4143
- let mut entries = reliability::complete_tool(
4144
- &decision.action,
4145
- call_id,
4146
- checkpoint,
4147
- out.status == crate::mcp_result::ToolStatus::Success,
4148
- );
4149
- if let Some(verified) = workflow_verified {
4150
- entries = reliability::mark_workflow(call_id, verified);
4151
- }
4152
- if decision.action == "shell_output" {
4153
- entries = reliability::for_job(&decision.input.job_id);
4154
- }
4155
- if !entries.is_empty() {
4156
- out.status = if entries
4157
- .iter()
4158
- .any(|e| e.state == reliability::ExecutionState::Cancelled)
4159
- {
4160
- crate::mcp_result::ToolStatus::Cancelled
4161
- } else if entries
4162
- .iter()
4163
- .any(|e| e.state == reliability::ExecutionState::Failed)
4164
- {
4165
- crate::mcp_result::ToolStatus::Failed
4166
- } else if entries
4167
- .iter()
4168
- .any(|e| e.state == reliability::ExecutionState::Running)
4169
- {
4170
- crate::mcp_result::ToolStatus::Running
4171
- } else if entries
4172
- .iter()
4173
- .any(|e| e.state == reliability::ExecutionState::Unknown)
4174
- {
4175
- crate::mcp_result::ToolStatus::Unknown
4176
- } else {
4177
- crate::mcp_result::ToolStatus::Success
4178
- };
4179
- progress(AgentProgress::ExecutionUpdate {
4180
- call_id: call_id.into(),
4181
- executions: entries,
4182
- });
4183
- }
4184
- Ok(out)
4185
- }
4186
- }
4187
-
4188
- async fn execute_tool_text(
4189
- root: &Path,
4190
- config: &MintConfig,
4191
- decision: &AgentDecision,
4192
- chat_id: &str,
4193
- approve_cb: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
4194
- progress: &mut (dyn FnMut(AgentProgress) + Send),
4195
- ) -> Result<String, OrchestrationError> {
4196
- if let Some(permissions) = crate::swarm::current_permissions() {
4197
- if !permissions.allows(&decision.action) {
4198
- return Err(OrchestrationError::Agent(format!(
4199
- "action '{}' is not permitted by this subagent's permissions",
4200
- decision.action
4201
- )));
4202
- }
4203
- }
4204
- let input = &decision.input;
4205
- match decision.action.as_str() {
4206
- "mod_help" | "mod_list" | "mod_validate" | "mod_publish" => {
4207
- tools::mods::execute(&decision.action, input, root, config, chat_id, approve_cb).await
4208
- }
4209
- "list_files" | "read_file" | "note_write" | "apply_patch" | "write_file" => {
4210
- tools::files::execute(
4211
- decision.action.as_str(),
4212
- input,
4213
- root,
4214
- config,
4215
- chat_id,
4216
- approve_cb,
4217
- )
4218
- .await
4219
- }
4220
- "search_code" | "symbols" | "find_definition" | "find_references" | "repo_map"
4221
- | "semantic_index" | "semantic_search" => {
4222
- tools::code_search::execute(
4223
- decision.action.as_str(),
4224
- input,
4225
- root,
4226
- config,
4227
- chat_id,
4228
- approve_cb,
4229
- )
4230
- .await
4231
- }
4232
- "knowledge_search" | "web_search" | "image_search" => {
4233
- tools::web::execute(
4234
- decision.action.as_str(),
4235
- input,
4236
- root,
4237
- config,
4238
- chat_id,
4239
- approve_cb,
4240
- )
4241
- .await
4242
- }
4243
- "weather" | "stock" | "calculation" | "memory_recall" => {
4244
- tools::misc::execute(
4245
- decision.action.as_str(),
4246
- input,
4247
- root,
4248
- config,
4249
- chat_id,
4250
- approve_cb,
4251
- )
4252
- .await
4253
- }
4254
- "browser_open"
4255
- | "browser_click"
4256
- | "browser_type"
4257
- | "browser_read"
4258
- | "browser_mouse_move"
4259
- | "browser_mouse_click"
4260
- | "browser_key_press"
4261
- | "browser_screenshot"
4262
- | "browser_tabs"
4263
- | "browser_observe"
4264
- | "browser_fill"
4265
- | "browser_select"
4266
- | "browser_scroll"
4267
- | "browser_wait" => {
4268
- tools::browser::execute(
4269
- decision.action.as_str(),
4270
- input,
4271
- root,
4272
- config,
4273
- chat_id,
4274
- approve_cb,
4275
- )
4276
- .await
4277
- }
4278
- "git_status" | "git_diff" | "git_log" | "git_branch" | "git_checkpoint"
4279
- | "git_rollback" | "git_restore_file" | "git_commit" | "git_create_branch" => {
4280
- tools::git::execute(
4281
- decision.action.as_str(),
4282
- input,
4283
- root,
4284
- config,
4285
- chat_id,
4286
- approve_cb,
4287
- )
4288
- .await
4289
- }
4290
- "create_plan" | "update_plan" | "request_user_approval" | "ask_user" => {
4291
- tools::planning::execute(
4292
- decision.action.as_str(),
4293
- input,
4294
- root,
4295
- config,
4296
- chat_id,
4297
- approve_cb,
4298
- progress,
4299
- )
4300
- .await
4301
- }
4302
- "detect_project" | "list_tests" | "read_diagnostics" | "view_image" | "search_docs"
4303
- | "create_project_doc" => {
4304
- tools::project::execute(
4305
- decision.action.as_str(),
4306
- input,
4307
- root,
4308
- config,
4309
- chat_id,
4310
- approve_cb,
4311
- )
4312
- .await
4313
- }
4314
- "avatar_signal" => tools::avatar::execute(input).await,
4315
- "run_plugin" | "dispatch_agents" | "dispatch_subagent" | "mcp_tool" | "mcp_list_tools" => {
4316
- tools::plugins_mcp::execute(
4317
- decision.action.as_str(),
4318
- input,
4319
- root,
4320
- config,
4321
- chat_id,
4322
- approve_cb,
4323
- progress,
4324
- )
4325
- .await
4326
- }
4327
- "run_shell" | "shell_output" | "kill_shell" | "verify" | "run_tests" | "run_typecheck"
4328
- | "run_linter" => {
4329
- tools::shell::execute(
4330
- decision.action.as_str(),
4331
- input,
4332
- root,
4333
- config,
4334
- chat_id,
4335
- approve_cb,
4336
- )
4337
- .await
4338
- }
4339
- "video_trim"
4340
- | "video.trim"
4341
- | "video_remove_silence"
4342
- | "video.remove_silence"
4343
- | "video_resize"
4344
- | "video_merge"
4345
- | "video_export"
4346
- | "video.export"
4347
- | "video_extract_audio"
4348
- | "video_filmstrip"
4349
- | "video.filmstrip"
4350
- | "video_waveform"
4351
- | "video.waveform"
4352
- | "speech_transcribe"
4353
- | "subtitle_generate"
4354
- | "subtitle.generate"
4355
- | "subtitle_translate"
4356
- | "subtitle.translate"
4357
- | "subtitle_burn"
4358
- | "timeline_reorder"
4359
- | "timeline.reorder"
4360
- | "effect_zoom_on_speaker"
4361
- | "effect.zoom_on_speaker"
4362
- | "audio_duck_music"
4363
- | "audio.duck_music"
4364
- | "make_shorts"
4365
- | "video.make_shorts"
4366
- | "generate_image"
4367
- | "image_studio.generate"
4368
- | "image_generate"
4369
- | "generate_video"
4370
- | "veo.generate"
4371
- | "video_generate" => {
4372
- tools::media::execute(
4373
- decision.action.as_str(),
4374
- input,
4375
- root,
4376
- config,
4377
- chat_id,
4378
- approve_cb,
4379
- )
4380
- .await
4381
- }
4382
- other => Err(OrchestrationError::Agent(format!(
4383
- "unsupported code-agent action '{}'",
4384
- other
4385
- ))),
4386
- }
4387
- }
4388
-
4389
- /// Bridge for callers outside this module (e.g. the Gemini Live realtime voice
4390
- /// session) that receive an action name and raw JSON input rather than a parsed
4391
- /// `AgentDecision`, and so can't construct one directly since its fields are
4392
- /// private to this module.
4393
- pub(crate) async fn execute_tool_from_json<Approve>(
4394
- root: &Path,
4395
- config: &MintConfig,
4396
- action: &str,
4397
- input: Value,
4398
- chat_id: &str,
4399
- approve_cb: &mut Approve,
4400
- ) -> Result<String, OrchestrationError>
4401
- where
4402
- Approve: FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send,
4403
- {
4404
- let decision = AgentDecision {
4405
- thought: String::new(),
4406
- action: action.to_string(),
4407
- input: parse_tool_input(action, input)?,
4408
- };
4409
- // No progress channel from the realtime voice session — tool-call
4410
- // activity isn't surfaced there the way it is in the CLI/GUI.
4411
- execute_tool(root, config, &decision, chat_id, approve_cb, &mut |_| {}).await
4412
- }
4413
-
4414
- fn parse_tool_input(action: &str, input: Value) -> Result<AgentInput, OrchestrationError> {
4415
- let normalized = if action == "run_shell" {
4416
- match &input {
4417
- Value::String(cmd) if !cmd.trim().is_empty() => {
4418
- if let Ok(val) = serde_json::from_str::<Value>(cmd)
4419
- && val.is_object()
4420
- {
4421
- val
4422
- } else {
4423
- serde_json::json!({ "command": cmd.trim() })
4424
- }
4425
- }
4426
- _ => input,
4427
- }
4428
- } else {
4429
- input
4430
- };
4431
- decode_agent_input(normalized).map_err(|error| {
4432
- OrchestrationError::Agent(format!("invalid arguments for tool '{action}': {error}"))
4433
- })
4434
- }
4435
-
4436
- fn append_native_tool_results(
4437
- messages: &mut Vec<ChatMessage>,
4438
- response_text: &str,
4439
- results: &[ToolResultEntry],
4440
- signatures: &std::collections::HashMap<String, String>,
4441
- images: &std::collections::HashMap<String, Vec<String>>,
4442
- ) {
4443
- let mut assistant_content: Vec<ContentBlock> = Vec::new();
4444
- if !response_text.is_empty() {
4445
- assistant_content.push(ContentBlock::Text {
4446
- text: response_text.to_string(),
4447
- });
4448
- }
4449
- let mut tool_result_content: Vec<ContentBlock> = Vec::new();
4450
- for ToolResultEntry {
4451
- call_id,
4452
- action,
4453
- input: input_value,
4454
- text: final_result,
4455
- status,
4456
- } in results
4457
- {
4458
- // Activity annotations are for the UI, not parameters of the provider tool schema.
4459
- let mut tool_input = input_value.clone();
4460
- if let Some(object) = tool_input.as_object_mut() {
4461
- object.remove("skill_name");
4462
- }
4463
- assistant_content.push(ContentBlock::ToolUse {
4464
- id: call_id.clone(),
4465
- name: action.clone(),
4466
- input: tool_input,
4467
- thought_signature: signatures.get(call_id).cloned(),
4468
- });
4469
- tool_result_content.push(ContentBlock::ToolResult {
4470
- tool_use_id: call_id.clone(),
4471
- content: final_result.clone(),
4472
- is_error: matches!(
4473
- *status,
4474
- crate::mcp_result::ToolStatus::Failed
4475
- | crate::mcp_result::ToolStatus::TimedOut
4476
- | crate::mcp_result::ToolStatus::Cancelled
4477
- ),
4478
- });
4479
- if let Some(data_uris) = images.get(call_id) {
4480
- for data_uri in data_uris {
4481
- tool_result_content.push(ContentBlock::Image {
4482
- data_uri: data_uri.clone(),
4483
- });
4484
- }
4485
- }
4486
- }
4487
- if !assistant_content.is_empty() {
4488
- messages.push(ChatMessage {
4489
- role: ChatRole::Assistant,
4490
- content: assistant_content,
4491
- });
4492
- }
4493
- if !tool_result_content.is_empty() {
4494
- messages.push(ChatMessage {
4495
- role: ChatRole::Tool,
4496
- content: tool_result_content,
4497
- });
4498
- }
4499
- }
4500
-
4501
- #[derive(Debug, Clone)]
4502
- pub(super) struct ToolResultEntry {
4503
- pub(super) call_id: String,
4504
- pub(super) action: String,
4505
- pub(super) input: Value,
4506
- pub(super) text: String,
4507
- pub(super) status: crate::mcp_result::ToolStatus,
4508
- }
4509
-
4510
- // Legacy tools determine their outcome once at this adapter; MCP supplies its
4511
- // explicit status directly, regardless of the wording of server text.
4512
- impl From<(String, String, Value, String)> for ToolResultEntry {
4513
- fn from((call_id, action, input, text): (String, String, Value, String)) -> Self {
4514
- let mut status = crate::mcp_result::ToolOutcome::from(text.clone()).status;
4515
- if text.starts_with("Skipped") {
4516
- status = crate::mcp_result::ToolStatus::Skipped;
4517
- } else if matches!(
4518
- action.as_str(),
4519
- "run_shell" | "verify" | "run_tests" | "run_typecheck" | "run_linter"
4520
- ) && shell_result_failed(&text)
4521
- {
4522
- status = crate::mcp_result::ToolStatus::Failed;
4523
- }
4524
- Self {
4525
- call_id,
4526
- action,
4527
- input,
4528
- text,
4529
- status,
4530
- }
4531
- }
4532
- }
4533
- type PreparedToolCalls = (Vec<(String, AgentDecision)>, Vec<ToolResultEntry>);
4534
-
4535
- #[derive(Debug, Default)]
4536
- struct ToolArgumentRetries {
4537
- failures: BTreeMap<String, usize>,
4538
- }
4539
-
4540
- impl ToolArgumentRetries {
4541
- fn succeeded(&mut self, tool: &str) {
4542
- self.failures.remove(tool);
4543
- }
4544
-
4545
- fn observe_completed(&mut self, results: &[ToolResultEntry]) {
4546
- for ToolResultEntry {
4547
- action: tool,
4548
- status,
4549
- ..
4550
- } in results
4551
- {
4552
- // Parallel batches encode execution errors and hook blocks in
4553
- // their results. Neither is a successful execution.
4554
- if *status == crate::mcp_result::ToolStatus::Success {
4555
- self.succeeded(tool);
4556
- }
4557
- }
4558
- }
4559
- }
4560
-
4561
- /// Invalid calls never become executable decisions. Return their original
4562
- /// arguments and call IDs so the next model turn can repair them.
4563
- fn prepare_native_tool_calls(
4564
- calls: Vec<crate::chat::ToolCall>,
4565
- thought: &str,
4566
- retries: &mut ToolArgumentRetries,
4567
- ) -> Result<PreparedToolCalls, OrchestrationError> {
4568
- let mut decisions = Vec::new();
4569
- let mut errors = Vec::new();
4570
- for (index, call) in calls.into_iter().enumerate() {
4571
- match parse_tool_input(&call.name, call.input.clone()) {
4572
- Ok(input) => decisions.push((
4573
- call.id,
4574
- AgentDecision {
4575
- thought: if index == 0 {
4576
- thought.to_owned()
4577
- } else {
4578
- String::new()
4579
- },
4580
- action: call.name,
4581
- input,
4582
- },
4583
- )),
4584
- Err(error) => {
4585
- let invalid_count = retries.failures.entry(call.name.clone()).or_default();
4586
- if *invalid_count >= MAX_TOOL_ARGUMENT_RETRIES {
4587
- return Err(OrchestrationError::Agent(format!(
4588
- "Tool '{}' exhausted its {MAX_TOOL_ARGUMENT_RETRIES} argument-correction retries without a successful execution. Other tools have independent budgets. {error}",
4589
- call.name
4590
- )));
4591
- }
4592
- *invalid_count += 1;
4593
- errors.push((call.id, call.name, call.input, format!(
4594
- "Error: {error}. No tool was executed for this call. Correct the arguments to match the tool schema, or choose another suitable tool. Correction retries for this tool: {}/{MAX_TOOL_ARGUMENT_RETRIES}. This counter resets only after this tool executes successfully; changing arguments alone does not reset it. Other tools have independent counters. Continued failures for this tool will stop the run.",
4595
- *invalid_count,
4596
- )).into());
4597
- }
4598
- }
4599
- }
4600
- Ok((decisions, errors))
4601
- }
4602
-
4603
- #[cfg(test)]
4604
- mod tests {
4605
- use super::*;
4606
- use crate::config::AgentConfig;
4607
-
4608
- #[test]
4609
- fn stringified_patch_uses_explicit_outer_target() {
4610
- let patch =
4611
- serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string();
4612
- let input = parse_tool_input(
4613
- "apply_patch",
4614
- serde_json::json!({"path": "style.css", "patch": patch}),
4615
- )
4616
- .unwrap();
4617
- let patch = input.patch.unwrap();
4618
- assert_eq!(patch.path, PathBuf::from("style.css"));
4619
- assert_eq!(patch.hunks.len(), 1);
4620
- }
4621
-
4622
- #[tokio::test]
4623
- async fn stringified_patch_executes_only_after_approval_and_can_be_undone() {
4624
- let root = std::env::temp_dir().join(format!("mint-patch-repro-{}", uuid::Uuid::new_v4()));
4625
- std::fs::create_dir_all(&root).unwrap();
4626
- std::fs::write(root.join("style.css"), "body { color: black; }").unwrap();
4627
- let config = MintConfig {
4628
- allowed_read_paths: vec![root.clone()],
4629
- allowed_write_paths: vec![root.clone()],
4630
- blocked_paths: vec![],
4631
- blocked_file_names: vec![],
4632
- ..MintConfig::default()
4633
- };
4634
- let input = serde_json::json!({"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "black", "newText": "orange"}]}).to_string()});
4635
- execute_tool_from_json(
4636
- &root,
4637
- &config,
4638
- "apply_patch",
4639
- input.clone(),
4640
- "patch-repro",
4641
- &mut |_| Ok(ApprovalOutcome::Denied),
4642
- )
4643
- .await
4644
- .unwrap();
4645
- assert_eq!(
4646
- std::fs::read_to_string(root.join("style.css")).unwrap(),
4647
- "body { color: black; }"
4648
- );
4649
- execute_tool_from_json(
4650
- &root,
4651
- &config,
4652
- "apply_patch",
4653
- input,
4654
- "patch-repro",
4655
- &mut |_| Ok(ApprovalOutcome::Approved),
4656
- )
4657
- .await
4658
- .unwrap();
4659
- assert_eq!(
4660
- std::fs::read_to_string(root.join("style.css")).unwrap(),
4661
- "body { color: orange; }"
4662
- );
4663
- crate::system::workspace_history::undo(&root, None).unwrap();
4664
- assert_eq!(
4665
- std::fs::read_to_string(root.join("style.css")).unwrap(),
4666
- "body { color: black; }"
4667
- );
4668
- std::fs::remove_dir_all(root).unwrap();
4669
- }
4670
-
4671
- #[test]
4672
- fn patch_decoding_preserves_strict_types_and_rejects_ambiguous_targets() {
4673
- let patch = serde_json::json!({"path": "style.css", "hunks": [{"oldText": "old", "newText": "new"}]});
4674
- assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": patch})).is_ok());
4675
- assert!(
4676
- parse_tool_input(
4677
- "apply_patch",
4678
- serde_json::json!({"path": "other.css", "patch": patch.to_string()})
4679
- )
4680
- .is_err()
4681
- );
4682
- assert!(
4683
- parse_tool_input(
4684
- "apply_patch",
4685
- serde_json::json!({"patch": "{\"hunks\": []}"})
4686
- )
4687
- .is_err()
4688
- );
4689
- assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": "not JSON"})).is_err());
4690
- assert!(parse_tool_input("list_files", serde_json::json!({"limit": "100"})).is_err());
4691
- let huge = "private-code-marker".repeat(1000);
4692
- let error = parse_tool_input(
4693
- "apply_patch",
4694
- serde_json::json!({"patch": {"path": "style.css", "hunks": huge}}),
4695
- )
4696
- .unwrap_err()
4697
- .to_string();
4698
- assert!(error.len() < 600);
4699
- assert!(!error.contains("private-code-marker"));
4700
- let decision: AgentDecision = serde_json::from_value(serde_json::json!({"action": "apply_patch", "input": {"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string()}})).unwrap();
4701
- assert_eq!(
4702
- decision.input.patch.unwrap().path,
4703
- PathBuf::from("style.css")
4704
- );
4705
- }
4706
-
4707
- fn test_list_call(id: &str, input: Value) -> crate::chat::ToolCall {
4708
- crate::chat::ToolCall {
4709
- id: id.into(),
4710
- name: "list_files".into(),
4711
- input,
4712
- thought_signature: Some("signature".into()),
4713
- }
4714
- }
4715
-
4716
- #[tokio::test]
4717
- async fn native_argument_error_is_returned_and_corrected_call_can_execute() {
4718
- let raw = serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": "100"});
4719
- let mut count = ToolArgumentRetries::default();
4720
- let (decisions, errors) =
4721
- prepare_native_tool_calls(vec![test_list_call("bad", raw.clone())], "", &mut count)
4722
- .unwrap();
4723
- assert!(decisions.is_empty());
4724
- assert_eq!(errors.len(), 1);
4725
- let mut messages = Vec::new();
4726
- let signatures = std::collections::HashMap::from([("bad".into(), "signature".into())]);
4727
- append_native_tool_results(&mut messages, "", &errors, &signatures, &Default::default());
4728
- assert!(
4729
- matches!(&messages[0].content[0], ContentBlock::ToolUse { id, input, thought_signature, .. }
4730
- if id == "bad" && input == &raw && thought_signature.as_deref() == Some("signature"))
4731
- );
4732
- assert!(
4733
- matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, content, is_error }
4734
- if tool_use_id == "bad" && *is_error && content.contains("Correct the arguments"))
4735
- );
4736
-
4737
- let root =
4738
- std::env::temp_dir().join(format!("mint-tool-recovery-{}", uuid::Uuid::new_v4()));
4739
- let nested = root.join("Downloads/YOUTUBE_DL/playlist");
4740
- std::fs::create_dir_all(&nested).unwrap();
4741
- std::fs::write(nested.join("song.txt"), "song").unwrap();
4742
- let config = MintConfig {
4743
- allowed_read_paths: vec![root.clone()],
4744
- blocked_paths: vec![],
4745
- blocked_file_names: vec![],
4746
- ..MintConfig::default()
4747
- };
4748
- let (decisions, errors) = prepare_native_tool_calls(
4749
- vec![test_list_call(
4750
- "fixed",
4751
- serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
4752
- )],
4753
- "",
4754
- &mut count,
4755
- )
4756
- .unwrap();
4757
- assert!(errors.is_empty());
4758
- let output = execute_tool(
4759
- &root,
4760
- &config,
4761
- &decisions[0].1,
4762
- "regression",
4763
- &mut |_| panic!("listing must not request approval"),
4764
- &mut |_| {},
4765
- )
4766
- .await
4767
- .unwrap();
4768
- let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
4769
- assert_eq!(entries.len(), 1);
4770
- assert_eq!(
4771
- entries[0]["path"],
4772
- serde_json::json!(nested.join("song.txt"))
4773
- );
4774
- // Parsing the repaired call alone must not renew its budget.
4775
- assert_eq!(count.failures["list_files"], 1);
4776
- count.observe_completed(&[
4777
- ("fixed".into(), "list_files".into(), Value::Null, output).into()
4778
- ]);
4779
- assert!(!count.failures.contains_key("list_files"));
4780
- std::fs::remove_dir_all(root).unwrap();
4781
- }
4782
-
4783
- #[test]
4784
- fn native_invalid_calls_are_bounded_without_blocking_valid_alternatives() {
4785
- let mut count = ToolArgumentRetries::default();
4786
- for _ in 0..MAX_TOOL_ARGUMENT_RETRIES {
4787
- let (decisions, errors) = prepare_native_tool_calls(
4788
- vec![test_list_call(
4789
- "bad",
4790
- serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
4791
- )],
4792
- "",
4793
- &mut count,
4794
- )
4795
- .unwrap();
4796
- assert!(decisions.is_empty());
4797
- assert_eq!(errors.len(), 1);
4798
- }
4799
- let alternative = crate::chat::ToolCall {
4800
- id: "alternative".into(),
4801
- name: "run_shell".into(),
4802
- input: serde_json::json!({"command": "ls ~/Downloads/YOUTUBE_DL"}),
4803
- thought_signature: None,
4804
- };
4805
- let (decisions, errors) =
4806
- prepare_native_tool_calls(vec![alternative], "", &mut count).unwrap();
4807
- assert_eq!(decisions[0].1.action, "run_shell");
4808
- assert!(errors.is_empty());
4809
- assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
4810
- let error =
4811
- prepare_native_tool_calls(vec![test_list_call("bad", Value::Null)], "", &mut count)
4812
- .unwrap_err();
4813
- assert!(
4814
- error
4815
- .to_string()
4816
- .contains("exhausted its 5 argument-correction retries")
4817
- );
4818
- // One exhausted tool must not consume another tool's corrections.
4819
- let other = crate::chat::ToolCall {
4820
- id: "other-bad".into(),
4821
- name: "read_file".into(),
4822
- input: serde_json::json!({"path": 123}),
4823
- thought_signature: None,
4824
- };
4825
- let (_, errors) = prepare_native_tool_calls(vec![other], "", &mut count).unwrap();
4826
- assert_eq!(errors.len(), 1);
4827
- assert_eq!(count.failures["read_file"], 1);
4828
- for result in [
4829
- "Error: execution failed",
4830
- "Blocked by hook",
4831
- "Skipped duplicate",
4832
- ] {
4833
- count.observe_completed(&[(
4834
- "x".into(),
4835
- "list_files".into(),
4836
- Value::Null,
4837
- result.into(),
4838
- )
4839
- .into()]);
4840
- assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
4841
- }
4842
- count.succeeded("list_files");
4843
- assert_eq!(count.failures["read_file"], 1);
4844
- assert!(!count.failures.contains_key("list_files"));
4845
- assert!(
4846
- prepare_native_tool_calls(vec![test_list_call("fresh", Value::Null)], "", &mut count)
4847
- .is_ok()
4848
- );
4849
- }
4850
-
4851
- #[test]
4852
- fn native_mixed_batch_keeps_valid_calls_and_matches_each_error_to_its_id() {
4853
- let mut count = ToolArgumentRetries::default();
4854
- let (decisions, errors) = prepare_native_tool_calls(
4855
- vec![
4856
- test_list_call("bad", Value::Null),
4857
- test_list_call("valid-1", serde_json::json!({"path": "."})),
4858
- test_list_call("valid-2", serde_json::json!({"path": "src"})),
4859
- ],
4860
- "",
4861
- &mut count,
4862
- )
4863
- .unwrap();
4864
- assert_eq!(decisions.len(), 2);
4865
- assert!(decisions_are_parallel_read_only_batch(&decisions));
4866
- assert_eq!(errors[0].call_id, "bad");
4867
- let mut results = errors;
4868
- results.extend(decisions.iter().map(|(id, d)| {
4869
- (
4870
- id.clone(),
4871
- d.action.clone(),
4872
- serde_json::to_value(&d.input).unwrap(),
4873
- "[]".into(),
4874
- )
4875
- .into()
4876
- }));
4877
- let mut messages = Vec::new();
4878
- append_native_tool_results(
4879
- &mut messages,
4880
- "",
4881
- &results,
4882
- &Default::default(),
4883
- &Default::default(),
4884
- );
4885
- assert_eq!(messages[0].content.len(), 3);
4886
- assert_eq!(messages[1].content.len(), 3);
4887
- assert!(
4888
- matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "bad")
4889
- );
4890
- assert!(
4891
- matches!(&messages[1].content[1], ContentBlock::ToolResult { tool_use_id, is_error: false, .. } if tool_use_id == "valid-1")
4892
- );
4893
- }
4894
-
4895
- #[tokio::test]
4896
- async fn list_files_rejects_invalid_arguments_instead_of_listing_workspace() {
4897
- let config = MintConfig::default();
4898
- // A nonexistent root ensures no filesystem lookup occurs for bad input.
4899
- let root = Path::new("/nonexistent-mint-list-files-regression");
4900
- for input in [
4901
- serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
4902
- serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": -1}),
4903
- serde_json::json!({"path": null}),
4904
- Value::String(r#"{"path":"~/Downloads/YOUTUBE_DL""#.into()),
4905
- Value::Null,
4906
- ] {
4907
- let error = execute_tool_from_json(
4908
- root,
4909
- &config,
4910
- "list_files",
4911
- input,
4912
- "regression",
4913
- &mut |_| panic!("invalid arguments must not request approval"),
4914
- )
4915
- .await
4916
- .unwrap_err();
4917
- assert!(
4918
- error
4919
- .to_string()
4920
- .contains("invalid arguments for tool 'list_files'")
4921
- );
4922
- }
4923
- }
4924
-
4925
- #[tokio::test]
4926
- async fn list_files_preserves_nested_paths_and_default_directory() {
4927
- let root =
4928
- std::env::temp_dir().join(format!("mint-list-files-paths-{}", std::process::id()));
4929
- let nested = root.join("Downloads/YOUTUBE_DL/playlist");
4930
- std::fs::create_dir_all(&nested).unwrap();
4931
- std::fs::write(nested.join("song.txt"), "song").unwrap();
4932
- let config = MintConfig {
4933
- allowed_read_paths: vec![root.clone()],
4934
- blocked_paths: vec![],
4935
- blocked_file_names: vec![],
4936
- ..MintConfig::default()
4937
- };
4938
- for input in [
4939
- serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
4940
- serde_json::json!({"path": nested}),
4941
- serde_json::json!({}),
4942
- ] {
4943
- let expected = if input.get("path").is_some() {
4944
- &nested
4945
- } else {
4946
- &root
4947
- };
4948
- let output = execute_tool_from_json(
4949
- &root,
4950
- &config,
4951
- "list_files",
4952
- input,
4953
- "regression",
4954
- &mut |_| panic!("listing must not request approval"),
4955
- )
4956
- .await
4957
- .unwrap();
4958
- let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
4959
- assert_eq!(entries.len(), 1);
4960
- assert_eq!(
4961
- Path::new(entries[0]["path"].as_str().unwrap()).parent(),
4962
- Some(expected.as_path())
4963
- );
4964
- }
4965
- std::fs::remove_dir_all(root).unwrap();
4966
- }
4967
-
4968
- #[tokio::test]
4969
- async fn turn_start_listener_reports_only_the_matching_chat() {
4970
- let memory = MemoryStore::open(std::env::temp_dir().join(format!(
4971
- "mint-turn-start-listener-{}.sqlite",
4972
- uuid::Uuid::new_v4()
4973
- )));
4974
- let reported = Arc::new(std::sync::Mutex::new(Vec::new()));
4975
- let callback_reported = reported.clone();
4976
- with_turn_start_listener(
4977
- "cli::one".to_string(),
4978
- move |id| callback_reported.lock().unwrap().push(id),
4979
- async {
4980
- let first = TurnLease::start(&memory, "cli::one", "hello")
4981
- .await
4982
- .unwrap();
4983
- let other = TurnLease::start(&memory, "cli::two", "other")
4984
- .await
4985
- .unwrap();
4986
- assert_eq!(*reported.lock().unwrap(), vec![first.id]);
4987
- drop(first);
4988
- drop(other);
4989
- },
4990
- )
4991
- .await;
4992
- }
4993
-
4994
- #[test]
4995
- fn test_compact_agent_progress_cleans_deltas_and_preserves_tools() {
4996
- let events = vec![
4997
- AgentProgress::ToolStart {
4998
- call_id: None,
4999
- action: "read_file".into(),
5000
- input: serde_json::json!({ "path": "src/main.rs" }),
5001
- subagent: None,
5002
- },
5003
- AgentProgress::ThinkingDelta {
5004
- id: "cot-1".into(),
5005
- delta: "Reading ".into(),
5006
- elapsed_ms: 100,
5007
- },
5008
- AgentProgress::ThinkingDelta {
5009
- id: "cot-1".into(),
5010
- delta: "the file...".into(),
5011
- elapsed_ms: 200,
5012
- },
5013
- AgentProgress::ExtendedThinking {
5014
- id: Some("cot-1".into()),
5015
- thought: "Reading the file...".into(),
5016
- elapsed_ms: Some(200),
5017
- },
5018
- AgentProgress::ToolEnd {
5019
- elapsed_ms: None,
5020
- call_id: None,
5021
- status: None,
5022
- action: "read_file".into(),
5023
- input: serde_json::json!({ "path": "src/main.rs" }),
5024
- result: "fn main() {}".into(),
5025
- subagent: None,
5026
- },
5027
- AgentProgress::ThinkingDelta {
5028
- id: "cot-interrupted".into(),
5029
- delta: "Let's check ".into(),
5030
- elapsed_ms: 300,
5031
- },
5032
- AgentProgress::ThinkingDelta {
5033
- id: "cot-interrupted".into(),
5034
- delta: "more lines".into(),
5035
- elapsed_ms: 350,
5036
- },
5037
- ];
5038
-
5039
- let compacted = compact_agent_progress(&events);
5040
- assert!(
5041
- !compacted
5042
- .iter()
5043
- .any(|e| matches!(e, AgentProgress::ThinkingDelta { id, .. } if id == "cot-1"))
5044
- );
5045
- assert!(compacted.iter().any(
5046
- |e| matches!(e, AgentProgress::ToolStart { action, .. } if action == "read_file")
5047
- ));
5048
- assert!(
5049
- compacted.iter().any(
5050
- |e| matches!(e, AgentProgress::ToolEnd { action, .. } if action == "read_file")
5051
- )
5052
- );
5053
- assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-1" && thought == "Reading the file...")));
5054
- assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-interrupted" && thought == "Let's check more lines")));
5055
- }
5056
-
5057
- #[tokio::test]
5058
- async fn test_turn_lease_persists_agent_activity_on_complete() {
5059
- let memory = MemoryStore::open(std::env::temp_dir().join(format!(
5060
- "mint-turn-lease-complete-{}.sqlite",
5061
- uuid::Uuid::new_v4()
5062
- )));
5063
- let mut turn = TurnLease::start(&memory, "cli::test_complete", "hello")
5064
- .await
5065
- .unwrap();
5066
- let turn_id = turn.id;
5067
- turn.activity
5068
- .lock()
5069
- .unwrap()
5070
- .push(AgentProgress::ToolStart {
5071
- call_id: None,
5072
- action: "run_shell".into(),
5073
- input: serde_json::json!({ "command": "ls -la" }),
5074
- subagent: None,
5075
- });
5076
- turn.activity.lock().unwrap().push(AgentProgress::ToolEnd {
5077
- elapsed_ms: None,
5078
- call_id: None,
5079
- status: None,
5080
- action: "run_shell".into(),
5081
- input: serde_json::json!({ "command": "ls -la" }),
5082
- result: "file.txt".into(),
5083
- subagent: None,
5084
- });
5085
-
5086
- turn.complete_with_summary("Done listing files", "test_provider", "test_model", None)
5087
- .unwrap();
5088
-
5089
- let rows = memory.interactions_for_chat("cli::test_complete").unwrap();
5090
- assert_eq!(rows.len(), 1);
5091
- let row = &rows[0];
5092
- assert_eq!(row.id, turn_id);
5093
- assert_eq!(row.status, "completed");
5094
- assert_eq!(row.ai_text, "Done listing files");
5095
- assert!(row.agent_activity.is_some());
5096
- let activity_val = row.agent_activity.as_ref().unwrap();
5097
- let activity_arr = activity_val.as_array().unwrap();
5098
- assert_eq!(activity_arr.len(), 2);
5099
- assert_eq!(activity_arr[0]["type"], "ToolStart");
5100
- assert_eq!(activity_arr[1]["type"], "ToolEnd");
5101
- }
5102
-
5103
- #[tokio::test]
5104
- async fn test_turn_lease_persists_partial_activity_on_interrupt() {
5105
- let memory = MemoryStore::open(std::env::temp_dir().join(format!(
5106
- "mint-turn-lease-interrupt-{}.sqlite",
5107
- uuid::Uuid::new_v4()
5108
- )));
5109
- let turn = TurnLease::start(&memory, "cli::test_interrupt", "run something")
5110
- .await
5111
- .unwrap();
5112
- let turn_id = turn.id;
5113
- turn.activity
5114
- .lock()
5115
- .unwrap()
5116
- .push(AgentProgress::ToolStart {
5117
- call_id: None,
5118
- action: "read_file".into(),
5119
- input: serde_json::json!({ "path": "test.txt" }),
5120
- subagent: None,
5121
- });
5122
- drop(turn);
5123
-
5124
- let rows = memory.interactions_for_chat("cli::test_interrupt").unwrap();
5125
- assert_eq!(rows.len(), 1);
5126
- let row = &rows[0];
5127
- assert_eq!(row.id, turn_id);
5128
- assert_eq!(row.status, "interrupted");
5129
- assert!(row.agent_activity.is_some());
5130
- let activity_val = row.agent_activity.as_ref().unwrap();
5131
- let activity_arr = activity_val.as_array().unwrap();
5132
- assert_eq!(activity_arr.len(), 1);
5133
- assert_eq!(activity_arr[0]["type"], "ToolStart");
5134
- }
5135
-
5136
- #[test]
5137
- fn extracts_a_summary_incrementally_without_matching_text_inside_other_strings() {
5138
- let mut stream = FinishSummaryStream::default();
5139
- let mut output = String::new();
5140
- let mut emit = |delta: String| output.push_str(&delta);
5141
-
5142
- stream.push_tool_call(0, Some("read_file"), None, None, &mut emit);
5143
- stream.push_tool_call(
5144
- 0,
5145
- None,
5146
- Some(r#"{"thought":"the \"summary\" key is data","path":"x"}"#),
5147
- None,
5148
- &mut emit,
5149
- );
5150
- assert!(stream.emitted.is_empty());
5151
-
5152
- stream.push_tool_call(1, Some("fin"), None, None, &mut emit);
5153
- stream.push_tool_call(1, Some("finish"), None, None, &mut emit);
5154
- stream.push_tool_call(1, None, Some(r#"{"summary":"สวัส"#), None, &mut emit);
5155
- stream.push_tool_call(
5156
- 1,
5157
- None,
5158
- Some(r#"ดี\nโลก","verification":""}"#),
5159
- None,
5160
- &mut emit,
5161
- );
5162
-
5163
- assert_eq!(output, "สวัสดี\nโลก");
5164
- }
5165
-
5166
- #[test]
5167
- fn extracts_json_prompt_summary_as_chunks_arrive() {
5168
- let mut stream = FinishSummaryStream::default();
5169
- let mut output = String::new();
5170
- let mut emit = |delta: String| output.push_str(&delta);
5171
- stream.push_json_prompt(
5172
- r#"{"thought":"done","action":"finish","input":{"summary":"Hello"#,
5173
- &mut emit,
5174
- );
5175
- assert_eq!(stream.emitted, "Hello");
5176
- stream.push_json_prompt(r#" world"}}"#, &mut emit);
5177
- assert_eq!(output, "Hello world");
5178
- }
5179
-
5180
- #[test]
5181
- fn direct_native_text_is_not_sent_again_as_a_completed_summary() {
5182
- assert_eq!(
5183
- unstreamed_summary_remainder("A streamed answer.", "", "A streamed answer."),
5184
- ""
5185
- );
5186
- }
5187
-
5188
- #[test]
5189
- fn direct_native_text_appends_only_the_unstreamed_suffix() {
5190
- assert_eq!(
5191
- unstreamed_summary_remainder("A streamed answer, continued.", "", "A streamed answer"),
5192
- ", continued."
5193
- );
5194
- }
5195
-
5196
- #[test]
5197
- fn streamed_finish_summary_takes_precedence_over_direct_text() {
5198
- assert_eq!(
5199
- unstreamed_summary_remainder("Final answer.", "Final", "unrelated preamble"),
5200
- " answer."
5201
- );
5202
- }
5203
-
5204
- #[test]
5205
- fn truncate_for_context_leaves_short_text_untouched() {
5206
- assert_eq!(truncate_for_context("hello", 400), "hello");
5207
- }
5208
-
5209
- #[test]
5210
- fn truncate_for_context_truncates_long_text_with_a_marker() {
5211
- let long = "a".repeat(500);
5212
- let result = truncate_for_context(&long, 400);
5213
- assert_eq!(
5214
- result.chars().count(),
5215
- 400 + "... [truncated]".chars().count()
5216
- );
5217
- assert!(result.starts_with(&"a".repeat(400)));
5218
- assert!(result.ends_with("... [truncated]"));
5219
- }
5220
-
5221
- #[test]
5222
- fn truncate_for_context_never_splits_a_multibyte_char() {
5223
- // Thai text is multi-byte UTF-8; a byte-index slice here would panic
5224
- // or produce invalid UTF-8 if it landed mid-character.
5225
- let thai = "สวัสดี".repeat(200);
5226
- let result = truncate_for_context(&thai, 5);
5227
- assert_eq!(
5228
- result.chars().count(),
5229
- 5 + "... [truncated]".chars().count()
5230
- );
5231
- }
5232
-
5233
- #[test]
5234
- fn slugify_lowercases_and_collapses_separators() {
5235
- assert_eq!(
5236
- slugify("Retry Flaky Playwright Tests!!"),
5237
- "retry-flaky-playwright-tests"
5238
- );
5239
- assert_eq!(
5240
- slugify(" leading/trailing --dashes-- "),
5241
- "leading-trailing-dashes"
5242
- );
5243
- assert_eq!(slugify("already-a-slug"), "already-a-slug");
5244
- assert_eq!(slugify("***"), "");
5245
- }
5246
-
5247
- #[test]
5248
- fn skill_revision_defaults_to_zero_without_a_revisions_line() {
5249
- assert_eq!(skill_revision("no frontmatter at all"), 0);
5250
- assert_eq!(skill_revision("---\ndescription: a skill\n---\nbody"), 0);
5251
- }
5252
-
5253
- #[test]
5254
- fn skill_revision_reads_back_what_set_skill_revision_wrote() {
5255
- let original = "---\ndescription: a skill\n---\nstep-by-step body";
5256
- let bumped = set_skill_revision(original, 1);
5257
- assert_eq!(skill_revision(&bumped), 1);
5258
- assert!(
5259
- bumped.contains("description: a skill"),
5260
- "must preserve existing frontmatter fields: {bumped}"
5261
- );
5262
- assert!(
5263
- bumped.contains("step-by-step body"),
5264
- "must preserve the body: {bumped}"
5265
- );
5266
-
5267
- // Refining again must replace the old count, not accumulate a
5268
- // second `revisions:` line alongside it.
5269
- let bumped_again = set_skill_revision(&bumped, 2);
5270
- assert_eq!(skill_revision(&bumped_again), 2);
5271
- assert_eq!(
5272
- bumped_again.matches("revisions:").count(),
5273
- 1,
5274
- "expected exactly one revisions line, got: {bumped_again}"
5275
- );
5276
- }
5277
-
5278
- #[test]
5279
- fn set_skill_revision_adds_a_frontmatter_block_when_content_has_none() {
5280
- let result = set_skill_revision("just a body, no frontmatter", 1);
5281
- assert_eq!(skill_revision(&result), 1);
5282
- assert!(result.contains("just a body, no frontmatter"));
5283
- }
5284
-
5285
- #[test]
5286
- fn existing_workspace_skill_bodies_reports_none_yet_for_a_fresh_workspace() {
5287
- let root = std::env::temp_dir().join("mint-memory-skill-test-no-skills-yet");
5288
- let _ = std::fs::remove_dir_all(&root);
5289
- assert_eq!(existing_workspace_skill_bodies(&root), "(none yet)");
5290
- }
5291
-
5292
- #[test]
5293
- fn existing_workspace_skill_bodies_includes_full_content_by_slug() {
5294
- let root = std::env::temp_dir().join("mint-memory-skill-test-existing-bodies");
5295
- let _ = std::fs::remove_dir_all(&root);
5296
- let skill_dir = root
5297
- .join(".agents")
5298
- .join("skills")
5299
- .join("retry-flaky-tests");
5300
- std::fs::create_dir_all(&skill_dir).unwrap();
5301
- std::fs::write(
5302
- skill_dir.join("SKILL.md"),
5303
- "---\ndescription: retry flaky tests\n---\nrun with --retries=3",
5304
- )
5305
- .unwrap();
5306
-
5307
- let bodies = existing_workspace_skill_bodies(&root);
5308
- assert!(bodies.contains("--- retry-flaky-tests ---"));
5309
- assert!(bodies.contains("run with --retries=3"));
5310
-
5311
- let _ = std::fs::remove_dir_all(&root);
5312
- }
5313
-
5314
- #[test]
5315
- fn skill_worthy_requires_enough_steps_and_substantive_work() {
5316
- let mut counts = BTreeMap::new();
5317
- counts.insert("read_file:foo.rs".to_string(), 1);
5318
- counts.insert("apply_patch:foo.rs".to_string(), 1);
5319
-
5320
- // Too few steps, even with a substantive action.
5321
- assert!(!looks_skill_worthy(2, &counts));
5322
- // Enough steps, has a substantive action.
5323
- assert!(looks_skill_worthy(3, &counts));
5324
-
5325
- let mut read_only = BTreeMap::new();
5326
- read_only.insert("read_file:foo.rs".to_string(), 5);
5327
- read_only.insert("web_search::rust patterns".to_string(), 2);
5328
- // Enough steps, but nothing substantive happened.
5329
- assert!(!looks_skill_worthy(5, &read_only));
5330
- }
5331
-
5332
- #[test]
5333
- fn shell_result_failed_reads_the_exit_line() {
5334
- assert!(!shell_result_failed(
5335
- "exit: 0\nmode: normal\nsandboxed: true\nstdout:\nok\nstderr:\n"
5336
- ));
5337
- assert!(shell_result_failed(
5338
- "exit: 1\nmode: normal\nsandboxed: true\nstdout:\nfail\nstderr:\n"
5339
- ));
5340
- // A killed/unknown-status process isn't treated as a confirmed failure.
5341
- assert!(shell_result_failed(
5342
- "exit: unknown\nmode: normal\nsandboxed: true\nstdout:\nstderr:\n"
5343
- ));
5344
- }
5345
-
5346
- #[test]
5347
- fn shell_result_failed_catches_a_later_command_failing_in_a_multi_command_verify() {
5348
- // A multi-command `verify` joins each command's own `run_shell` block with
5349
- // "\n\n" — the first command here passes, the second fails, and the scan
5350
- // must not stop after the first "exit: " line it finds.
5351
- let joined = "exit: 0\nmode: normal\nsandboxed: true\nstdout:\ncargo check ok\nstderr:\n\n\
5352
- exit: 1\nmode: normal\nsandboxed: true\nstdout:\n\nstderr:\n2 tests failed";
5353
- assert!(shell_result_failed(joined));
5354
- }
5355
-
5356
- #[test]
5357
- fn unverified_modification_requires_a_verify_call_after_the_last_edit() {
5358
- // No edit at all this run — nothing to verify.
5359
- assert!(!unverified_modification(None, None, ""));
5360
- // Edited, never verified, no explanation given.
5361
- assert!(unverified_modification(Some(2), None, ""));
5362
- // Edited, verified *before* the edit (stale) — still unverified.
5363
- assert!(unverified_modification(Some(2), Some(1), ""));
5364
- // Edited, verified after — satisfied regardless of what verify found.
5365
- assert!(!unverified_modification(Some(2), Some(3), ""));
5366
- // An explanation cannot promote an unchecked edit to verified.
5367
- assert!(unverified_modification(
5368
- Some(2),
5369
- None,
5370
- "Docs-only change, no test suite."
5371
- ));
5372
- }
5373
-
5374
- #[test]
5375
- fn unacknowledged_verify_failure_requires_the_agent_to_address_a_real_failure() {
5376
- // No verify ran yet, or it passed — nothing to block on.
5377
- assert!(!unacknowledged_verify_failure(None, ""));
5378
- assert!(!unacknowledged_verify_failure(Some(false), ""));
5379
- // Verify failed and the agent said nothing about it — this is the
5380
- // "claims success while a check is actually failing" case.
5381
- assert!(unacknowledged_verify_failure(Some(true), ""));
5382
- assert!(unacknowledged_verify_failure(Some(true), "n/a"));
5383
- // Even an explanation of pre-existing failures does not make them pass.
5384
- assert!(unacknowledged_verify_failure(
5385
- Some(true),
5386
- "2 pre-existing test failures unrelated to this change; see notes."
5387
- ));
5388
- }
5389
-
5390
- #[test]
5391
- fn preserves_request_without_history() {
5392
- let store = MemoryStore::open(
5393
- std::env::temp_dir().join(format!("mint-orchestrator-{}.sqlite", std::process::id())),
5394
- );
5395
- let request = ChatRequest {
5396
- message: "hello".into(),
5397
- system_instruction: "system".into(),
5398
- chat_id: None,
5399
- image_data_uri: None,
5400
- audio_data_uri: None,
5401
- video_data_uri: None,
5402
- document_attachment: None,
5403
- workspace_path: None,
5404
- agent_id: None,
5405
- plan_mode: false,
5406
- pinned_mcp_server: None,
5407
- messages: None,
5408
- tools: None,
5409
- temperature: None,
5410
- ..Default::default()
5411
- };
5412
- let config = MintConfig::default();
5413
- assert!(
5414
- enrich_request(&config, &store, &request)
5415
- .unwrap()
5416
- .system_instruction
5417
- .starts_with("system")
5418
- );
5419
- }
5420
-
5421
- #[test]
5422
- fn agent_decision_allows_null_input() {
5423
- let decision = parse_decision(r#"{"thought":"done","action":"finish","input":null}"#)
5424
- .expect("null input should parse as default input");
5425
-
5426
- assert_eq!(decision.action, "finish");
5427
- assert!(decision.input.summary.is_empty());
5428
- }
5429
-
5430
- #[test]
5431
- fn agent_decision_allows_missing_input() {
5432
- let decision = parse_decision(r#"{"thought":"done","action":"finish"}"#)
5433
- .expect("missing input should parse as default input");
5434
-
5435
- assert_eq!(decision.action, "finish");
5436
- assert!(decision.input.summary.is_empty());
5437
- }
5438
-
5439
- #[test]
5440
- fn shorthand_finish_allows_null_or_missing_finish() {
5441
- let decision = parse_decision(r#"{"thought":"done","finish":null}"#)
5442
- .expect("null finish should parse");
5443
- assert_eq!(decision.action, "finish");
5444
- assert!(decision.input.summary.is_empty());
5445
-
5446
- let decision =
5447
- parse_decision(r#"{"thought":"done"}"#).expect("missing finish should parse");
5448
- assert_eq!(decision.action, "finish");
5449
- assert!(decision.input.summary.is_empty());
5450
- }
5451
-
5452
- #[test]
5453
- fn shorthand_finish_allows_string_finish_as_summary() {
5454
- let decision = parse_decision(r#"{"thought":"done","finish":"all done!"}"#)
5455
- .expect("string finish should parse as summary");
5456
- assert_eq!(decision.action, "finish");
5457
- assert_eq!(decision.input.summary, "all done!");
5458
- }
5459
-
5460
- #[test]
5461
- fn write_file_policy_rejects_existing_workspace_file() {
5462
- let root =
5463
- std::env::temp_dir().join(format!("mint-write-file-policy-{}", std::process::id()));
5464
- std::fs::create_dir_all(&root).unwrap();
5465
- let target = root.join("existing.txt");
5466
- std::fs::write(&target, "already here").unwrap();
5467
- let config = MintConfig {
5468
- allowed_read_paths: vec![root.clone()],
5469
- allowed_write_paths: vec![root.clone()],
5470
- blocked_paths: vec![],
5471
- blocked_file_names: vec![],
5472
- ..MintConfig::default()
5473
- };
5474
-
5475
- let result = validate_new_workspace_file(&root, &config, Path::new("existing.txt"));
5476
-
5477
- assert!(
5478
- matches!(result, Err(OrchestrationError::Agent(message)) if message.contains("Use apply_patch"))
5479
- );
5480
- let _ = std::fs::remove_file(target);
5481
- let _ = std::fs::remove_dir(root);
5482
- }
5483
-
5484
- #[test]
5485
- fn agent_list_files_includes_directories() {
5486
- let root = std::env::temp_dir().join(format!(
5487
- "mint-agent-list-directories-{}",
5488
- std::process::id()
5489
- ));
5490
- std::fs::create_dir_all(root.join("Bunny Girl")).unwrap();
5491
- std::fs::write(root.join("note.txt"), "hello").unwrap();
5492
- let config = MintConfig {
5493
- allowed_read_paths: vec![root.clone()],
5494
- allowed_write_paths: vec![root.clone()],
5495
- blocked_paths: vec![],
5496
- blocked_file_names: vec![],
5497
- ..MintConfig::default()
5498
- };
5499
-
5500
- let entries = list_directory_entries(&root, 100, &config).unwrap();
5501
-
5502
- assert!(entries.iter().any(|entry| {
5503
- entry.name == "Bunny Girl" && entry.kind == "directory" && entry.size.is_none()
5504
- }));
5505
- assert!(entries.iter().any(|entry| {
5506
- entry.name == "note.txt" && entry.kind == "file" && entry.size == Some(5)
5507
- }));
5508
- let _ = std::fs::remove_file(root.join("note.txt"));
5509
- let _ = std::fs::remove_dir(root.join("Bunny Girl"));
5510
- let _ = std::fs::remove_dir(root);
5511
- }
5512
-
5513
- #[test]
5514
- fn grep_is_classified_as_read_only() {
5515
- let classification = classify_shell_command("ls ~/Downloads | grep -F \"Bunny Girl\"");
5516
-
5517
- assert_eq!(classification.mode.as_str(), "readOnly");
5518
- }
5519
-
5520
- #[test]
5521
- fn test_resolve_agent_config() {
5522
- let mut config = MintConfig::default();
5523
- config.enable_agent_collaboration = true;
5524
- config.agents = vec![
5525
- AgentConfig {
5526
- id: "planner".into(),
5527
- name: "Planner".into(),
5528
- provider: "openai".into(),
5529
- model: "gpt-4o".into(),
5530
- api_key: Some("test-key".into()),
5531
- system_instruction: "Planner instruction".into(),
5532
- enabled: true,
5533
- },
5534
- AgentConfig {
5535
- id: "coder".into(),
5536
- name: "Coder".into(),
5537
- provider: "gemini".into(),
5538
- model: "gemini-2.5-flash".into(),
5539
- api_key: None,
5540
- system_instruction: "Coder instruction".into(),
5541
- enabled: true,
5542
- },
5543
- ];
5544
-
5545
- let (cfg, instr, name, model) = resolve_agent_config(&config, Some("planner"), &[]);
5546
- assert_eq!(cfg.ai_provider, "openai");
5547
- assert_eq!(cfg.openai_model, "gpt-4o");
5548
- assert_eq!(cfg.openai_api_key, "test-key");
5549
- assert_eq!(instr, "Planner instruction");
5550
- assert_eq!(name, Some("Planner".to_string()));
5551
- assert_eq!(model, Some("gpt-4o".to_string()));
5552
-
5553
- let (cfg, instr, name, _model) = resolve_agent_config(&config, None, &[]);
5554
- assert_eq!(cfg.ai_provider, "openai");
5555
- assert_eq!(instr, "Planner instruction");
5556
- assert_eq!(name, Some("Planner".to_string()));
5557
-
5558
- let trajectory =
5559
- vec!["Step 1:\n- Action: create_plan\n- Observation: all planned".to_string()];
5560
- let (cfg, instr, name, model) = resolve_agent_config(&config, None, &trajectory);
5561
- assert_eq!(cfg.ai_provider, "gemini");
5562
- assert_eq!(instr, "Coder instruction");
5563
- assert_eq!(name, Some("Coder".to_string()));
5564
- assert_eq!(model, Some("gemini-2.5-flash".to_string()));
5565
- }
5566
-
5567
- fn step_pair(index: usize) -> [ChatMessage; 2] {
5568
- [
5569
- ChatMessage {
5570
- role: ChatRole::Assistant,
5571
- content: vec![ContentBlock::ToolUse {
5572
- id: format!("call_{index}"),
5573
- name: "read_file".into(),
5574
- input: serde_json::json!({ "path": format!("file{index}.rs") }),
5575
- thought_signature: None,
5576
- }],
5577
- },
5578
- ChatMessage {
5579
- role: ChatRole::Tool,
5580
- content: vec![ContentBlock::ToolResult {
5581
- tool_use_id: format!("call_{index}"),
5582
- content: format!("contents of file{index}.rs"),
5583
- is_error: false,
5584
- }],
5585
- },
5586
- ]
5587
- }
5588
-
5589
- fn native_messages_with_steps(step_count: usize) -> Vec<ChatMessage> {
5590
- let mut messages = vec![ChatMessage::text(ChatRole::User, "do the task")];
5591
- for i in 0..step_count {
5592
- messages.extend(step_pair(i));
5593
- }
5594
- messages
5595
- }
5596
-
5597
- #[tokio::test]
5598
- async fn compact_native_messages_is_a_noop_when_history_is_short() {
5599
- // COMPACTION_KEEP_RECENT_STEPS = 3, so exactly 3 step-pairs is nothing to compact yet.
5600
- let messages = native_messages_with_steps(3);
5601
- let config = MintConfig::default();
5602
- let result = compact_native_messages(&config, &messages).await.unwrap();
5603
- assert!(result.is_none());
5604
- }
5605
-
5606
- #[tokio::test]
5607
- async fn compact_native_messages_is_a_noop_for_empty_history() {
5608
- let config = MintConfig::default();
5609
- let result = compact_native_messages(&config, &[]).await.unwrap();
5610
- assert!(result.is_none());
5611
- }
5612
-
5613
- #[test]
5614
- fn render_messages_as_text_includes_tool_calls_and_results() {
5615
- let messages = native_messages_with_steps(1);
5616
- let rendered = render_messages_as_text(&messages);
5617
- assert!(rendered.contains("Called read_file with"));
5618
- assert!(rendered.contains("Result: contents of file0.rs"));
5619
- assert!(rendered.contains("do the task"));
5620
- }
5621
-
5622
- #[test]
5623
- fn no_modification_means_finish_never_needs_verification() {
5624
- // A pure Q&A run that never touched apply_patch/write_file can finish
5625
- // immediately, regardless of what (if anything) is in `verification`.
5626
- assert!(!unverified_modification(None, None, ""));
5627
- assert!(!unverified_modification(None, Some(1), ""));
5628
- }
5629
-
5630
- #[test]
5631
- fn modification_without_any_verify_call_blocks_finish() {
5632
- assert!(unverified_modification(Some(2), None, ""));
5633
- }
5634
-
5635
- #[test]
5636
- fn verify_called_before_the_modification_does_not_count() {
5637
- // Editing again after the last verify invalidates that earlier check.
5638
- assert!(unverified_modification(Some(3), Some(1), ""));
5639
- }
5640
-
5641
- #[test]
5642
- fn verify_called_after_the_modification_satisfies_the_gate() {
5643
- assert!(!unverified_modification(Some(2), Some(3), ""));
5644
- }
5645
-
5646
- #[test]
5647
- fn verify_on_the_same_step_as_the_modification_satisfies_the_gate() {
5648
- // Both markers can land on the same step if a single turn issues
5649
- // multiple tool calls (apply_patch then verify back to back).
5650
- assert!(!unverified_modification(Some(2), Some(2), ""));
5651
- }
5652
-
5653
- #[test]
5654
- fn explicit_written_justification_does_not_verify_an_unchecked_edit() {
5655
- assert!(unverified_modification(
5656
- Some(2),
5657
- None,
5658
- "Documentation-only change; no test suite in this repo."
5659
- ));
5660
- }
5661
-
5662
- #[test]
5663
- fn placeholder_verification_text_does_not_satisfy_the_gate() {
5664
- // Mirrors `meaningful_verification`'s own placeholder filter — "n/a" etc.
5665
- // must not be treated as a real justification.
5666
- assert!(unverified_modification(Some(2), None, "n/a"));
5667
- assert!(unverified_modification(Some(2), None, "not run"));
5668
- }
5669
-
5670
- fn decision_with_action(action: &str) -> (String, AgentDecision) {
5671
- (
5672
- "call_0".to_string(),
5673
- AgentDecision {
5674
- thought: String::new(),
5675
- action: action.to_string(),
5676
- input: AgentInput::default(),
5677
- },
5678
- )
5679
- }
5680
-
5681
- #[test]
5682
- fn empty_decisions_never_qualify_for_the_parallel_batch() {
5683
- assert!(!decisions_are_parallel_read_only_batch(&[]));
5684
- }
5685
-
5686
- #[test]
5687
- fn two_or_more_read_only_decisions_qualify_for_the_parallel_batch() {
5688
- let decisions = vec![
5689
- decision_with_action("read_file"),
5690
- decision_with_action("search_code"),
5691
- ];
5692
- assert!(decisions_are_parallel_read_only_batch(&decisions));
5693
-
5694
- let decisions = vec![
5695
- decision_with_action("read_file"),
5696
- decision_with_action("web_search"),
5697
- decision_with_action("git_diff"),
5698
- ];
5699
- assert!(decisions_are_parallel_read_only_batch(&decisions));
5700
- }
5701
-
5702
- #[test]
5703
- fn a_lone_read_only_decision_stays_sequential() {
5704
- let decisions = vec![decision_with_action("read_file")];
5705
- assert!(!decisions_are_parallel_read_only_batch(&decisions));
5706
- }
5707
-
5708
- #[test]
5709
- fn read_only_mixed_with_mutating_action_stays_sequential() {
5710
- let decisions = vec![
5711
- decision_with_action("read_file"),
5712
- decision_with_action("write_file"),
5713
- ];
5714
- assert!(!decisions_are_parallel_read_only_batch(&decisions));
5715
-
5716
- let decisions = vec![
5717
- decision_with_action("read_file"),
5718
- decision_with_action("run_shell"),
5719
- ];
5720
- assert!(!decisions_are_parallel_read_only_batch(&decisions));
5721
- }
5722
-
5723
- #[test]
5724
- fn rebuild_observation_reuses_buffer_and_formats_history() {
5725
- let root = Path::new("/workspace/test-project");
5726
- let task = "Fix the login bug";
5727
- let step1 = format_trajectory_step(1, "Inspecting files", "list_files", "src/main.rs");
5728
- let step2 = format_trajectory_step(2, "Reading code", "read_file", "fn main() {}");
5729
- let trajectory = vec![step1, step2];
5730
-
5731
- let mut buffer = String::new();
5732
- rebuild_observation(task, root, &trajectory, &mut buffer);
5733
-
5734
- assert!(buffer.contains("Task: Fix the login bug"));
5735
- assert!(buffer.contains("Workspace: /workspace/test-project"));
5736
- assert!(buffer.contains("Step 1:\n- Thought: Inspecting files"));
5737
- assert!(buffer.contains("Step 2:\n- Thought: Reading code"));
5738
- assert!(buffer.ends_with(
5739
- "Proceed to the next step. If you have completed the task, use the 'finish' action."
5740
- ));
5741
-
5742
- // Ensure buffer can be reused cleanly for subsequent steps
5743
- let step3 = format_trajectory_step(3, "All done", "finish", "Fixed");
5744
- let mut extended_trajectory = trajectory;
5745
- extended_trajectory.push(step3);
5746
- rebuild_observation(task, root, &extended_trajectory, &mut buffer);
5747
-
5748
- assert!(buffer.contains("Step 3:\n- Thought: All done"));
5749
- }
5750
- }
5751
-
5752
- #[cfg(test)]
5753
- mod full_skill_result_tests {
5754
- use super::*;
5755
- #[test]
5756
- fn large_loaded_skill_results_reach_the_model_without_losing_middle_instructions() {
5757
- let content = format!(
5758
- "[Loaded skill: review]\n{}\nREQUIRED-MIDDLE-INSTRUCTION\n{}",
5759
- "a".repeat(40_000),
5760
- "z".repeat(40_000)
5761
- );
5762
- assert_eq!(model_tool_result("read_file", &content), content);
5763
- assert!(model_tool_result("run_shell", &"x".repeat(80_000)).len() < 20_000);
5764
- }
5765
- }
2702
+ #[cfg(test)]
2703
+ mod full_skill_result_tests;
5766
2704
 
5767
2705
  #[cfg(all(test, unix))]
5768
- mod mcp_outcome_tests {
5769
- use super::*;
5770
- use std::time::Duration;
5771
- fn generic_server(tool: &str, response: Value) -> String {
5772
- format!(
5773
- r#"
5774
- import sys,json
5775
- response=json.loads({:?})
5776
- for line in sys.stdin:
5777
- r=json.loads(line);m=r.get('method')
5778
- if m=='initialize': result={{}}
5779
- elif m=='tools/list': result={{'tools':[{{'name':{:?},'inputSchema':{{'type':'object'}}}}]}}
5780
- elif m=='tools/call': result=response
5781
- else: continue
5782
- print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
5783
- "#,
5784
- response.to_string(),
5785
- tool
5786
- )
5787
- }
5788
-
5789
- #[test]
5790
- fn compact_device_progress_keeps_acceptance_and_latest_observation() {
5791
- let events=["accepted","running","running","verified"].into_iter().enumerate().map(|(seq,state)|serde_json::from_value(serde_json::json!({"type":"DeviceState","data":{"callId":"one","server":"motor","tool":"move","update":{"state":state,"target":{"rpm":100},"actual":{"rpm":seq},"observationSeq":seq,"elapsedSecs":seq,"message":"observed"}}})).unwrap()).collect::<Vec<AgentProgress>>();
5792
- let compacted = compact_agent_progress(&events);
5793
- assert_eq!(
5794
- compacted.len(),
5795
- 2,
5796
- "saved history should retain acceptance and the final fresh observation"
5797
- );
5798
- assert!(matches!(
5799
- &compacted[1],
5800
- AgentProgress::DeviceState {
5801
- update: crate::mcp::device::DeviceUpdate {
5802
- state: crate::mcp::device::DevicePhase::Verified,
5803
- ..
5804
- },
5805
- ..
5806
- }
5807
- ));
5808
- }
5809
-
5810
- #[tokio::test]
5811
- async fn mcp_definitions_reach_native_and_json_models_before_any_command() {
5812
- use tokio::io::{AsyncReadExt, AsyncWriteExt};
5813
- for native in [true, false] {
5814
- let marker =
5815
- std::env::temp_dir().join(format!("mint-model-command-{}", uuid::Uuid::new_v4()));
5816
- let server = format!("model-mcp-{}", uuid::Uuid::new_v4());
5817
- let mut cfg = MintConfig::default();
5818
- let script=generic_server("move",serde_json::json!({"content":[{"type":"text","text":"moved"}]})).replace("elif m=='tools/call': result=response",&format!("elif m=='tools/call':\n open({:?},'a').write('called\\n')\n result=response",marker.to_string_lossy())).replace("'name':\"move\",'inputSchema'","'name':\"move\",'description':'Move the mock axis. RPM are integer units.', 'inputSchema'");
5819
- cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":5}}));
5820
- cfg.extra.insert(
5821
- "allowedMcpTools".into(),
5822
- serde_json::json!({server.clone():["*"]}),
5823
- );
5824
- cfg.ai_provider = "ollama".into();
5825
- cfg.ollama_model = "qwen2.5".into();
5826
- cfg.extra
5827
- .insert("forceJsonPromptMode".into(), serde_json::json!(!native));
5828
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5829
- cfg.ollama_host = format!("http://{}", listener.local_addr().unwrap());
5830
- let recorded = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
5831
- let requests = Arc::clone(&recorded);
5832
- let observed_marker = marker.clone();
5833
- let name = server.clone();
5834
- let provider = tokio::spawn(async move {
5835
- for step in 0..3 {
5836
- let (mut socket, _) = listener.accept().await.unwrap();
5837
- let mut bytes = Vec::new();
5838
- let mut chunk = [0u8; 4096];
5839
- let (end, len) = loop {
5840
- let n = socket.read(&mut chunk).await.unwrap();
5841
- assert!(n > 0);
5842
- bytes.extend_from_slice(&chunk[..n]);
5843
- if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
5844
- let header = String::from_utf8_lossy(&bytes[..end]);
5845
- let len = header
5846
- .lines()
5847
- .find_map(|l| {
5848
- l.to_ascii_lowercase()
5849
- .strip_prefix("content-length:")
5850
- .and_then(|v| v.trim().parse::<usize>().ok())
5851
- })
5852
- .unwrap();
5853
- break (end + 4, len);
5854
- }
5855
- };
5856
- while bytes.len() < end + len {
5857
- let n = socket.read(&mut chunk).await.unwrap();
5858
- assert!(n > 0);
5859
- bytes.extend_from_slice(&chunk[..n]);
5860
- }
5861
- requests
5862
- .lock()
5863
- .unwrap()
5864
- .push(serde_json::from_slice(&bytes[end..end + len]).unwrap());
5865
- if step == 1 {
5866
- assert!(
5867
- !observed_marker.exists(),
5868
- "initial preparation sent a tools/call"
5869
- );
5870
- }
5871
- let (action, input) = if step < 2 {
5872
- (
5873
- "mcp_tool",
5874
- serde_json::json!({"server":name,"tool":"move","arguments":{}}),
5875
- )
5876
- } else {
5877
- (
5878
- "finish",
5879
- serde_json::json!({"summary":"done","verification":"Mock command verified"}),
5880
- )
5881
- };
5882
- let message = if native {
5883
- serde_json::json!({"role":"assistant","content":"","tool_calls":[{"function":{"name":action,"arguments":input}}]})
5884
- } else {
5885
- serde_json::json!({"role":"assistant","content":serde_json::json!({"thought":"test","action":action,"input":input}).to_string()})
5886
- };
5887
- let body = format!(
5888
- "{}\n",
5889
- serde_json::json!({"model":"qwen2.5","message":message,"done":true,"prompt_eval_count":100,"eval_count":10})
5890
- );
5891
- socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap();
5892
- }
5893
- });
5894
- let chat = format!("model-review-{}", uuid::Uuid::new_v4());
5895
- let mut events = Vec::new();
5896
- let outcome = tokio::time::timeout(
5897
- Duration::from_secs(15),
5898
- orchestrate_agent_loop(
5899
- &cfg,
5900
- "Use the mock MCP move tool",
5901
- Path::new("."),
5902
- None,
5903
- None,
5904
- None,
5905
- Some(&chat),
5906
- None,
5907
- None,
5908
- None,
5909
- true,
5910
- false,
5911
- |_| Ok(ApprovalOutcome::Denied),
5912
- |e| events.push(e),
5913
- |_| {},
5914
- ),
5915
- )
5916
- .await;
5917
- provider.abort();
5918
- crate::mcp::close_mcp_session(&server);
5919
- assert!(
5920
- matches!(outcome, Ok(Ok(_))),
5921
- "native={native}, {outcome:?}; requests={}, events={:?}",
5922
- recorded.lock().unwrap().len(),
5923
- events.iter().rev().take(5).collect::<Vec<_>>()
5924
- );
5925
- let requests = recorded.lock().unwrap();
5926
- assert_eq!(requests.len(), 3);
5927
- assert!(
5928
- requests[1].to_string().contains("Move the mock axis"),
5929
- "full tool definition missing from model request: {}",
5930
- requests[1]
5931
- );
5932
- assert!(
5933
- requests[2].to_string().contains("Move the mock axis"),
5934
- "definition must remain in later requests"
5935
- );
5936
- assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1);
5937
- std::fs::remove_file(marker).unwrap();
5938
- assert_eq!(
5939
- events
5940
- .iter()
5941
- .filter(
5942
- |e| matches!(e,AgentProgress::ToolStart {action,..} if action=="mcp_tool")
5943
- )
5944
- .count(),
5945
- 1
5946
- );
5947
- let memory = MemoryStore::open_default().unwrap();
5948
- let _ = memory.delete_chat_session(&chat);
5949
- }
5950
- }
5951
-
5952
- #[tokio::test]
5953
- async fn device_command_waits_for_a_fresh_verified_observation() {
5954
- let server = format!("device-{}", uuid::Uuid::new_v4());
5955
- let mut cfg = MintConfig::default();
5956
- cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"node","args":[Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/device-mcp/server.mjs")],"timeoutSecs":5}}));
5957
- cfg.extra.insert(
5958
- "allowedMcpTools".into(),
5959
- serde_json::json!({server.clone():["*"]}),
5960
- );
5961
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock-device","rpm":100}}})).unwrap();
5962
- let mut events = Vec::new();
5963
- let out = execute_tool_outcome(
5964
- Path::new("."),
5965
- &cfg,
5966
- &decision,
5967
- "device-test",
5968
- &mut |_| Ok(ApprovalOutcome::Denied),
5969
- &mut |e| events.push(e),
5970
- "device-command",
5971
- )
5972
- .await
5973
- .unwrap();
5974
- crate::mcp::close_mcp_session(&server);
5975
- assert_eq!(
5976
- out.status,
5977
- crate::integrations::mcp_result::ToolStatus::Success
5978
- );
5979
- let events = serde_json::to_value(events).unwrap();
5980
- assert!(
5981
- events
5982
- .as_array()
5983
- .unwrap()
5984
- .iter()
5985
- .any(|e| e["type"] == "DeviceState" && e["data"]["update"]["state"] == "verified"),
5986
- "receipt must not be considered a verified device state: {events}"
5987
- );
5988
- }
5989
-
5990
- #[tokio::test]
5991
- async fn device_capture_preserves_verified_images_and_explicit_stop_confirms_zero() {
5992
- let server = format!("device-photo-{}", uuid::Uuid::new_v4());
5993
- let mut cfg = MintConfig::default();
5994
- cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"node","args":[Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/device-mcp/server.mjs")],"timeoutSecs":10}}));
5995
- cfg.extra.insert(
5996
- "allowedMcpTools".into(),
5997
- serde_json::json!({server.clone():["*"]}),
5998
- );
5999
- let photo:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"capture_photo","arguments":{"deviceId":"mock-device"}}})).unwrap();
6000
- let out = execute_tool_outcome(
6001
- Path::new("."),
6002
- &cfg,
6003
- &photo,
6004
- "photo-test",
6005
- &mut |_| Ok(ApprovalOutcome::Denied),
6006
- &mut |_| {},
6007
- "photo-command",
6008
- )
6009
- .await
6010
- .unwrap();
6011
- assert_eq!(
6012
- out.status,
6013
- crate::integrations::mcp_result::ToolStatus::Success
6014
- );
6015
- assert_eq!(out.images.len(), 1);
6016
- assert!(out.warnings.is_empty());
6017
- remove_test_artifacts(&out.artifacts);
6018
- crate::mcp::call_mcp_tool_async(
6019
- &cfg,
6020
- &server,
6021
- "set_speed",
6022
- serde_json::json!({"deviceId":"mock-device","rpm":1000}),
6023
- "photo-test",
6024
- |_| {},
6025
- )
6026
- .await
6027
- .unwrap();
6028
- let stop:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"stop","arguments":{"deviceId":"mock-device"}}})).unwrap();
6029
- let mut events = Vec::new();
6030
- let out = execute_tool_outcome(
6031
- Path::new("."),
6032
- &cfg,
6033
- &stop,
6034
- "photo-test",
6035
- &mut |_| Ok(ApprovalOutcome::Denied),
6036
- &mut |e| events.push(e),
6037
- "stop-command",
6038
- )
6039
- .await
6040
- .unwrap();
6041
- crate::mcp::close_mcp_session(&server);
6042
- assert_eq!(
6043
- out.status,
6044
- crate::integrations::mcp_result::ToolStatus::Success
6045
- );
6046
- assert!(events.iter().any(|e|matches!(e,AgentProgress::DeviceState {update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified) && update.actual["rpm"]==0)));
6047
- }
6048
-
6049
- fn device_fixture(status: Value, timeout: u64) -> (MintConfig, String, PathBuf) {
6050
- let name = format!("device-fault-{}", uuid::Uuid::new_v4());
6051
- let log = std::env::temp_dir().join(format!("mint-device-calls-{}", uuid::Uuid::new_v4()));
6052
- let schema = serde_json::json!({"type":"object","properties":{"deviceId":{"type":"string"},"operationId":{"type":"string"},"rpm":{"type":"integer"}},"required":["deviceId"],"additionalProperties":false});
6053
- let tools = serde_json::json!([{"name":"set_speed","inputSchema":schema,"outputSchema":{"type":"object"},"_meta":{"mint/device":{"version":1,"statusTool":"get_status"}}},{"name":"get_status","inputSchema":schema,"outputSchema":{"type":"object"}}]);
6054
- let receipt = serde_json::json!({"deviceId":"mock","operationId":"one","state":"accepted","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":0}});
6055
- let script = format!(
6056
- r#"
6057
- import sys,json
6058
- tools=json.loads({:?});receipt=json.loads({:?});status=json.loads({:?})
6059
- for line in sys.stdin:
6060
- r=json.loads(line);m=r.get('method')
6061
- if m=='initialize': result={{}}
6062
- elif m=='tools/list': result={{'tools':tools}}
6063
- elif m=='tools/call':
6064
- with open({:?},'a') as f: f.write(json.dumps(r['params'])+'\n')
6065
- result={{'structuredContent':receipt if r['params']['name']=='set_speed' else status,'content':[]}}
6066
- else: continue
6067
- print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
6068
- "#,
6069
- tools.to_string(),
6070
- receipt.to_string(),
6071
- status.to_string(),
6072
- log.to_string_lossy()
6073
- );
6074
- let mut cfg = MintConfig::default();
6075
- cfg.extra.insert("mcpServers".into(),serde_json::json!({name.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":timeout}}));
6076
- cfg.extra.insert(
6077
- "allowedMcpTools".into(),
6078
- serde_json::json!({name.clone():["*"]}),
6079
- );
6080
- (cfg, name, log)
6081
- }
6082
-
6083
- #[tokio::test]
6084
- async fn device_stale_observations_cannot_confirm_completion() {
6085
- let (cfg, server, log) = device_fixture(
6086
- serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":100}}),
6087
- 1,
6088
- );
6089
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6090
- let out = execute_tool_outcome(
6091
- Path::new("."),
6092
- &cfg,
6093
- &decision,
6094
- "stale-test",
6095
- &mut |_| Ok(ApprovalOutcome::Denied),
6096
- &mut |_| {},
6097
- "stale-command",
6098
- )
6099
- .await
6100
- .unwrap();
6101
- crate::mcp::close_mcp_session(&server);
6102
- let calls = std::fs::read_to_string(&log).unwrap();
6103
- std::fs::remove_file(log).unwrap();
6104
- assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
6105
- assert_eq!(
6106
- out.status,
6107
- crate::integrations::mcp_result::ToolStatus::TimedOut
6108
- );
6109
- assert!(
6110
- out.completion_unconfirmed,
6111
- "a dispatched command remains uncertain after stale observations"
6112
- );
6113
- }
6114
-
6115
- #[tokio::test]
6116
- async fn review_device_session_loss_preserves_receipt_without_reconnecting_or_repeating() {
6117
- let (mut cfg, server, log) = device_fixture(
6118
- serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":50}}),
6119
- 5,
6120
- );
6121
- let starts = log.with_extension("starts");
6122
- let script = cfg.extra["mcpServers"][&server]["args"][2]
6123
- .as_str()
6124
- .unwrap()
6125
- .replace(
6126
- "import sys,json",
6127
- &format!(
6128
- "import sys,json,os\nopen({:?},'a').write('start\\n')",
6129
- starts.to_string_lossy()
6130
- ),
6131
- );
6132
- cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(format!(
6133
- "{script}\n if m=='tools/call' and r['params']['name']=='get_status': os._exit(0)\n"
6134
- ));
6135
- let decision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6136
- let mut events = Vec::new();
6137
- let out = execute_tool_outcome(
6138
- Path::new("."),
6139
- &cfg,
6140
- &decision,
6141
- "review-device-loss",
6142
- &mut |_| Ok(ApprovalOutcome::Denied),
6143
- &mut |e| events.push(e),
6144
- "command",
6145
- )
6146
- .await
6147
- .unwrap();
6148
- crate::mcp::close_mcp_session(&server);
6149
- let calls = std::fs::read_to_string(&log).unwrap();
6150
- assert_eq!(std::fs::read_to_string(&starts).unwrap(), "start\n");
6151
- let _ = std::fs::remove_file(log);
6152
- let _ = std::fs::remove_file(starts);
6153
- assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
6154
- assert_eq!(
6155
- calls.lines().filter(|l| l.contains("get_status")).count(),
6156
- 1
6157
- );
6158
- assert_eq!(out.status, crate::mcp_result::ToolStatus::Failed);
6159
- assert!(out.completion_unconfirmed);
6160
- assert!(
6161
- out.text.contains("one"),
6162
- "operation receipt lost: {}",
6163
- out.text
6164
- );
6165
- assert!(events.iter().any(|e|matches!(e,AgentProgress::DeviceState{update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Unconfirmed) && update.operation_id.as_deref()==Some("one"))));
6166
- assert!(!events.iter().any(|e|matches!(e,AgentProgress::DeviceState{update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified))));
6167
- }
6168
-
6169
- #[tokio::test]
6170
- async fn device_wrong_operation_and_physical_failure_never_report_success() {
6171
- for status in [
6172
- serde_json::json!({"deviceId":"mock","operationId":"other","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
6173
- serde_json::json!({"deviceId":"mock","operationId":"one","state":"failed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":0},"error":"jammed"}),
6174
- ] {
6175
- let (cfg, server, log) = device_fixture(status, 3);
6176
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6177
- let out = execute_tool_outcome(
6178
- Path::new("."),
6179
- &cfg,
6180
- &decision,
6181
- "wrong-operation",
6182
- &mut |_| Ok(ApprovalOutcome::Denied),
6183
- &mut |_| {},
6184
- "wrong-command",
6185
- )
6186
- .await
6187
- .unwrap();
6188
- crate::mcp::close_mcp_session(&server);
6189
- std::fs::remove_file(log).unwrap();
6190
- assert_eq!(
6191
- out.status,
6192
- crate::integrations::mcp_result::ToolStatus::Failed
6193
- );
6194
- assert!(
6195
- out.text.starts_with("Error:"),
6196
- "Native tool results must carry an error marker for failed verification: {}",
6197
- out.text
6198
- );
6199
- assert!(
6200
- !out.text.contains("no tool was executed"),
6201
- "verification errors must acknowledge the already dispatched command: {}",
6202
- out.text
6203
- );
6204
- }
6205
- }
6206
-
6207
- #[tokio::test]
6208
- async fn device_failed_receipt_is_terminal_without_status_polling() {
6209
- let (mut cfg, server, log) = device_fixture(
6210
- serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
6211
- 3,
6212
- );
6213
- let script = cfg.extra["mcpServers"][&server]["args"][2]
6214
- .as_str()
6215
- .unwrap()
6216
- .replace("accepted", "failed");
6217
- cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(script);
6218
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6219
- let out = execute_tool_outcome(
6220
- Path::new("."),
6221
- &cfg,
6222
- &decision,
6223
- "failed-receipt",
6224
- &mut |_| Ok(ApprovalOutcome::Denied),
6225
- &mut |_| {},
6226
- "failed-command",
6227
- )
6228
- .await
6229
- .unwrap();
6230
- crate::mcp::close_mcp_session(&server);
6231
- let calls = std::fs::read_to_string(&log).unwrap();
6232
- std::fs::remove_file(log).unwrap();
6233
- assert_eq!(
6234
- out.status,
6235
- crate::integrations::mcp_result::ToolStatus::Failed
6236
- );
6237
- assert!(
6238
- !calls.contains("get_status"),
6239
- "failed receipt must not be overwritten by a later status"
6240
- );
6241
- }
6242
-
6243
- #[tokio::test]
6244
- async fn device_denied_status_access_blocks_movement_before_dispatch() {
6245
- let (mut cfg, server, log) = device_fixture(serde_json::json!({}), 3);
6246
- cfg.extra.insert(
6247
- "allowedMcpTools".into(),
6248
- serde_json::json!({server.clone():["set_speed"]}),
6249
- );
6250
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6251
- let mut approvals = Vec::new();
6252
- let out = execute_tool_outcome(
6253
- Path::new("."),
6254
- &cfg,
6255
- &decision,
6256
- "denied-status",
6257
- &mut |a| {
6258
- approvals.push(serde_json::to_value(a).unwrap());
6259
- Ok(ApprovalOutcome::Denied)
6260
- },
6261
- &mut |_| {},
6262
- "blocked-command",
6263
- )
6264
- .await
6265
- .unwrap();
6266
- crate::mcp::close_mcp_session(&server);
6267
- assert!(
6268
- !log.exists(),
6269
- "command was dispatched without access to status verification"
6270
- );
6271
- assert_eq!(
6272
- out.status,
6273
- crate::integrations::mcp_result::ToolStatus::Failed
6274
- );
6275
- assert!(
6276
- serde_json::to_string(&approvals)
6277
- .unwrap()
6278
- .contains("get_status")
6279
- );
6280
- }
6281
-
6282
- #[tokio::test]
6283
- async fn canceling_between_device_observations_does_not_issue_another_command() {
6284
- let (cfg, server, log) = device_fixture(
6285
- serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":20}}),
6286
- 15,
6287
- );
6288
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
6289
- let chat = format!("cancel-between-{}", uuid::Uuid::new_v4());
6290
- let name = chat.clone();
6291
- let (tx, rx) = tokio::sync::oneshot::channel();
6292
- let mut tx = Some(tx);
6293
- let running = tokio::spawn(async move {
6294
- execute_tool_outcome(
6295
- Path::new("."),
6296
- &cfg,
6297
- &decision,
6298
- &name,
6299
- &mut |_| Ok(ApprovalOutcome::Denied),
6300
- &mut |e| {
6301
- if matches!(
6302
- e,
6303
- AgentProgress::DeviceState {
6304
- update: crate::mcp::device::DeviceUpdate {
6305
- state: crate::mcp::device::DevicePhase::Running,
6306
- ..
6307
- },
6308
- ..
6309
- }
6310
- ) && let Some(tx) = tx.take()
6311
- {
6312
- let _ = tx.send(());
6313
- }
6314
- },
6315
- "cancel-command",
6316
- )
6317
- .await
6318
- .unwrap()
6319
- });
6320
- tokio::time::timeout(Duration::from_secs(10), rx)
6321
- .await
6322
- .unwrap()
6323
- .unwrap();
6324
- tokio::time::sleep(Duration::from_millis(100)).await;
6325
- assert!(crate::mcp::cancel_mcp_calls(&chat) > 0);
6326
- let out = tokio::time::timeout(Duration::from_secs(2), running)
6327
- .await
6328
- .unwrap()
6329
- .unwrap();
6330
- crate::mcp::close_mcp_session(&server);
6331
- assert_eq!(
6332
- out.status,
6333
- crate::integrations::mcp_result::ToolStatus::Cancelled
6334
- );
6335
- let calls = std::fs::read_to_string(&log).unwrap();
6336
- std::fs::remove_file(log).unwrap();
6337
- assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
6338
- assert!(!calls.contains("\"stop\""));
6339
- }
6340
-
6341
- #[tokio::test]
6342
- async fn approval_feedback_does_not_mark_an_unexecuted_mcp_call_successful() {
6343
- let decision: AgentDecision = serde_json::from_value(serde_json::json!({
6344
- "action":"mcp_tool", "input":{"server":"feedback-server","tool":"render","arguments":{}}
6345
- }))
6346
- .unwrap();
6347
- let mut config = MintConfig::default();
6348
- config.extra.insert("mcpServers".into(),serde_json::json!({"feedback-server":{"command":"python3","args":["-u","-c",generic_server("render",serde_json::json!({}))]}}));
6349
- let out = execute_tool_outcome(
6350
- Path::new("."),
6351
- &config,
6352
- &decision,
6353
- "feedback",
6354
- &mut |_| {
6355
- Ok(ApprovalOutcome::Intercepted(
6356
- "Use a different device".into(),
6357
- ))
6358
- },
6359
- &mut |_| {},
6360
- "not-executed",
6361
- )
6362
- .await
6363
- .unwrap();
6364
- assert!(out.text.contains("Use a different device"));
6365
- assert!(out.text.contains("no command was executed"));
6366
- assert_eq!(
6367
- out.status,
6368
- crate::integrations::mcp_result::ToolStatus::Failed
6369
- );
6370
- }
6371
-
6372
- #[tokio::test]
6373
- async fn text_only_model_keeps_artifacts_without_receiving_image_blocks() {
6374
- use base64::Engine as _;
6375
- let mut bytes = std::io::Cursor::new(Vec::new());
6376
- image::DynamicImage::new_rgb8(1, 1)
6377
- .write_to(&mut bytes, image::ImageFormat::Png)
6378
- .unwrap();
6379
- let encoded = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
6380
- let response =
6381
- serde_json::json!({"content":[{"type":"image","mimeType":"image/png","data":encoded}]})
6382
- .to_string();
6383
- let script = generic_server("preview", serde_json::from_str(&response).unwrap());
6384
- let server = format!("text-model-{}", uuid::Uuid::new_v4());
6385
- let mut cfg = MintConfig::default();
6386
- cfg.extra.insert(
6387
- "mcpServers".into(),
6388
- serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
6389
- );
6390
- cfg.extra.insert(
6391
- "allowedMcpTools".into(),
6392
- serde_json::json!({server.clone():["preview"]}),
6393
- );
6394
- cfg.extra.insert("mcpImageInput".into(), Value::Bool(false));
6395
- let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
6396
- let out = execute_tool_outcome(
6397
- Path::new("."),
6398
- &cfg,
6399
- &decision,
6400
- "text-only",
6401
- &mut |_| Ok(ApprovalOutcome::Denied),
6402
- &mut |_| {},
6403
- "preview",
6404
- )
6405
- .await
6406
- .unwrap();
6407
- assert!(out.images.is_empty());
6408
- assert_eq!(out.artifacts.len(), 1);
6409
- assert!(out.text.contains("do not claim visual inspection"));
6410
- crate::mcp::close_mcp_session(&server);
6411
- remove_test_artifacts(&out.artifacts);
6412
- }
6413
-
6414
- #[test]
6415
- fn multiple_images_attach_to_the_correct_native_tool_result() {
6416
- let images = std::collections::HashMap::from([(
6417
- "call-2".to_string(),
6418
- vec![
6419
- "data:image/png;base64,first".into(),
6420
- "data:image/png;base64,second".into(),
6421
- ],
6422
- )]);
6423
- let results = vec![
6424
- (
6425
- "call-1".into(),
6426
- "mcp_tool".into(),
6427
- Value::Null,
6428
- "one".into(),
6429
- )
6430
- .into(),
6431
- (
6432
- "call-2".into(),
6433
- "mcp_tool".into(),
6434
- Value::Null,
6435
- "two".into(),
6436
- )
6437
- .into(),
6438
- ];
6439
- let mut messages = Vec::new();
6440
- append_native_tool_results(&mut messages, "", &results, &Default::default(), &images);
6441
- assert!(
6442
- matches!(&messages[1].content[1],ContentBlock::ToolResult {tool_use_id,..} if tool_use_id=="call-2")
6443
- );
6444
- assert!(
6445
- matches!(&messages[1].content[2],ContentBlock::Image {data_uri} if data_uri.ends_with("first"))
6446
- );
6447
- assert!(
6448
- matches!(&messages[1].content[3],ContentBlock::Image {data_uri} if data_uri.ends_with("second"))
6449
- );
6450
- }
6451
-
6452
- #[tokio::test]
6453
- async fn mcp_images_do_not_enter_the_text_observation() {
6454
- let server = format!("image-{}", uuid::Uuid::new_v4());
6455
- let mut bytes = std::io::Cursor::new(Vec::new());
6456
- image::DynamicImage::new_rgb8(1, 1)
6457
- .write_to(&mut bytes, image::ImageFormat::Png)
6458
- .unwrap();
6459
- let png = BASE64_STANDARD.encode(bytes.into_inner());
6460
- let result = serde_json::json!({"content":[{"type":"text","text":"preview"},{"type":"image","mimeType":"image/png","data":png}]}).to_string();
6461
- let script = generic_server("preview", serde_json::from_str(&result).unwrap());
6462
- let mut config = MintConfig::default();
6463
- config.extra.insert(
6464
- "mcpServers".into(),
6465
- serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
6466
- );
6467
- let decision: AgentDecision = serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
6468
- let mut events = Vec::new();
6469
- let out = execute_tool_outcome(
6470
- Path::new("."),
6471
- &config,
6472
- &decision,
6473
- "image-test",
6474
- &mut |_| Ok(ApprovalOutcome::Approved),
6475
- &mut |event| events.push(event),
6476
- "image-invocation",
6477
- )
6478
- .await
6479
- .unwrap();
6480
- assert!(
6481
- !out.text.contains(&png),
6482
- "binary data must be removed from the model's text observation"
6483
- );
6484
- assert_eq!(
6485
- out.status,
6486
- crate::integrations::mcp_result::ToolStatus::Success
6487
- );
6488
- assert_eq!(out.images.len(), 1);
6489
- assert_eq!(out.artifacts[0].call_id, "image-invocation");
6490
- assert!(out.text.contains("preview"));
6491
- assert!(!serde_json::to_string(&events).unwrap().contains(&png));
6492
- assert!(
6493
- !crate::mcp::is_mcp_tool_allowed(&config, &server, "preview"),
6494
- "one-call approval must not persist a grant"
6495
- );
6496
- crate::mcp::close_mcp_session(&server);
6497
- remove_test_artifacts(&out.artifacts);
6498
- }
6499
-
6500
- fn remove_test_artifacts(artifacts: &[crate::integrations::mcp_result::McpArtifact]) {
6501
- let directory = crate::integrations::mcp_result::artifact_directory().unwrap();
6502
- for artifact in artifacts {
6503
- std::fs::remove_file(directory.join(&artifact.id)).unwrap();
6504
- std::fs::remove_file(directory.join(format!("{}.json", artifact.id))).unwrap();
6505
- }
6506
- }
6507
-
6508
- // A transport-successful tool failure must not become a successful agent step.
6509
- #[tokio::test]
6510
- async fn mcp_tool_failure_is_visible_to_the_agent() {
6511
- let server = format!("outcome-{}", uuid::Uuid::new_v4());
6512
- let mut config = MintConfig::default();
6513
- config.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",generic_server("render",serde_json::json!({"isError":true,"content":[{"type":"text","text":"render failed"}]}))]}}));
6514
- config.extra.insert(
6515
- "allowedMcpTools".into(),
6516
- serde_json::json!({server.clone():["render"]}),
6517
- );
6518
- let decision: AgentDecision = serde_json::from_value(serde_json::json!({
6519
- "thought":"render", "action":"mcp_tool", "input":{"server":server,"tool":"render","arguments":{}}
6520
- })).unwrap();
6521
- let result = execute_tool(
6522
- Path::new("."),
6523
- &config,
6524
- &decision,
6525
- "outcome-test",
6526
- &mut |_| Ok(ApprovalOutcome::Denied),
6527
- &mut |_| {},
6528
- )
6529
- .await
6530
- .unwrap();
6531
- assert!(
6532
- result.starts_with("Error:"),
6533
- "tool failure must reach the model as a failure: {result}"
6534
- );
6535
- assert!(result.contains("render failed"));
6536
- crate::mcp::close_mcp_session(&server);
6537
- }
6538
-
6539
- #[test]
6540
- fn test_fault_tolerant_null_handling() {
6541
- let input = serde_json::json!({
6542
- "command": "curl -sL https://example.com",
6543
- "city": null,
6544
- "columns": null,
6545
- "verification": null,
6546
- "filter": null
6547
- });
6548
- let parsed = decode_agent_input(input).unwrap();
6549
- assert_eq!(parsed.command, "curl -sL https://example.com");
6550
- assert_eq!(parsed.city, "");
6551
- assert_eq!(parsed.verification, "");
6552
- }
6553
-
6554
- #[test]
6555
- fn test_unpack_nested_arguments() {
6556
- // Stringified JSON arguments
6557
- let str_args = serde_json::json!({
6558
- "action": "run_shell",
6559
- "arguments": "{\"command\": \"ls -la\", \"city\": null}"
6560
- });
6561
- let parsed_str = decode_agent_input(str_args).unwrap();
6562
- assert_eq!(parsed_str.command, "ls -la");
6563
-
6564
- // Object arguments
6565
- let obj_args = serde_json::json!({
6566
- "action": "run_shell",
6567
- "arguments": { "command": "pwd" }
6568
- });
6569
- let parsed_obj = decode_agent_input(obj_args).unwrap();
6570
- assert_eq!(parsed_obj.command, "pwd");
6571
- }
6572
-
6573
- #[test]
6574
- fn test_shell_command_aliases() {
6575
- for (alias, expected) in [
6576
- ("cmd", "echo 1"),
6577
- ("script", "echo 2"),
6578
- ("exec", "echo 3"),
6579
- ("code", "echo 4"),
6580
- ("bash", "echo 5"),
6581
- ("sh", "echo 6"),
6582
- ] {
6583
- let input = serde_json::json!({ alias: expected });
6584
- let parsed = decode_agent_input(input).unwrap();
6585
- assert_eq!(parsed.command, expected, "Failed for alias {alias}");
6586
- }
6587
- }
6588
-
6589
- #[test]
6590
- fn test_parse_tool_input_raw_shell_string() {
6591
- let raw = Value::String("curl -sL --max-time 25 https://sndiary02.com".into());
6592
- let parsed = parse_tool_input("run_shell", raw).unwrap();
6593
- assert_eq!(
6594
- parsed.command,
6595
- "curl -sL --max-time 25 https://sndiary02.com"
6596
- );
6597
- }
6598
-
6599
- #[test]
6600
- fn test_parse_tool_input_prunes_dummy_null_strings() {
6601
- let raw = serde_json::json!({
6602
- "command": "curl -sL https://example.com",
6603
- "path": "null",
6604
- "query": "null",
6605
- "name": "null",
6606
- "title": "undefined",
6607
- "url": "none"
6608
- });
6609
- let parsed = parse_tool_input("run_shell", raw).unwrap();
6610
- assert_eq!(parsed.command, "curl -sL https://example.com");
6611
- assert_eq!(parsed.path, "");
6612
- assert_eq!(parsed.query, "");
6613
- assert_eq!(parsed.name, "");
6614
- assert_eq!(parsed.title, "");
6615
- assert_eq!(parsed.url, "");
6616
- }
6617
-
6618
- #[test]
6619
- fn test_is_dummy_value() {
6620
- assert!(is_dummy_value("null"));
6621
- assert!(is_dummy_value("NULL"));
6622
- assert!(is_dummy_value(" null "));
6623
- assert!(is_dummy_value("undefined"));
6624
- assert!(is_dummy_value("none"));
6625
- assert!(is_dummy_value("nil"));
6626
- assert!(is_dummy_value("(null)"));
6627
- assert!(is_dummy_value("\"\""));
6628
- assert!(is_dummy_value("''"));
6629
-
6630
- assert!(!is_dummy_value("echo null"));
6631
- assert!(!is_dummy_value("cat /dev/null"));
6632
- assert!(!is_dummy_value("src/main.rs"));
6633
- assert!(!is_dummy_value("pwd"));
6634
- }
6635
- }
2706
+ mod mcp_outcome_tests;