@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
@@ -0,0 +1,1665 @@
1
+ use super::*;
2
+ use crate::config::AgentConfig;
3
+
4
+ #[test]
5
+ fn stringified_patch_uses_explicit_outer_target() {
6
+ let patch = serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string();
7
+ let input = parse_tool_input(
8
+ "apply_patch",
9
+ serde_json::json!({"path": "style.css", "patch": patch}),
10
+ )
11
+ .unwrap();
12
+ let patch = input.patch.unwrap();
13
+ assert_eq!(patch.path, PathBuf::from("style.css"));
14
+ assert_eq!(patch.hunks.len(), 1);
15
+ }
16
+
17
+ #[tokio::test]
18
+ async fn stringified_patch_executes_only_after_approval_and_can_be_undone() {
19
+ let root = std::env::temp_dir().join(format!("mint-patch-repro-{}", uuid::Uuid::new_v4()));
20
+ std::fs::create_dir_all(&root).unwrap();
21
+ std::fs::write(root.join("style.css"), "body { color: black; }").unwrap();
22
+ let config = MintConfig {
23
+ allowed_read_paths: vec![root.clone()],
24
+ allowed_write_paths: vec![root.clone()],
25
+ blocked_paths: vec![],
26
+ blocked_file_names: vec![],
27
+ ..MintConfig::default()
28
+ };
29
+ let input = serde_json::json!({"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "black", "newText": "orange"}]}).to_string()});
30
+ execute_tool_from_json(
31
+ &root,
32
+ &config,
33
+ "apply_patch",
34
+ input.clone(),
35
+ "patch-repro",
36
+ &mut |_| Ok(ApprovalOutcome::Denied),
37
+ )
38
+ .await
39
+ .unwrap();
40
+ assert_eq!(
41
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
42
+ "body { color: black; }"
43
+ );
44
+ execute_tool_from_json(
45
+ &root,
46
+ &config,
47
+ "apply_patch",
48
+ input,
49
+ "patch-repro",
50
+ &mut |_| Ok(ApprovalOutcome::Approved),
51
+ )
52
+ .await
53
+ .unwrap();
54
+ assert_eq!(
55
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
56
+ "body { color: orange; }"
57
+ );
58
+ crate::system::workspace_history::undo(&root, None).unwrap();
59
+ assert_eq!(
60
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
61
+ "body { color: black; }"
62
+ );
63
+ std::fs::remove_dir_all(root).unwrap();
64
+ }
65
+
66
+ #[test]
67
+ fn patch_decoding_preserves_strict_types_and_rejects_ambiguous_targets() {
68
+ let patch =
69
+ serde_json::json!({"path": "style.css", "hunks": [{"oldText": "old", "newText": "new"}]});
70
+ assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": patch})).is_ok());
71
+ assert!(
72
+ parse_tool_input(
73
+ "apply_patch",
74
+ serde_json::json!({"path": "other.css", "patch": patch.to_string()})
75
+ )
76
+ .is_err()
77
+ );
78
+ assert!(
79
+ parse_tool_input(
80
+ "apply_patch",
81
+ serde_json::json!({"patch": "{\"hunks\": []}"})
82
+ )
83
+ .is_err()
84
+ );
85
+ assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": "not JSON"})).is_err());
86
+ assert!(parse_tool_input("list_files", serde_json::json!({"limit": "100"})).is_err());
87
+ let huge = "private-code-marker".repeat(1000);
88
+ let error = parse_tool_input(
89
+ "apply_patch",
90
+ serde_json::json!({"patch": {"path": "style.css", "hunks": huge}}),
91
+ )
92
+ .unwrap_err()
93
+ .to_string();
94
+ assert!(error.len() < 600);
95
+ assert!(!error.contains("private-code-marker"));
96
+ 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();
97
+ assert_eq!(
98
+ decision.input.patch.unwrap().path,
99
+ PathBuf::from("style.css")
100
+ );
101
+ }
102
+
103
+ fn test_list_call(id: &str, input: Value) -> crate::chat::ToolCall {
104
+ crate::chat::ToolCall {
105
+ id: id.into(),
106
+ name: "list_files".into(),
107
+ input,
108
+ thought_signature: Some("signature".into()),
109
+ }
110
+ }
111
+
112
+ #[tokio::test]
113
+ async fn native_argument_error_is_returned_and_corrected_call_can_execute() {
114
+ let raw = serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": "100"});
115
+ let mut count = ToolArgumentRetries::default();
116
+ let (decisions, errors) =
117
+ prepare_native_tool_calls(vec![test_list_call("bad", raw.clone())], "", &mut count)
118
+ .unwrap();
119
+ assert!(decisions.is_empty());
120
+ assert_eq!(errors.len(), 1);
121
+ let mut messages = Vec::new();
122
+ let signatures = std::collections::HashMap::from([("bad".into(), "signature".into())]);
123
+ append_native_tool_results(&mut messages, "", &errors, &signatures, &Default::default());
124
+ assert!(
125
+ matches!(&messages[0].content[0], ContentBlock::ToolUse { id, input, thought_signature, .. }
126
+ if id == "bad" && input == &raw && thought_signature.as_deref() == Some("signature"))
127
+ );
128
+ assert!(
129
+ matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, content, is_error }
130
+ if tool_use_id == "bad" && *is_error && content.contains("Correct the arguments"))
131
+ );
132
+
133
+ let root = std::env::temp_dir().join(format!("mint-tool-recovery-{}", uuid::Uuid::new_v4()));
134
+ let nested = root.join("Downloads/YOUTUBE_DL/playlist");
135
+ std::fs::create_dir_all(&nested).unwrap();
136
+ std::fs::write(nested.join("song.txt"), "song").unwrap();
137
+ let config = MintConfig {
138
+ allowed_read_paths: vec![root.clone()],
139
+ blocked_paths: vec![],
140
+ blocked_file_names: vec![],
141
+ ..MintConfig::default()
142
+ };
143
+ let (decisions, errors) = prepare_native_tool_calls(
144
+ vec![test_list_call(
145
+ "fixed",
146
+ serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
147
+ )],
148
+ "",
149
+ &mut count,
150
+ )
151
+ .unwrap();
152
+ assert!(errors.is_empty());
153
+ let output = execute_tool(
154
+ &root,
155
+ &config,
156
+ &decisions[0].1,
157
+ "regression",
158
+ &mut |_| panic!("listing must not request approval"),
159
+ &mut |_| {},
160
+ )
161
+ .await
162
+ .unwrap();
163
+ let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
164
+ assert_eq!(entries.len(), 1);
165
+ assert_eq!(
166
+ entries[0]["path"],
167
+ serde_json::json!(nested.join("song.txt"))
168
+ );
169
+ // Parsing the repaired call alone must not renew its budget.
170
+ assert_eq!(count.failures["list_files"], 1);
171
+ count.observe_completed(&[("fixed".into(), "list_files".into(), Value::Null, output).into()]);
172
+ assert!(!count.failures.contains_key("list_files"));
173
+ std::fs::remove_dir_all(root).unwrap();
174
+ }
175
+
176
+ #[test]
177
+ fn native_invalid_calls_are_bounded_without_blocking_valid_alternatives() {
178
+ let mut count = ToolArgumentRetries::default();
179
+ for _ in 0..MAX_TOOL_ARGUMENT_RETRIES {
180
+ let (decisions, errors) = prepare_native_tool_calls(
181
+ vec![test_list_call(
182
+ "bad",
183
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
184
+ )],
185
+ "",
186
+ &mut count,
187
+ )
188
+ .unwrap();
189
+ assert!(decisions.is_empty());
190
+ assert_eq!(errors.len(), 1);
191
+ }
192
+ let alternative = crate::chat::ToolCall {
193
+ id: "alternative".into(),
194
+ name: "run_shell".into(),
195
+ input: serde_json::json!({"command": "ls ~/Downloads/YOUTUBE_DL"}),
196
+ thought_signature: None,
197
+ };
198
+ let (decisions, errors) = prepare_native_tool_calls(vec![alternative], "", &mut count).unwrap();
199
+ assert_eq!(decisions[0].1.action, "run_shell");
200
+ assert!(errors.is_empty());
201
+ assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
202
+ let error = prepare_native_tool_calls(vec![test_list_call("bad", Value::Null)], "", &mut count)
203
+ .unwrap_err();
204
+ assert!(
205
+ error
206
+ .to_string()
207
+ .contains("exhausted its 5 argument-correction retries")
208
+ );
209
+ // One exhausted tool must not consume another tool's corrections.
210
+ let other = crate::chat::ToolCall {
211
+ id: "other-bad".into(),
212
+ name: "read_file".into(),
213
+ input: serde_json::json!({"path": 123}),
214
+ thought_signature: None,
215
+ };
216
+ let (_, errors) = prepare_native_tool_calls(vec![other], "", &mut count).unwrap();
217
+ assert_eq!(errors.len(), 1);
218
+ assert_eq!(count.failures["read_file"], 1);
219
+ for result in [
220
+ "Error: execution failed",
221
+ "Blocked by hook",
222
+ "Skipped duplicate",
223
+ ] {
224
+ count.observe_completed(&[
225
+ ("x".into(), "list_files".into(), Value::Null, result.into()).into(),
226
+ ]);
227
+ assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
228
+ }
229
+ count.succeeded("list_files");
230
+ assert_eq!(count.failures["read_file"], 1);
231
+ assert!(!count.failures.contains_key("list_files"));
232
+ assert!(
233
+ prepare_native_tool_calls(vec![test_list_call("fresh", Value::Null)], "", &mut count)
234
+ .is_ok()
235
+ );
236
+ }
237
+
238
+ #[test]
239
+ fn native_mixed_batch_keeps_valid_calls_and_matches_each_error_to_its_id() {
240
+ let mut count = ToolArgumentRetries::default();
241
+ let (decisions, errors) = prepare_native_tool_calls(
242
+ vec![
243
+ test_list_call("bad", Value::Null),
244
+ test_list_call("valid-1", serde_json::json!({"path": "."})),
245
+ test_list_call("valid-2", serde_json::json!({"path": "src"})),
246
+ ],
247
+ "",
248
+ &mut count,
249
+ )
250
+ .unwrap();
251
+ assert_eq!(decisions.len(), 2);
252
+ assert!(decisions_are_parallel_read_only_batch(&decisions));
253
+ assert_eq!(errors[0].call_id, "bad");
254
+ let mut results = errors;
255
+ results.extend(decisions.iter().map(|(id, d)| {
256
+ (
257
+ id.clone(),
258
+ d.action.clone(),
259
+ serde_json::to_value(&d.input).unwrap(),
260
+ "[]".into(),
261
+ )
262
+ .into()
263
+ }));
264
+ let mut messages = Vec::new();
265
+ append_native_tool_results(
266
+ &mut messages,
267
+ "",
268
+ &results,
269
+ &Default::default(),
270
+ &Default::default(),
271
+ );
272
+ assert_eq!(messages[0].content.len(), 3);
273
+ assert_eq!(messages[1].content.len(), 3);
274
+ assert!(
275
+ matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "bad")
276
+ );
277
+ assert!(
278
+ matches!(&messages[1].content[1], ContentBlock::ToolResult { tool_use_id, is_error: false, .. } if tool_use_id == "valid-1")
279
+ );
280
+ }
281
+
282
+ #[tokio::test]
283
+ async fn list_files_rejects_invalid_arguments_instead_of_listing_workspace() {
284
+ let config = MintConfig::default();
285
+ // A nonexistent root ensures no filesystem lookup occurs for bad input.
286
+ let root = Path::new("/nonexistent-mint-list-files-regression");
287
+ for input in [
288
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
289
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": -1}),
290
+ serde_json::json!({"path": null}),
291
+ Value::String(r#"{"path":"~/Downloads/YOUTUBE_DL""#.into()),
292
+ Value::Null,
293
+ ] {
294
+ let error = execute_tool_from_json(
295
+ root,
296
+ &config,
297
+ "list_files",
298
+ input,
299
+ "regression",
300
+ &mut |_| panic!("invalid arguments must not request approval"),
301
+ )
302
+ .await
303
+ .unwrap_err();
304
+ assert!(
305
+ error
306
+ .to_string()
307
+ .contains("invalid arguments for tool 'list_files'")
308
+ );
309
+ }
310
+ }
311
+
312
+ #[tokio::test]
313
+ async fn list_files_preserves_nested_paths_and_default_directory() {
314
+ let root = std::env::temp_dir().join(format!("mint-list-files-paths-{}", std::process::id()));
315
+ let nested = root.join("Downloads/YOUTUBE_DL/playlist");
316
+ std::fs::create_dir_all(&nested).unwrap();
317
+ std::fs::write(nested.join("song.txt"), "song").unwrap();
318
+ let config = MintConfig {
319
+ allowed_read_paths: vec![root.clone()],
320
+ blocked_paths: vec![],
321
+ blocked_file_names: vec![],
322
+ ..MintConfig::default()
323
+ };
324
+ for input in [
325
+ serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
326
+ serde_json::json!({"path": nested}),
327
+ serde_json::json!({}),
328
+ ] {
329
+ let expected = if input.get("path").is_some() {
330
+ &nested
331
+ } else {
332
+ &root
333
+ };
334
+ let output = execute_tool_from_json(
335
+ &root,
336
+ &config,
337
+ "list_files",
338
+ input,
339
+ "regression",
340
+ &mut |_| panic!("listing must not request approval"),
341
+ )
342
+ .await
343
+ .unwrap();
344
+ let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
345
+ assert_eq!(entries.len(), 1);
346
+ assert_eq!(
347
+ Path::new(entries[0]["path"].as_str().unwrap()).parent(),
348
+ Some(expected.as_path())
349
+ );
350
+ }
351
+ std::fs::remove_dir_all(root).unwrap();
352
+ }
353
+
354
+ #[tokio::test]
355
+ async fn turn_start_listener_reports_only_the_matching_chat() {
356
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
357
+ "mint-turn-start-listener-{}.sqlite",
358
+ uuid::Uuid::new_v4()
359
+ )));
360
+ let reported = Arc::new(std::sync::Mutex::new(Vec::new()));
361
+ let callback_reported = reported.clone();
362
+ with_turn_start_listener(
363
+ "cli::one".to_string(),
364
+ move |id| callback_reported.lock().unwrap().push(id),
365
+ async {
366
+ let first = TurnLease::start(&memory, "cli::one", "hello")
367
+ .await
368
+ .unwrap();
369
+ let other = TurnLease::start(&memory, "cli::two", "other")
370
+ .await
371
+ .unwrap();
372
+ assert_eq!(*reported.lock().unwrap(), vec![first.id]);
373
+ drop(first);
374
+ drop(other);
375
+ },
376
+ )
377
+ .await;
378
+ }
379
+
380
+ #[test]
381
+ fn test_compact_agent_progress_cleans_deltas_and_preserves_tools() {
382
+ let events = vec![
383
+ AgentProgress::ToolStart {
384
+ call_id: None,
385
+ action: "read_file".into(),
386
+ input: serde_json::json!({ "path": "src/main.rs" }),
387
+ subagent: None,
388
+ },
389
+ AgentProgress::ThinkingDelta {
390
+ id: "cot-1".into(),
391
+ delta: "Reading ".into(),
392
+ elapsed_ms: 100,
393
+ },
394
+ AgentProgress::ThinkingDelta {
395
+ id: "cot-1".into(),
396
+ delta: "the file...".into(),
397
+ elapsed_ms: 200,
398
+ },
399
+ AgentProgress::ExtendedThinking {
400
+ id: Some("cot-1".into()),
401
+ thought: "Reading the file...".into(),
402
+ elapsed_ms: Some(200),
403
+ },
404
+ AgentProgress::ToolEnd {
405
+ elapsed_ms: None,
406
+ call_id: None,
407
+ status: None,
408
+ action: "read_file".into(),
409
+ input: serde_json::json!({ "path": "src/main.rs" }),
410
+ result: "fn main() {}".into(),
411
+ subagent: None,
412
+ },
413
+ AgentProgress::ThinkingDelta {
414
+ id: "cot-interrupted".into(),
415
+ delta: "Let's check ".into(),
416
+ elapsed_ms: 300,
417
+ },
418
+ AgentProgress::ThinkingDelta {
419
+ id: "cot-interrupted".into(),
420
+ delta: "more lines".into(),
421
+ elapsed_ms: 350,
422
+ },
423
+ ];
424
+
425
+ let compacted = compact_agent_progress(&events);
426
+ assert!(
427
+ !compacted
428
+ .iter()
429
+ .any(|e| matches!(e, AgentProgress::ThinkingDelta { id, .. } if id == "cot-1"))
430
+ );
431
+ assert!(
432
+ compacted
433
+ .iter()
434
+ .any(|e| matches!(e, AgentProgress::ToolStart { action, .. } if action == "read_file"))
435
+ );
436
+ assert!(
437
+ compacted
438
+ .iter()
439
+ .any(|e| matches!(e, AgentProgress::ToolEnd { action, .. } if action == "read_file"))
440
+ );
441
+ assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-1" && thought == "Reading the file...")));
442
+ assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-interrupted" && thought == "Let's check more lines")));
443
+ }
444
+
445
+ #[tokio::test]
446
+ async fn test_turn_lease_persists_agent_activity_on_complete() {
447
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
448
+ "mint-turn-lease-complete-{}.sqlite",
449
+ uuid::Uuid::new_v4()
450
+ )));
451
+ let mut turn = TurnLease::start(&memory, "cli::test_complete", "hello")
452
+ .await
453
+ .unwrap();
454
+ let turn_id = turn.id;
455
+ turn.activity
456
+ .lock()
457
+ .unwrap()
458
+ .push(AgentProgress::ToolStart {
459
+ call_id: None,
460
+ action: "run_shell".into(),
461
+ input: serde_json::json!({ "command": "ls -la" }),
462
+ subagent: None,
463
+ });
464
+ turn.activity.lock().unwrap().push(AgentProgress::ToolEnd {
465
+ elapsed_ms: None,
466
+ call_id: None,
467
+ status: None,
468
+ action: "run_shell".into(),
469
+ input: serde_json::json!({ "command": "ls -la" }),
470
+ result: "file.txt".into(),
471
+ subagent: None,
472
+ });
473
+
474
+ turn.complete_with_summary("Done listing files", "test_provider", "test_model", None)
475
+ .unwrap();
476
+
477
+ let rows = memory.interactions_for_chat("cli::test_complete").unwrap();
478
+ assert_eq!(rows.len(), 1);
479
+ let row = &rows[0];
480
+ assert_eq!(row.id, turn_id);
481
+ assert_eq!(row.status, "completed");
482
+ assert_eq!(row.ai_text, "Done listing files");
483
+ assert!(row.agent_activity.is_some());
484
+ let activity_val = row.agent_activity.as_ref().unwrap();
485
+ let activity_arr = activity_val.as_array().unwrap();
486
+ assert_eq!(activity_arr.len(), 2);
487
+ assert_eq!(activity_arr[0]["type"], "ToolStart");
488
+ assert_eq!(activity_arr[1]["type"], "ToolEnd");
489
+ }
490
+
491
+ #[tokio::test]
492
+ async fn test_turn_lease_persists_partial_activity_on_interrupt() {
493
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
494
+ "mint-turn-lease-interrupt-{}.sqlite",
495
+ uuid::Uuid::new_v4()
496
+ )));
497
+ let turn = TurnLease::start(&memory, "cli::test_interrupt", "run something")
498
+ .await
499
+ .unwrap();
500
+ let turn_id = turn.id;
501
+ turn.activity
502
+ .lock()
503
+ .unwrap()
504
+ .push(AgentProgress::ToolStart {
505
+ call_id: None,
506
+ action: "read_file".into(),
507
+ input: serde_json::json!({ "path": "test.txt" }),
508
+ subagent: None,
509
+ });
510
+ drop(turn);
511
+
512
+ let rows = memory.interactions_for_chat("cli::test_interrupt").unwrap();
513
+ assert_eq!(rows.len(), 1);
514
+ let row = &rows[0];
515
+ assert_eq!(row.id, turn_id);
516
+ assert_eq!(row.status, "interrupted");
517
+ assert!(row.agent_activity.is_some());
518
+ let activity_val = row.agent_activity.as_ref().unwrap();
519
+ let activity_arr = activity_val.as_array().unwrap();
520
+ assert_eq!(activity_arr.len(), 1);
521
+ assert_eq!(activity_arr[0]["type"], "ToolStart");
522
+ }
523
+
524
+ #[test]
525
+ fn extracts_a_summary_incrementally_without_matching_text_inside_other_strings() {
526
+ let mut stream = FinishSummaryStream::default();
527
+ let mut output = String::new();
528
+ let mut emit = |delta: String| output.push_str(&delta);
529
+
530
+ stream.push_tool_call(0, Some("read_file"), None, None, &mut emit);
531
+ stream.push_tool_call(
532
+ 0,
533
+ None,
534
+ Some(r#"{"thought":"the \"summary\" key is data","path":"x"}"#),
535
+ None,
536
+ &mut emit,
537
+ );
538
+ assert!(stream.emitted.is_empty());
539
+
540
+ stream.push_tool_call(1, Some("fin"), None, None, &mut emit);
541
+ stream.push_tool_call(1, Some("finish"), None, None, &mut emit);
542
+ stream.push_tool_call(1, None, Some(r#"{"summary":"สวัส"#), None, &mut emit);
543
+ stream.push_tool_call(
544
+ 1,
545
+ None,
546
+ Some(r#"ดี\nโลก","verification":""}"#),
547
+ None,
548
+ &mut emit,
549
+ );
550
+
551
+ assert_eq!(output, "สวัสดี\nโลก");
552
+ }
553
+
554
+ #[test]
555
+ fn extracts_json_prompt_summary_as_chunks_arrive() {
556
+ let mut stream = FinishSummaryStream::default();
557
+ let mut output = String::new();
558
+ let mut emit = |delta: String| output.push_str(&delta);
559
+ stream.push_json_prompt(
560
+ r#"{"thought":"done","action":"finish","input":{"summary":"Hello"#,
561
+ &mut emit,
562
+ );
563
+ assert_eq!(stream.emitted, "Hello");
564
+ stream.push_json_prompt(r#" world"}}"#, &mut emit);
565
+ assert_eq!(output, "Hello world");
566
+ }
567
+
568
+ #[test]
569
+ fn direct_native_text_is_not_sent_again_as_a_completed_summary() {
570
+ assert_eq!(
571
+ unstreamed_summary_remainder("A streamed answer.", "", "A streamed answer."),
572
+ ""
573
+ );
574
+ }
575
+
576
+ #[test]
577
+ fn direct_native_text_appends_only_the_unstreamed_suffix() {
578
+ assert_eq!(
579
+ unstreamed_summary_remainder("A streamed answer, continued.", "", "A streamed answer"),
580
+ ", continued."
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn streamed_finish_summary_takes_precedence_over_direct_text() {
586
+ assert_eq!(
587
+ unstreamed_summary_remainder("Final answer.", "Final", "unrelated preamble"),
588
+ " answer."
589
+ );
590
+ }
591
+
592
+ #[test]
593
+ fn streams_native_direct_text_incrementally_once_past_single_line_preamble() {
594
+ let mut stream = FinishSummaryStream::default();
595
+ let mut chunks = Vec::new();
596
+
597
+ // First short line is held briefly in case a tool call follows.
598
+ stream.push_native_text("Here is the breakdown:\n", true, &mut |delta| {
599
+ chunks.push(delta)
600
+ });
601
+ assert!(chunks.is_empty());
602
+
603
+ // As soon as the second line begins with no tool call, the buffered prefix
604
+ // and subsequent deltas stream live in real time.
605
+ stream.push_native_text("- Point 1\n", true, &mut |delta| chunks.push(delta));
606
+ assert_eq!(chunks.concat(), "Here is the breakdown:\n- Point 1\n");
607
+
608
+ stream.push_native_text("- Point 2", true, &mut |delta| chunks.push(delta));
609
+ assert_eq!(
610
+ chunks.concat(),
611
+ "Here is the breakdown:\n- Point 1\n- Point 2"
612
+ );
613
+
614
+ // Completing the turn must not duplicate already-streamed direct text.
615
+ stream.finish_native_text(
616
+ "Here is the breakdown:\n- Point 1\n- Point 2",
617
+ &mut |delta| chunks.push(delta),
618
+ );
619
+ assert_eq!(
620
+ chunks.concat(),
621
+ "Here is the breakdown:\n- Point 1\n- Point 2"
622
+ );
623
+ }
624
+
625
+ #[test]
626
+ fn suppresses_native_single_line_preamble_when_tool_call_follows() {
627
+ let mut stream = FinishSummaryStream::default();
628
+ let mut direct_output = String::new();
629
+ let mut summary_output = String::new();
630
+
631
+ stream.push_native_text(
632
+ "ขออ่านไฟล์ config ก่อนนะครับ",
633
+ true,
634
+ &mut |delta| direct_output.push_str(&delta),
635
+ );
636
+ stream.push_tool_call(
637
+ 0,
638
+ Some("read_file"),
639
+ Some(r#"{"path":"Cargo.toml"}"#),
640
+ None,
641
+ &mut |delta| summary_output.push_str(&delta),
642
+ );
643
+ stream.push_native_text("\nAdditional trailing text", true, &mut |delta| {
644
+ direct_output.push_str(&delta)
645
+ });
646
+
647
+ assert!(direct_output.is_empty());
648
+ assert!(summary_output.is_empty());
649
+ }
650
+
651
+ #[test]
652
+ fn flushes_short_native_direct_reply_on_completion_when_no_tools_called() {
653
+ let mut stream = FinishSummaryStream::default();
654
+ let mut output = String::new();
655
+
656
+ stream.push_native_text(
657
+ "สวัสดีครับ! มีอะไรให้ช่วยไหมครับ",
658
+ true,
659
+ &mut |delta| output.push_str(&delta),
660
+ );
661
+ assert!(output.is_empty());
662
+
663
+ stream.finish_native_text(
664
+ "สวัสดีครับ! มีอะไรให้ช่วยไหมครับ",
665
+ &mut |delta| output.push_str(&delta),
666
+ );
667
+ assert_eq!(output, "สวัสดีครับ! มีอะไรให้ช่วยไหมครับ");
668
+ }
669
+
670
+ #[test]
671
+ fn streams_native_text_immediately_when_tools_are_disabled() {
672
+ let mut stream = FinishSummaryStream::default();
673
+ let mut chunks = Vec::new();
674
+
675
+ stream.push_native_text("สวัสดี", false, &mut |delta| chunks.push(delta));
676
+ stream.push_native_text("ครับ", false, &mut |delta| chunks.push(delta));
677
+ assert_eq!(chunks, vec!["สวัสดี", "ครับ"]);
678
+
679
+ stream.finish_native_text("สวัสดีครับ", &mut |delta| {
680
+ chunks.push(delta)
681
+ });
682
+ assert_eq!(chunks.concat(), "สวัสดีครับ");
683
+ }
684
+
685
+ #[test]
686
+ fn native_text_with_leading_newlines_after_think_tag_aligns_with_trimmed_response_text() {
687
+ let mut stream = FinishSummaryStream::default();
688
+ let mut chunks = Vec::new();
689
+
690
+ // After </think>, StreamReplyAccumulator often emits "\n\nLine 1\nLine 2" while
691
+ // extract_think_tag trims response.text to "Line 1\nLine 2\nLine 3".
692
+ stream.push_native_text("\n\nLine 1\nLine 2", true, &mut |delta| chunks.push(delta));
693
+ assert_eq!(chunks, vec!["Line 1\nLine 2"]);
694
+
695
+ stream.finish_native_text("Line 1\nLine 2\nLine 3", &mut |delta| chunks.push(delta));
696
+ assert_eq!(chunks.concat(), "Line 1\nLine 2\nLine 3");
697
+ }
698
+
699
+ #[test]
700
+ fn compact_agent_progress_keeps_only_the_latest_plan_updated_event() {
701
+ let events = vec![
702
+ AgentProgress::PlanUpdated {
703
+ plan: ActivePlan {
704
+ objective: "Ship feature".into(),
705
+ tasks: vec![PlanTaskItem {
706
+ id: "step-1".into(),
707
+ title: "Write failing test".into(),
708
+ status: "in_progress".into(),
709
+ }],
710
+ },
711
+ },
712
+ AgentProgress::ToolStart {
713
+ call_id: Some("call_1".into()),
714
+ action: "read_file".into(),
715
+ input: serde_json::json!({"path": "src/lib.rs"}),
716
+ subagent: None,
717
+ },
718
+ AgentProgress::PlanUpdated {
719
+ plan: ActivePlan {
720
+ objective: "Ship feature".into(),
721
+ tasks: vec![PlanTaskItem {
722
+ id: "step-1".into(),
723
+ title: "Write failing test".into(),
724
+ status: "completed".into(),
725
+ }],
726
+ },
727
+ },
728
+ ];
729
+
730
+ let compacted = compact_agent_progress(&events);
731
+ let plans: Vec<_> = compacted
732
+ .iter()
733
+ .filter_map(|e| match e {
734
+ AgentProgress::PlanUpdated { plan } => Some(plan),
735
+ _ => None,
736
+ })
737
+ .collect();
738
+ assert_eq!(plans.len(), 1);
739
+ assert_eq!(plans[0].tasks[0].status, "completed");
740
+ }
741
+
742
+ #[test]
743
+ fn truncate_for_context_leaves_short_text_untouched() {
744
+ assert_eq!(truncate_for_context("hello", 400), "hello");
745
+ }
746
+
747
+ #[test]
748
+ fn truncate_for_context_truncates_long_text_with_a_marker() {
749
+ let long = "a".repeat(500);
750
+ let result = truncate_for_context(&long, 400);
751
+ assert_eq!(
752
+ result.chars().count(),
753
+ 400 + "... [truncated]".chars().count()
754
+ );
755
+ assert!(result.starts_with(&"a".repeat(400)));
756
+ assert!(result.ends_with("... [truncated]"));
757
+ }
758
+
759
+ #[test]
760
+ fn truncate_for_context_never_splits_a_multibyte_char() {
761
+ // Thai text is multi-byte UTF-8; a byte-index slice here would panic
762
+ // or produce invalid UTF-8 if it landed mid-character.
763
+ let thai = "สวัสดี".repeat(200);
764
+ let result = truncate_for_context(&thai, 5);
765
+ assert_eq!(
766
+ result.chars().count(),
767
+ 5 + "... [truncated]".chars().count()
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn slugify_lowercases_and_collapses_separators() {
773
+ assert_eq!(
774
+ slugify("Retry Flaky Playwright Tests!!"),
775
+ "retry-flaky-playwright-tests"
776
+ );
777
+ assert_eq!(
778
+ slugify(" leading/trailing --dashes-- "),
779
+ "leading-trailing-dashes"
780
+ );
781
+ assert_eq!(slugify("already-a-slug"), "already-a-slug");
782
+ assert_eq!(slugify("***"), "");
783
+ }
784
+
785
+ #[test]
786
+ fn skill_revision_defaults_to_zero_without_a_revisions_line() {
787
+ assert_eq!(skill_revision("no frontmatter at all"), 0);
788
+ assert_eq!(skill_revision("---\ndescription: a skill\n---\nbody"), 0);
789
+ }
790
+
791
+ #[test]
792
+ fn skill_revision_reads_back_what_set_skill_revision_wrote() {
793
+ let original = "---\ndescription: a skill\n---\nstep-by-step body";
794
+ let bumped = set_skill_revision(original, 1);
795
+ assert_eq!(skill_revision(&bumped), 1);
796
+ assert!(
797
+ bumped.contains("description: a skill"),
798
+ "must preserve existing frontmatter fields: {bumped}"
799
+ );
800
+ assert!(
801
+ bumped.contains("step-by-step body"),
802
+ "must preserve the body: {bumped}"
803
+ );
804
+
805
+ // Refining again must replace the old count, not accumulate a
806
+ // second `revisions:` line alongside it.
807
+ let bumped_again = set_skill_revision(&bumped, 2);
808
+ assert_eq!(skill_revision(&bumped_again), 2);
809
+ assert_eq!(
810
+ bumped_again.matches("revisions:").count(),
811
+ 1,
812
+ "expected exactly one revisions line, got: {bumped_again}"
813
+ );
814
+ }
815
+
816
+ #[test]
817
+ fn set_skill_revision_adds_a_frontmatter_block_when_content_has_none() {
818
+ let result = set_skill_revision("just a body, no frontmatter", 1);
819
+ assert_eq!(skill_revision(&result), 1);
820
+ assert!(result.contains("just a body, no frontmatter"));
821
+ }
822
+
823
+ #[test]
824
+ fn existing_workspace_skill_bodies_reports_none_yet_for_a_fresh_workspace() {
825
+ let root = std::env::temp_dir().join("mint-memory-skill-test-no-skills-yet");
826
+ let _ = std::fs::remove_dir_all(&root);
827
+ assert_eq!(existing_workspace_skill_bodies(&root), "(none yet)");
828
+ }
829
+
830
+ #[test]
831
+ fn existing_workspace_skill_bodies_includes_full_content_by_slug() {
832
+ let root = std::env::temp_dir().join("mint-memory-skill-test-existing-bodies");
833
+ let _ = std::fs::remove_dir_all(&root);
834
+ let skill_dir = root
835
+ .join(".agents")
836
+ .join("skills")
837
+ .join("retry-flaky-tests");
838
+ std::fs::create_dir_all(&skill_dir).unwrap();
839
+ std::fs::write(
840
+ skill_dir.join("SKILL.md"),
841
+ "---\ndescription: retry flaky tests\n---\nrun with --retries=3",
842
+ )
843
+ .unwrap();
844
+
845
+ let bodies = existing_workspace_skill_bodies(&root);
846
+ assert!(bodies.contains("--- retry-flaky-tests ---"));
847
+ assert!(bodies.contains("run with --retries=3"));
848
+
849
+ let _ = std::fs::remove_dir_all(&root);
850
+ }
851
+
852
+ #[test]
853
+ fn skill_worthy_requires_enough_steps_and_substantive_work() {
854
+ let mut counts = BTreeMap::new();
855
+ counts.insert("read_file:foo.rs".to_string(), 1);
856
+ counts.insert("apply_patch:foo.rs".to_string(), 1);
857
+
858
+ // Too few steps, even with a substantive action.
859
+ assert!(!looks_skill_worthy(2, &counts));
860
+ // Enough steps, has a substantive action.
861
+ assert!(looks_skill_worthy(3, &counts));
862
+
863
+ let mut read_only = BTreeMap::new();
864
+ read_only.insert("read_file:foo.rs".to_string(), 5);
865
+ read_only.insert("web_search::rust patterns".to_string(), 2);
866
+ // Enough steps, but nothing substantive happened.
867
+ assert!(!looks_skill_worthy(5, &read_only));
868
+ }
869
+
870
+ #[test]
871
+ fn shell_result_failed_reads_the_exit_line() {
872
+ assert!(!shell_result_failed(
873
+ "exit: 0\nmode: normal\nsandboxed: true\nstdout:\nok\nstderr:\n"
874
+ ));
875
+ assert!(shell_result_failed(
876
+ "exit: 1\nmode: normal\nsandboxed: true\nstdout:\nfail\nstderr:\n"
877
+ ));
878
+ // A killed/unknown-status process isn't treated as a confirmed failure.
879
+ assert!(shell_result_failed(
880
+ "exit: unknown\nmode: normal\nsandboxed: true\nstdout:\nstderr:\n"
881
+ ));
882
+ }
883
+
884
+ #[test]
885
+ fn shell_result_failed_catches_a_later_command_failing_in_a_multi_command_verify() {
886
+ // A multi-command `verify` joins each command's own `run_shell` block with
887
+ // "\n\n" — the first command here passes, the second fails, and the scan
888
+ // must not stop after the first "exit: " line it finds.
889
+ let joined = "exit: 0\nmode: normal\nsandboxed: true\nstdout:\ncargo check ok\nstderr:\n\n\
890
+ exit: 1\nmode: normal\nsandboxed: true\nstdout:\n\nstderr:\n2 tests failed";
891
+ assert!(shell_result_failed(joined));
892
+ }
893
+
894
+ #[test]
895
+ fn unverified_modification_requires_a_verify_call_after_the_last_edit() {
896
+ // No edit at all this run — nothing to verify.
897
+ assert!(!unverified_modification(None, None, ""));
898
+ // Edited, never verified, no explanation given.
899
+ assert!(unverified_modification(Some(2), None, ""));
900
+ // Edited, verified *before* the edit (stale) — still unverified.
901
+ assert!(unverified_modification(Some(2), Some(1), ""));
902
+ // Edited, verified after — satisfied regardless of what verify found.
903
+ assert!(!unverified_modification(Some(2), Some(3), ""));
904
+ // An explanation cannot promote an unchecked edit to verified.
905
+ assert!(unverified_modification(
906
+ Some(2),
907
+ None,
908
+ "Docs-only change, no test suite."
909
+ ));
910
+ }
911
+
912
+ #[test]
913
+ fn unacknowledged_verify_failure_requires_the_agent_to_address_a_real_failure() {
914
+ // No verify ran yet, or it passed — nothing to block on.
915
+ assert!(!unacknowledged_verify_failure(None, ""));
916
+ assert!(!unacknowledged_verify_failure(Some(false), ""));
917
+ // Verify failed and the agent said nothing about it — this is the
918
+ // "claims success while a check is actually failing" case.
919
+ assert!(unacknowledged_verify_failure(Some(true), ""));
920
+ assert!(unacknowledged_verify_failure(Some(true), "n/a"));
921
+ // Even an explanation of pre-existing failures does not make them pass.
922
+ assert!(unacknowledged_verify_failure(
923
+ Some(true),
924
+ "2 pre-existing test failures unrelated to this change; see notes."
925
+ ));
926
+ }
927
+
928
+ #[test]
929
+ fn preserves_request_without_history() {
930
+ let store = MemoryStore::open(
931
+ std::env::temp_dir().join(format!("mint-orchestrator-{}.sqlite", std::process::id())),
932
+ );
933
+ let request = ChatRequest {
934
+ message: "hello".into(),
935
+ system_instruction: "system".into(),
936
+ chat_id: None,
937
+ image_data_uri: None,
938
+ audio_data_uri: None,
939
+ video_data_uri: None,
940
+ document_attachment: None,
941
+ workspace_path: None,
942
+ agent_id: None,
943
+ plan_mode: false,
944
+ pinned_mcp_server: None,
945
+ messages: None,
946
+ tools: None,
947
+ temperature: None,
948
+ ..Default::default()
949
+ };
950
+ let config = MintConfig::default();
951
+ assert!(
952
+ enrich_request(&config, &store, &request)
953
+ .unwrap()
954
+ .system_instruction
955
+ .starts_with("system")
956
+ );
957
+ }
958
+
959
+ fn insert_completed_turn(
960
+ store: &MemoryStore,
961
+ chat_id: &str,
962
+ user_text: &str,
963
+ ai_text: &str,
964
+ ) -> i64 {
965
+ let turn = store.start_turn(chat_id, user_text).unwrap();
966
+ store.claim_turn(chat_id, turn).unwrap();
967
+ store
968
+ .finish_turn(turn, ai_text, "test-provider", "test-model", None)
969
+ .unwrap();
970
+ turn
971
+ }
972
+
973
+ #[test]
974
+ fn build_conversation_history_messages_empty_on_fresh_session() {
975
+ let db_path =
976
+ std::env::temp_dir().join(format!("mint-test-fresh-{}.sqlite", uuid::Uuid::new_v4()));
977
+ let store = MemoryStore::open(&db_path);
978
+ let messages = build_conversation_history_messages(&store, "fresh-session-123", 15, 4000);
979
+ assert!(messages.is_empty());
980
+ let _ = std::fs::remove_file(db_path);
981
+ }
982
+
983
+ #[test]
984
+ fn build_conversation_history_messages_preserves_order_and_roles() {
985
+ let db_path =
986
+ std::env::temp_dir().join(format!("mint-test-order-{}.sqlite", uuid::Uuid::new_v4()));
987
+ let store = MemoryStore::open(&db_path);
988
+ let chat_id = "test-chat-multi-turn";
989
+
990
+ // Insert Turn 1
991
+ insert_completed_turn(
992
+ &store,
993
+ chat_id,
994
+ "ถามชื่อหน่อยดิ แล้วก็ถามผลไม้ที่ชอบหน่อย",
995
+ "ได้เลยครับ! คุณชื่ออะไร และชอบทานผลไม้อะไรเป็นพิเศษครับ?",
996
+ );
997
+
998
+ // Insert Turn 2
999
+ insert_completed_turn(
1000
+ &store,
1001
+ chat_id,
1002
+ "ฉันชื่อภีม ชอบกินมะม่วง",
1003
+ "สวัสดีครับคุณภีม ยินดีที่ได้รู้จักครับ! มะม่วงหวานอร่อยมากครับ",
1004
+ );
1005
+
1006
+ let messages = build_conversation_history_messages(&store, chat_id, 15, 4000);
1007
+ assert_eq!(messages.len(), 4);
1008
+
1009
+ // Verify chronological order and role alternation
1010
+ assert_eq!(messages[0].role, ChatRole::User);
1011
+ assert_eq!(
1012
+ messages[0].content,
1013
+ vec![ContentBlock::Text {
1014
+ text: "ถามชื่อหน่อยดิ แล้วก็ถามผลไม้ที่ชอบหน่อย".to_string()
1015
+ }]
1016
+ );
1017
+
1018
+ assert_eq!(messages[1].role, ChatRole::Assistant);
1019
+ assert_eq!(
1020
+ messages[1].content,
1021
+ vec![ContentBlock::Text {
1022
+ text: "ได้เลยครับ! คุณชื่ออะไร และชอบทานผลไม้อะไรเป็นพิเศษครับ?".to_string()
1023
+ }]
1024
+ );
1025
+
1026
+ assert_eq!(messages[2].role, ChatRole::User);
1027
+ assert_eq!(
1028
+ messages[2].content,
1029
+ vec![ContentBlock::Text {
1030
+ text: "ฉันชื่อภีม ชอบกินมะม่วง".to_string()
1031
+ }]
1032
+ );
1033
+
1034
+ assert_eq!(messages[3].role, ChatRole::Assistant);
1035
+ assert_eq!(
1036
+ messages[3].content,
1037
+ vec![ContentBlock::Text {
1038
+ text: "สวัสดีครับคุณภีม ยินดีที่ได้รู้จักครับ! มะม่วงหวานอร่อยมากครับ".to_string()
1039
+ }]
1040
+ );
1041
+
1042
+ let _ = std::fs::remove_file(db_path);
1043
+ }
1044
+
1045
+ #[test]
1046
+ fn build_conversation_history_messages_handles_empty_ai_text_fallback() {
1047
+ let db_path = std::env::temp_dir().join(format!(
1048
+ "mint-test-fallback-{}.sqlite",
1049
+ uuid::Uuid::new_v4()
1050
+ ));
1051
+ let store = MemoryStore::open(&db_path);
1052
+ let chat_id = "test-chat-empty-ai";
1053
+
1054
+ insert_completed_turn(&store, chat_id, "run silent task", " ");
1055
+
1056
+ let messages = build_conversation_history_messages(&store, chat_id, 15, 4000);
1057
+ assert_eq!(messages.len(), 2);
1058
+ assert_eq!(messages[0].role, ChatRole::User);
1059
+ assert_eq!(messages[1].role, ChatRole::Assistant);
1060
+ assert_eq!(
1061
+ messages[1].content,
1062
+ vec![ContentBlock::Text {
1063
+ text: "(completed)".to_string()
1064
+ }]
1065
+ );
1066
+
1067
+ let _ = std::fs::remove_file(db_path);
1068
+ }
1069
+
1070
+ #[test]
1071
+ fn build_conversation_history_messages_truncation_preserves_multibyte() {
1072
+ let db_path =
1073
+ std::env::temp_dir().join(format!("mint-test-trunc-{}.sqlite", uuid::Uuid::new_v4()));
1074
+ let store = MemoryStore::open(&db_path);
1075
+ let chat_id = "test-chat-trunc";
1076
+
1077
+ let long_thai = "สวัสดีชาวโลก ".repeat(400); // > 4,000 characters
1078
+ insert_completed_turn(&store, chat_id, &long_thai, "รับทราบครับ");
1079
+
1080
+ let messages = build_conversation_history_messages(&store, chat_id, 15, 4000);
1081
+ assert_eq!(messages.len(), 2);
1082
+ if let ContentBlock::Text { text } = &messages[0].content[0] {
1083
+ assert!(text.ends_with("... [truncated]"));
1084
+ assert_eq!(
1085
+ text.chars().count(),
1086
+ 4000 + "... [truncated]".chars().count()
1087
+ );
1088
+ } else {
1089
+ panic!("expected ContentBlock::Text");
1090
+ }
1091
+
1092
+ let _ = std::fs::remove_file(db_path);
1093
+ }
1094
+
1095
+ #[test]
1096
+ fn render_conversation_transcript_formats_cleanly() {
1097
+ let db_path = std::env::temp_dir().join(format!(
1098
+ "mint-test-transcript-{}.sqlite",
1099
+ uuid::Uuid::new_v4()
1100
+ ));
1101
+ let store = MemoryStore::open(&db_path);
1102
+ let chat_id = "test-chat-transcript";
1103
+
1104
+ insert_completed_turn(&store, chat_id, "คำถามข้อ 1", "คำตอบข้อ 1");
1105
+
1106
+ let transcript = render_conversation_transcript(&store, chat_id, 15, 4000).unwrap();
1107
+ assert!(transcript.contains("User: คำถามข้อ 1"));
1108
+ assert!(transcript.contains("Assistant: คำตอบข้อ 1"));
1109
+
1110
+ let _ = std::fs::remove_file(db_path);
1111
+ }
1112
+
1113
+ #[test]
1114
+ fn enrich_request_populates_messages_and_omits_duplicate_transcript() {
1115
+ let db_path =
1116
+ std::env::temp_dir().join(format!("mint-test-enrich-{}.sqlite", uuid::Uuid::new_v4()));
1117
+ let store = MemoryStore::open(&db_path);
1118
+ let chat_id = "test-chat-enrich";
1119
+
1120
+ insert_completed_turn(&store, chat_id, "คุยรอบแรก", "ตอบรอบแรก");
1121
+
1122
+ let request = ChatRequest {
1123
+ message: "คุยรอบสอง".into(),
1124
+ system_instruction: "You are a helpful assistant.".into(),
1125
+ chat_id: Some(chat_id.to_string()),
1126
+ image_data_uri: None,
1127
+ audio_data_uri: None,
1128
+ video_data_uri: None,
1129
+ document_attachment: None,
1130
+ workspace_path: None,
1131
+ agent_id: None,
1132
+ plan_mode: false,
1133
+ pinned_mcp_server: None,
1134
+ messages: None,
1135
+ tools: None,
1136
+ temperature: None,
1137
+ ..Default::default()
1138
+ };
1139
+ let config = MintConfig::default();
1140
+ let enriched = enrich_request(&config, &store, &request).unwrap();
1141
+
1142
+ // Verify structured messages populated: 2 historical + 1 current = 3 messages
1143
+ assert!(enriched.messages.is_some());
1144
+ let msgs = enriched.messages.unwrap();
1145
+ assert_eq!(msgs.len(), 3);
1146
+ assert_eq!(msgs[0].role, ChatRole::User);
1147
+ assert_eq!(
1148
+ msgs[0].content,
1149
+ vec![ContentBlock::Text {
1150
+ text: "คุยรอบแรก".into()
1151
+ }]
1152
+ );
1153
+ assert_eq!(msgs[1].role, ChatRole::Assistant);
1154
+ assert_eq!(
1155
+ msgs[1].content,
1156
+ vec![ContentBlock::Text {
1157
+ text: "ตอบรอบแรก".into()
1158
+ }]
1159
+ );
1160
+ assert_eq!(msgs[2].role, ChatRole::User);
1161
+ assert_eq!(
1162
+ msgs[2].content,
1163
+ vec![ContentBlock::Text {
1164
+ text: "คุยรอบสอง".into()
1165
+ }]
1166
+ );
1167
+
1168
+ // Verify system instruction does NOT duplicate "Recent conversation context:"
1169
+ assert!(
1170
+ !enriched
1171
+ .system_instruction
1172
+ .contains("Recent conversation context:")
1173
+ );
1174
+
1175
+ let _ = std::fs::remove_file(db_path);
1176
+ }
1177
+
1178
+ #[test]
1179
+ fn enrich_request_injects_transcript_for_non_native_tool_calling() {
1180
+ let db_path = std::env::temp_dir().join(format!(
1181
+ "mint-test-enrich-nonnative-{}.sqlite",
1182
+ uuid::Uuid::new_v4()
1183
+ ));
1184
+ let store = MemoryStore::open(&db_path);
1185
+ let chat_id = "test-chat-enrich-nonnative";
1186
+
1187
+ insert_completed_turn(&store, chat_id, "คุยรอบแรก", "ตอบรอบแรก");
1188
+
1189
+ let request = ChatRequest {
1190
+ message: "คุยรอบสอง".into(),
1191
+ system_instruction: "You are a helpful assistant.".into(),
1192
+ chat_id: Some(chat_id.to_string()),
1193
+ ..Default::default()
1194
+ };
1195
+ // Provider with ToolCallingMode::JsonPrompt (e.g. huggingface)
1196
+ let config = MintConfig {
1197
+ ai_provider: "huggingface".into(),
1198
+ ..Default::default()
1199
+ };
1200
+ assert_eq!(config.tool_calling_mode(), ToolCallingMode::JsonPrompt);
1201
+
1202
+ let enriched = enrich_request(&config, &store, &request).unwrap();
1203
+
1204
+ // In non-native mode, messages should remain None (since non-native providers
1205
+ // ignore request.messages in plain chat)
1206
+ assert!(enriched.messages.is_none());
1207
+
1208
+ // Instead, recent conversation context transcript must be injected into system instruction
1209
+ assert!(
1210
+ enriched
1211
+ .system_instruction
1212
+ .contains("Recent conversation context:")
1213
+ );
1214
+ assert!(enriched.system_instruction.contains("User: คุยรอบแรก"));
1215
+ assert!(enriched.system_instruction.contains("Assistant: ตอบรอบแรก"));
1216
+
1217
+ let _ = std::fs::remove_file(db_path);
1218
+ }
1219
+
1220
+ #[tokio::test]
1221
+ async fn multi_turn_conversation_continuity_propagates_into_native_messages_and_preserves_context()
1222
+ {
1223
+ let db_path = std::env::temp_dir().join(format!(
1224
+ "mint-test-multiturn-{}.sqlite",
1225
+ uuid::Uuid::new_v4()
1226
+ ));
1227
+ let store = MemoryStore::open(&db_path);
1228
+ let chat_id = "test-multiturn-continuity";
1229
+
1230
+ // Turn 1
1231
+ insert_completed_turn(
1232
+ &store,
1233
+ chat_id,
1234
+ "ฉันชอบสีส้มและผลไม้แอปเปิ้ล",
1235
+ "รับทราบครับ คุณชอบสีส้มและผลไม้แอปเปิ้ล",
1236
+ );
1237
+ // Turn 2
1238
+ insert_completed_turn(&store, chat_id, "ช่วยจำเรื่องนี้ไว้ด้วยนะ", "ได้เลยครับ บันทึกไว้แล้ว");
1239
+
1240
+ // Verify history builder recovers all 4 messages in strict chronological order
1241
+ let history = build_conversation_history_messages(
1242
+ &store,
1243
+ chat_id,
1244
+ CONTEXT_LIMIT,
1245
+ MAX_CONTEXT_MESSAGE_CHARS,
1246
+ );
1247
+ assert_eq!(history.len(), 4);
1248
+ assert_eq!(history[0].role, ChatRole::User);
1249
+ assert_eq!(
1250
+ history[0].content,
1251
+ vec![ContentBlock::Text {
1252
+ text: "ฉันชอบสีส้มและผลไม้แอปเปิ้ล".into()
1253
+ }]
1254
+ );
1255
+ assert_eq!(history[1].role, ChatRole::Assistant);
1256
+ assert_eq!(
1257
+ history[1].content,
1258
+ vec![ContentBlock::Text {
1259
+ text: "รับทราบครับ คุณชอบสีส้มและผลไม้แอปเปิ้ล".into()
1260
+ }]
1261
+ );
1262
+ assert_eq!(history[2].role, ChatRole::User);
1263
+ assert_eq!(
1264
+ history[2].content,
1265
+ vec![ContentBlock::Text {
1266
+ text: "ช่วยจำเรื่องนี้ไว้ด้วยนะ".into()
1267
+ }]
1268
+ );
1269
+ assert_eq!(history[3].role, ChatRole::Assistant);
1270
+ assert_eq!(
1271
+ history[3].content,
1272
+ vec![ContentBlock::Text {
1273
+ text: "ได้เลยครับ บันทึกไว้แล้ว".into()
1274
+ }]
1275
+ );
1276
+
1277
+ // Simulate turn 3 initialization in orchestrate_agent_loop
1278
+ let mut native_messages = history;
1279
+ let history_prefix_len = native_messages.len();
1280
+ assert_eq!(history_prefix_len, 4);
1281
+
1282
+ let current_task = "ใช้ฟีเจอร์ ask_user ถามผลไม้ที่ฉันชอบหน่อย";
1283
+ let content = build_user_content_blocks(current_task.to_string(), None, None, None);
1284
+ native_messages.push(ChatMessage {
1285
+ role: ChatRole::User,
1286
+ content,
1287
+ });
1288
+ assert_eq!(native_messages.len(), 5);
1289
+
1290
+ // System prompt in native mode must NOT contain duplicate text transcript
1291
+ let config = MintConfig::default();
1292
+ let root = std::path::PathBuf::from("/tmp");
1293
+ let system_prompt = construct_system_prompt(
1294
+ &config,
1295
+ &store,
1296
+ chat_id,
1297
+ &root,
1298
+ current_task,
1299
+ false,
1300
+ true,
1301
+ None,
1302
+ None,
1303
+ );
1304
+ assert!(!system_prompt.contains("Recent conversation context:"));
1305
+
1306
+ // Verify compaction boundary preserves all 4 historical turns + initial task
1307
+ for i in 0..5 {
1308
+ native_messages.extend(step_pair(i));
1309
+ }
1310
+ let config_fail = MintConfig {
1311
+ ai_provider: "non_existent_provider_fail".into(),
1312
+ ..Default::default()
1313
+ };
1314
+ let (compacted, _) =
1315
+ compact_native_messages(&config_fail, &native_messages, history_prefix_len)
1316
+ .await
1317
+ .unwrap()
1318
+ .unwrap();
1319
+
1320
+ // 4 historical + 1 initial user + 2 summary/continuation + 6 recent steps = 13 messages
1321
+ assert_eq!(compacted.len(), 4 + 1 + 2 + 6);
1322
+ assert_eq!(
1323
+ compacted[0].content,
1324
+ vec![ContentBlock::Text {
1325
+ text: "ฉันชอบสีส้มและผลไม้แอปเปิ้ล".into()
1326
+ }]
1327
+ );
1328
+ assert_eq!(
1329
+ compacted[4].content,
1330
+ vec![ContentBlock::Text {
1331
+ text: current_task.into()
1332
+ }]
1333
+ );
1334
+
1335
+ let _ = std::fs::remove_file(db_path);
1336
+ }
1337
+
1338
+ #[test]
1339
+ fn agent_decision_allows_null_input() {
1340
+ let decision = parse_decision(r#"{"thought":"done","action":"finish","input":null}"#)
1341
+ .expect("null input should parse as default input");
1342
+
1343
+ assert_eq!(decision.action, "finish");
1344
+ assert!(decision.input.summary.is_empty());
1345
+ }
1346
+
1347
+ #[test]
1348
+ fn agent_decision_allows_missing_input() {
1349
+ let decision = parse_decision(r#"{"thought":"done","action":"finish"}"#)
1350
+ .expect("missing input should parse as default input");
1351
+
1352
+ assert_eq!(decision.action, "finish");
1353
+ assert!(decision.input.summary.is_empty());
1354
+ }
1355
+
1356
+ #[test]
1357
+ fn shorthand_finish_allows_null_or_missing_finish() {
1358
+ let decision =
1359
+ parse_decision(r#"{"thought":"done","finish":null}"#).expect("null finish should parse");
1360
+ assert_eq!(decision.action, "finish");
1361
+ assert!(decision.input.summary.is_empty());
1362
+
1363
+ let decision = parse_decision(r#"{"thought":"done"}"#).expect("missing finish should parse");
1364
+ assert_eq!(decision.action, "finish");
1365
+ assert!(decision.input.summary.is_empty());
1366
+ }
1367
+
1368
+ #[test]
1369
+ fn shorthand_finish_allows_string_finish_as_summary() {
1370
+ let decision = parse_decision(r#"{"thought":"done","finish":"all done!"}"#)
1371
+ .expect("string finish should parse as summary");
1372
+ assert_eq!(decision.action, "finish");
1373
+ assert_eq!(decision.input.summary, "all done!");
1374
+ }
1375
+
1376
+ #[test]
1377
+ fn write_file_policy_rejects_existing_workspace_file() {
1378
+ let root = std::env::temp_dir().join(format!("mint-write-file-policy-{}", std::process::id()));
1379
+ std::fs::create_dir_all(&root).unwrap();
1380
+ let target = root.join("existing.txt");
1381
+ std::fs::write(&target, "already here").unwrap();
1382
+ let config = MintConfig {
1383
+ allowed_read_paths: vec![root.clone()],
1384
+ allowed_write_paths: vec![root.clone()],
1385
+ blocked_paths: vec![],
1386
+ blocked_file_names: vec![],
1387
+ ..MintConfig::default()
1388
+ };
1389
+
1390
+ let result = validate_new_workspace_file(&root, &config, Path::new("existing.txt"));
1391
+
1392
+ assert!(
1393
+ matches!(result, Err(OrchestrationError::Agent(message)) if message.contains("Use apply_patch"))
1394
+ );
1395
+ let _ = std::fs::remove_file(target);
1396
+ let _ = std::fs::remove_dir(root);
1397
+ }
1398
+
1399
+ #[test]
1400
+ fn agent_list_files_includes_directories() {
1401
+ let root = std::env::temp_dir().join(format!(
1402
+ "mint-agent-list-directories-{}",
1403
+ std::process::id()
1404
+ ));
1405
+ std::fs::create_dir_all(root.join("Bunny Girl")).unwrap();
1406
+ std::fs::write(root.join("note.txt"), "hello").unwrap();
1407
+ let config = MintConfig {
1408
+ allowed_read_paths: vec![root.clone()],
1409
+ allowed_write_paths: vec![root.clone()],
1410
+ blocked_paths: vec![],
1411
+ blocked_file_names: vec![],
1412
+ ..MintConfig::default()
1413
+ };
1414
+
1415
+ let entries = list_directory_entries(&root, 100, &config).unwrap();
1416
+
1417
+ assert!(entries.iter().any(|entry| {
1418
+ entry.name == "Bunny Girl" && entry.kind == "directory" && entry.size.is_none()
1419
+ }));
1420
+ assert!(entries.iter().any(|entry| {
1421
+ entry.name == "note.txt" && entry.kind == "file" && entry.size == Some(5)
1422
+ }));
1423
+ let _ = std::fs::remove_file(root.join("note.txt"));
1424
+ let _ = std::fs::remove_dir(root.join("Bunny Girl"));
1425
+ let _ = std::fs::remove_dir(root);
1426
+ }
1427
+
1428
+ #[test]
1429
+ fn grep_is_classified_as_read_only() {
1430
+ let classification = classify_shell_command("ls ~/Downloads | grep -F \"Bunny Girl\"");
1431
+
1432
+ assert_eq!(classification.mode.as_str(), "readOnly");
1433
+ }
1434
+
1435
+ #[test]
1436
+ fn test_resolve_agent_config() {
1437
+ let mut config = MintConfig::default();
1438
+ config.enable_agent_collaboration = true;
1439
+ config.agents = vec![
1440
+ AgentConfig {
1441
+ id: "planner".into(),
1442
+ name: "Planner".into(),
1443
+ provider: "openai".into(),
1444
+ model: "gpt-4o".into(),
1445
+ api_key: Some("test-key".into()),
1446
+ system_instruction: "Planner instruction".into(),
1447
+ enabled: true,
1448
+ },
1449
+ AgentConfig {
1450
+ id: "coder".into(),
1451
+ name: "Coder".into(),
1452
+ provider: "gemini".into(),
1453
+ model: "gemini-2.5-flash".into(),
1454
+ api_key: None,
1455
+ system_instruction: "Coder instruction".into(),
1456
+ enabled: true,
1457
+ },
1458
+ ];
1459
+
1460
+ let (cfg, instr, name, model) = resolve_agent_config(&config, Some("planner"), &[]);
1461
+ assert_eq!(cfg.ai_provider, "openai");
1462
+ assert_eq!(cfg.openai_model, "gpt-4o");
1463
+ assert_eq!(cfg.openai_api_key, "test-key");
1464
+ assert_eq!(instr, "Planner instruction");
1465
+ assert_eq!(name, Some("Planner".to_string()));
1466
+ assert_eq!(model, Some("gpt-4o".to_string()));
1467
+
1468
+ let (cfg, instr, name, _model) = resolve_agent_config(&config, None, &[]);
1469
+ assert_eq!(cfg.ai_provider, "openai");
1470
+ assert_eq!(instr, "Planner instruction");
1471
+ assert_eq!(name, Some("Planner".to_string()));
1472
+
1473
+ let trajectory = vec!["Step 1:\n- Action: create_plan\n- Observation: all planned".to_string()];
1474
+ let (cfg, instr, name, model) = resolve_agent_config(&config, None, &trajectory);
1475
+ assert_eq!(cfg.ai_provider, "gemini");
1476
+ assert_eq!(instr, "Coder instruction");
1477
+ assert_eq!(name, Some("Coder".to_string()));
1478
+ assert_eq!(model, Some("gemini-2.5-flash".to_string()));
1479
+ }
1480
+
1481
+ fn step_pair(index: usize) -> [ChatMessage; 2] {
1482
+ [
1483
+ ChatMessage {
1484
+ role: ChatRole::Assistant,
1485
+ content: vec![ContentBlock::ToolUse {
1486
+ id: format!("call_{index}"),
1487
+ name: "read_file".into(),
1488
+ input: serde_json::json!({ "path": format!("file{index}.rs") }),
1489
+ thought_signature: None,
1490
+ }],
1491
+ },
1492
+ ChatMessage {
1493
+ role: ChatRole::Tool,
1494
+ content: vec![ContentBlock::ToolResult {
1495
+ tool_use_id: format!("call_{index}"),
1496
+ content: format!("contents of file{index}.rs"),
1497
+ is_error: false,
1498
+ }],
1499
+ },
1500
+ ]
1501
+ }
1502
+
1503
+ fn native_messages_with_steps(step_count: usize) -> Vec<ChatMessage> {
1504
+ let mut messages = vec![ChatMessage::text(ChatRole::User, "do the task")];
1505
+ for i in 0..step_count {
1506
+ messages.extend(step_pair(i));
1507
+ }
1508
+ messages
1509
+ }
1510
+
1511
+ #[tokio::test]
1512
+ async fn compact_native_messages_is_a_noop_when_history_is_short() {
1513
+ // COMPACTION_KEEP_RECENT_STEPS = 3, so exactly 3 step-pairs is nothing to compact yet.
1514
+ let messages = native_messages_with_steps(3);
1515
+ let config = MintConfig::default();
1516
+ let result = compact_native_messages(&config, &messages, 0)
1517
+ .await
1518
+ .unwrap();
1519
+ assert!(result.is_none());
1520
+ }
1521
+
1522
+ #[tokio::test]
1523
+ async fn compact_native_messages_is_a_noop_for_empty_history() {
1524
+ let config = MintConfig::default();
1525
+ let result = compact_native_messages(&config, &[], 0).await.unwrap();
1526
+ assert!(result.is_none());
1527
+ }
1528
+
1529
+ #[test]
1530
+ fn render_messages_as_text_includes_tool_calls_and_results() {
1531
+ let messages = native_messages_with_steps(1);
1532
+ let rendered = render_messages_as_text(&messages);
1533
+ assert!(rendered.contains("Called read_file with"));
1534
+ assert!(rendered.contains("Result: contents of file0.rs"));
1535
+ assert!(rendered.contains("do the task"));
1536
+ }
1537
+
1538
+ #[test]
1539
+ fn no_modification_means_finish_never_needs_verification() {
1540
+ // A pure Q&A run that never touched apply_patch/write_file can finish
1541
+ // immediately, regardless of what (if anything) is in `verification`.
1542
+ assert!(!unverified_modification(None, None, ""));
1543
+ assert!(!unverified_modification(None, Some(1), ""));
1544
+ }
1545
+
1546
+ #[test]
1547
+ fn modification_without_any_verify_call_blocks_finish() {
1548
+ assert!(unverified_modification(Some(2), None, ""));
1549
+ }
1550
+
1551
+ #[test]
1552
+ fn verify_called_before_the_modification_does_not_count() {
1553
+ // Editing again after the last verify invalidates that earlier check.
1554
+ assert!(unverified_modification(Some(3), Some(1), ""));
1555
+ }
1556
+
1557
+ #[test]
1558
+ fn verify_called_after_the_modification_satisfies_the_gate() {
1559
+ assert!(!unverified_modification(Some(2), Some(3), ""));
1560
+ }
1561
+
1562
+ #[test]
1563
+ fn verify_on_the_same_step_as_the_modification_satisfies_the_gate() {
1564
+ // Both markers can land on the same step if a single turn issues
1565
+ // multiple tool calls (apply_patch then verify back to back).
1566
+ assert!(!unverified_modification(Some(2), Some(2), ""));
1567
+ }
1568
+
1569
+ #[test]
1570
+ fn explicit_written_justification_does_not_verify_an_unchecked_edit() {
1571
+ assert!(unverified_modification(
1572
+ Some(2),
1573
+ None,
1574
+ "Documentation-only change; no test suite in this repo."
1575
+ ));
1576
+ }
1577
+
1578
+ #[test]
1579
+ fn placeholder_verification_text_does_not_satisfy_the_gate() {
1580
+ // Mirrors `meaningful_verification`'s own placeholder filter — "n/a" etc.
1581
+ // must not be treated as a real justification.
1582
+ assert!(unverified_modification(Some(2), None, "n/a"));
1583
+ assert!(unverified_modification(Some(2), None, "not run"));
1584
+ }
1585
+
1586
+ fn decision_with_action(action: &str) -> (String, AgentDecision) {
1587
+ (
1588
+ "call_0".to_string(),
1589
+ AgentDecision {
1590
+ thought: String::new(),
1591
+ action: action.to_string(),
1592
+ input: AgentInput::default(),
1593
+ },
1594
+ )
1595
+ }
1596
+
1597
+ #[test]
1598
+ fn empty_decisions_never_qualify_for_the_parallel_batch() {
1599
+ assert!(!decisions_are_parallel_read_only_batch(&[]));
1600
+ }
1601
+
1602
+ #[test]
1603
+ fn two_or_more_read_only_decisions_qualify_for_the_parallel_batch() {
1604
+ let decisions = vec![
1605
+ decision_with_action("read_file"),
1606
+ decision_with_action("search_code"),
1607
+ ];
1608
+ assert!(decisions_are_parallel_read_only_batch(&decisions));
1609
+
1610
+ let decisions = vec![
1611
+ decision_with_action("read_file"),
1612
+ decision_with_action("web_search"),
1613
+ decision_with_action("git_diff"),
1614
+ ];
1615
+ assert!(decisions_are_parallel_read_only_batch(&decisions));
1616
+ }
1617
+
1618
+ #[test]
1619
+ fn a_lone_read_only_decision_stays_sequential() {
1620
+ let decisions = vec![decision_with_action("read_file")];
1621
+ assert!(!decisions_are_parallel_read_only_batch(&decisions));
1622
+ }
1623
+
1624
+ #[test]
1625
+ fn read_only_mixed_with_mutating_action_stays_sequential() {
1626
+ let decisions = vec![
1627
+ decision_with_action("read_file"),
1628
+ decision_with_action("write_file"),
1629
+ ];
1630
+ assert!(!decisions_are_parallel_read_only_batch(&decisions));
1631
+
1632
+ let decisions = vec![
1633
+ decision_with_action("read_file"),
1634
+ decision_with_action("run_shell"),
1635
+ ];
1636
+ assert!(!decisions_are_parallel_read_only_batch(&decisions));
1637
+ }
1638
+
1639
+ #[test]
1640
+ fn rebuild_observation_reuses_buffer_and_formats_history() {
1641
+ let root = Path::new("/workspace/test-project");
1642
+ let task = "Fix the login bug";
1643
+ let step1 = format_trajectory_step(1, "Inspecting files", "list_files", "src/main.rs");
1644
+ let step2 = format_trajectory_step(2, "Reading code", "read_file", "fn main() {}");
1645
+ let trajectory = vec![step1, step2];
1646
+
1647
+ let mut buffer = String::new();
1648
+ rebuild_observation(task, root, &trajectory, &mut buffer);
1649
+
1650
+ assert!(buffer.contains("Task: Fix the login bug"));
1651
+ assert!(buffer.contains("Workspace: /workspace/test-project"));
1652
+ assert!(buffer.contains("Step 1:\n- Thought: Inspecting files"));
1653
+ assert!(buffer.contains("Step 2:\n- Thought: Reading code"));
1654
+ assert!(buffer.ends_with(
1655
+ "Proceed to the next step. If you have completed the task, use the 'finish' action."
1656
+ ));
1657
+
1658
+ // Ensure buffer can be reused cleanly for subsequent steps
1659
+ let step3 = format_trajectory_step(3, "All done", "finish", "Fixed");
1660
+ let mut extended_trajectory = trajectory;
1661
+ extended_trajectory.push(step3);
1662
+ rebuild_observation(task, root, &extended_trajectory, &mut buffer);
1663
+
1664
+ assert!(buffer.contains("Step 3:\n- Thought: All done"));
1665
+ }