@pheem49/mint 1.15.0 → 1.16.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 (644) hide show
  1. package/Cargo.lock +1012 -69
  2. package/Cargo.toml +5 -2
  3. package/LICENSE +21 -654
  4. package/README.md +92 -817
  5. package/Release_Note.md +53 -560
  6. package/apps/mint-companion/src-tauri/Cargo.toml +20 -0
  7. package/apps/mint-companion/src-tauri/build.rs +3 -0
  8. package/apps/mint-companion/src-tauri/src/main.rs +468 -0
  9. package/apps/mint-companion/tsconfig.json +20 -0
  10. package/apps/mint-companion/vite.config.ts +3 -0
  11. package/components.json +19 -0
  12. package/crates/mint-cli/Cargo.toml +6 -0
  13. package/crates/mint-cli/src/agent/live_status.rs +187 -39
  14. package/crates/mint-cli/src/agent.rs +323 -95
  15. package/crates/mint-cli/src/commands/agent.rs +69 -55
  16. package/crates/mint-cli/src/commands/integrations.rs +7 -0
  17. package/crates/mint-cli/src/commands/knowledge.rs +12 -32
  18. package/crates/mint-cli/src/commands/mod.rs +44 -0
  19. package/crates/mint-cli/src/image.rs +31 -33
  20. package/crates/mint-cli/src/interactive/chat_tui.rs +3594 -483
  21. package/crates/mint-cli/src/interactive/input_box.rs +56 -6
  22. package/crates/mint-cli/src/interactive/slash_commands.rs +208 -56
  23. package/crates/mint-cli/src/interactive/tool_activity.rs +328 -0
  24. package/crates/mint-cli/src/interactive.rs +64 -40
  25. package/crates/mint-cli/src/main.rs +13 -7
  26. package/crates/mint-cli/src/mcp.rs +5 -0
  27. package/crates/mint-cli/src/mod_host.rs +6 -0
  28. package/crates/mint-cli/src/mods_cli.rs +62 -0
  29. package/crates/mint-cli/src/mods_ui.rs +187 -0
  30. package/crates/mint-cli/src/onboard.rs +5 -31
  31. package/crates/mint-cli/src/terminal_theme.rs +3 -1
  32. package/crates/mint-cli/src/updater.rs +56 -2
  33. package/crates/mint-cli/tests/library_intake.rs +77 -0
  34. package/crates/mint-cli/tests/mods_testing.rs +133 -0
  35. package/crates/mint-companion-protocol/Cargo.toml +10 -0
  36. package/crates/mint-companion-protocol/src/lib.rs +163 -0
  37. package/crates/mint-core/Cargo.toml +6 -3
  38. package/crates/mint-core/build.rs +26 -0
  39. package/crates/mint-core/src/agent/chat.rs +167 -39
  40. package/crates/mint-core/src/agent/code_tools.rs +15 -0
  41. package/crates/mint-core/src/agent/memory/sources.rs +240 -0
  42. package/crates/mint-core/src/agent/memory.rs +147 -13
  43. package/crates/mint-core/src/agent/skill_inventory.rs +325 -0
  44. package/crates/mint-core/src/agent/skills.rs +437 -90
  45. package/crates/mint-core/src/agent.rs +1 -0
  46. package/crates/mint-core/src/api_server/routes/background_jobs.rs +310 -0
  47. package/crates/mint-core/src/api_server/routes/chat.rs +216 -199
  48. package/crates/mint-core/src/api_server/routes/html_preview.rs +77 -0
  49. package/crates/mint-core/src/api_server/routes/library.rs +97 -0
  50. package/crates/mint-core/src/api_server/routes/media_gen.rs +10 -57
  51. package/crates/mint-core/src/api_server/routes/misc.rs +104 -107
  52. package/crates/mint-core/src/api_server/routes/mod.rs +6 -0
  53. package/crates/mint-core/src/api_server/routes/mods.rs +31 -0
  54. package/crates/mint-core/src/api_server/routes/sessions.rs +41 -0
  55. package/crates/mint-core/src/api_server/routes/skills_subagents.rs +12 -42
  56. package/crates/mint-core/src/api_server/routes/slash.rs +50 -1
  57. package/crates/mint-core/src/api_server/routes/sources.rs +101 -0
  58. package/crates/mint-core/src/api_server.rs +118 -9
  59. package/crates/mint-core/src/avatar_bridge.rs +11 -2
  60. package/crates/mint-core/src/browser/session.rs +52 -0
  61. package/crates/mint-core/src/companion.rs +716 -0
  62. package/crates/mint-core/src/cron/scheduler.rs +34 -2
  63. package/crates/mint-core/src/integrations/channels.rs +87 -345
  64. package/crates/mint-core/src/integrations/mcp.rs +397 -50
  65. package/crates/mint-core/src/integrations/mcp_calls.rs +823 -0
  66. package/crates/mint-core/src/integrations/mcp_catalog.rs +267 -0
  67. package/crates/mint-core/src/integrations/mcp_device.rs +319 -0
  68. package/crates/mint-core/src/integrations/mcp_result.rs +356 -0
  69. package/crates/mint-core/src/integrations/webhooks.rs +539 -0
  70. package/crates/mint-core/src/integrations.rs +2 -0
  71. package/crates/mint-core/src/lib.rs +85 -26
  72. package/crates/mint-core/src/media/gemini_live.rs +134 -14
  73. package/crates/mint-core/src/media/image_models.rs +3 -27
  74. package/crates/mint-core/src/media/image_workflow.rs +216 -0
  75. package/crates/mint-core/src/media/library/intake.rs +144 -0
  76. package/crates/mint-core/src/media/library/migration.rs +56 -0
  77. package/crates/mint-core/src/media/library/removal.rs +55 -0
  78. package/crates/mint-core/src/media/library/tests.rs +730 -0
  79. package/crates/mint-core/src/media/library/thumbnails.rs +72 -0
  80. package/crates/mint-core/src/media/library.rs +545 -0
  81. package/crates/mint-core/src/media/model_catalog.rs +375 -0
  82. package/crates/mint-core/src/media/pictures.rs +79 -222
  83. package/crates/mint-core/src/media/video_models.rs +3 -22
  84. package/crates/mint-core/src/media.rs +3 -0
  85. package/crates/mint-core/src/mods/manager.rs +1124 -0
  86. package/crates/mint-core/src/mods/mod.rs +569 -0
  87. package/crates/mint-core/src/mods/operations.rs +204 -0
  88. package/crates/mint-core/src/mods/session.rs +146 -0
  89. package/crates/mint-core/src/mods/slash.rs +59 -0
  90. package/crates/mint-core/src/mods/telemetry.rs +306 -0
  91. package/crates/mint-core/src/orchestration/approvals.rs +344 -0
  92. package/crates/mint-core/src/orchestration/context_render.rs +104 -0
  93. package/crates/mint-core/src/orchestration/mcp_review.rs +153 -0
  94. package/crates/mint-core/src/orchestration/memory_skill.rs +104 -32
  95. package/crates/mint-core/src/orchestration/mod.rs +2139 -244
  96. package/crates/mint-core/src/orchestration/mod_authoring_regressions.rs +209 -0
  97. package/crates/mint-core/src/orchestration/progress_clock.rs +80 -0
  98. package/crates/mint-core/src/orchestration/repeated_failures.rs +116 -0
  99. package/crates/mint-core/src/orchestration/review_regressions.rs +449 -0
  100. package/crates/mint-core/src/orchestration/run_control.rs +196 -0
  101. package/crates/mint-core/src/orchestration/tools/files.rs +161 -14
  102. package/crates/mint-core/src/orchestration/tools/media.rs +37 -41
  103. package/crates/mint-core/src/orchestration/tools/misc.rs +3 -2
  104. package/crates/mint-core/src/orchestration/tools/mod.rs +2 -0
  105. package/crates/mint-core/src/orchestration/tools/mods.rs +161 -0
  106. package/crates/mint-core/src/orchestration/tools/plugins_mcp.rs +303 -71
  107. package/crates/mint-core/src/orchestration/tools/shell.rs +95 -2
  108. package/crates/mint-core/src/orchestration/workspace_helpers.rs +4 -1
  109. package/crates/mint-core/src/prompts/agent.rs +17 -2
  110. package/crates/mint-core/src/prompts/tool_catalog.rs +28 -2
  111. package/crates/mint-core/src/search/knowledge.rs +7 -1
  112. package/crates/mint-core/src/search/linked_folders/index.rs +177 -13
  113. package/crates/mint-core/src/search/linked_folders.rs +83 -2
  114. package/crates/mint-core/src/slash/catalog.rs +1 -1
  115. package/crates/mint-core/src/slash/mod.rs +174 -11
  116. package/crates/mint-core/src/slash/mods.rs +155 -0
  117. package/crates/mint-core/src/system/bg_shell.rs +1129 -95
  118. package/crates/mint-core/src/system/config.rs +5 -10
  119. package/crates/mint-core/src/system/html_preview.rs +398 -0
  120. package/crates/mint-core/src/system/mint_web.rs +164 -0
  121. package/crates/mint-core/src/system/release_notes.rs +18 -0
  122. package/crates/mint-core/src/system/shell.rs +21 -6
  123. package/crates/mint-core/src/system/workspace.rs +72 -8
  124. package/crates/mint-core/src/system/workspace_history.rs +604 -0
  125. package/crates/mint-core/src/system.rs +4 -0
  126. package/crates/mint-core/tests/background_retention.rs +34 -0
  127. package/crates/mint-core/tests/background_shutdown.rs +24 -0
  128. package/crates/mint-core/tests/library_http.rs +209 -0
  129. package/crates/mint-core/tests/mcp_contract.rs +283 -0
  130. package/crates/mint-core/tests/mcp_stdio.rs +11 -10
  131. package/crates/mint-core/tests/media_model_catalog_http.rs +403 -0
  132. package/crates/mint-core/tests/mods_http.rs +215 -0
  133. package/crates/mint-core/tests/terminal_shutdown.rs +109 -0
  134. package/crates/mint-mod-runtime/Cargo.toml +18 -0
  135. package/crates/mint-mod-runtime/src/lib.rs +507 -0
  136. package/crates/mint-mod-runtime/src/sdk.js +56 -0
  137. package/crates/mint-mod-runtime/src/test_sdk.js +32 -0
  138. package/crates/mint-mod-runtime/src/testing.rs +94 -0
  139. package/crates/mint-mod-runtime/tests/runtime.rs +156 -0
  140. package/docs/mods/AUTHORING.md +61 -0
  141. package/docs/mods/README.md +139 -0
  142. package/docs/mods/TESTING.md +56 -0
  143. package/docs/mods/mint-mods.d.ts +66 -0
  144. package/examples/device-mcp/README.md +22 -0
  145. package/examples/device-mcp/contract.md +49 -0
  146. package/examples/device-mcp/mock-device.mjs +66 -0
  147. package/examples/device-mcp/server.mjs +48 -0
  148. package/examples/device-mcp/server.test.mjs +78 -0
  149. package/examples/mods/task-checklist/README.md +1 -0
  150. package/examples/mods/task-checklist/main.ts +17 -0
  151. package/examples/mods/task-checklist/manifest.json +1 -0
  152. package/examples/mods/task-checklist/tests/checklist.test.ts +15 -0
  153. package/examples/mods/token-meter/README.md +1 -0
  154. package/examples/mods/token-meter/main.ts +8 -0
  155. package/examples/mods/token-meter/manifest.json +1 -0
  156. package/examples/mods/tool-guard/README.md +1 -0
  157. package/examples/mods/tool-guard/main.ts +9 -0
  158. package/examples/mods/tool-guard/manifest.json +1 -0
  159. package/out/web/assets/CliSessionsView-CtzUJpK_.css +1 -0
  160. package/out/web/assets/CliSessionsView-D7hw0Mgj.js +1 -0
  161. package/out/web/assets/CodeReviewPage-C7bEOJfd.js +4 -0
  162. package/out/web/assets/CodeReviewPage-Dte9uLiL.css +1 -0
  163. package/out/web/assets/CommandPalette-0RrF-AOT.js +1 -0
  164. package/out/web/assets/ImageStudioPanel-CdxkAf_r.js +1 -0
  165. package/out/web/assets/LibraryPage--Qfisp6D.js +2 -0
  166. package/out/web/assets/LibraryPage-DAxUTAg7.css +1 -0
  167. package/out/web/assets/LinkedFoldersView-2nfZbDgN.js +1 -0
  168. package/out/web/assets/McpRegistryPicker-h9uS8_wT.js +1 -0
  169. package/out/web/assets/McpServersView-slfQJzNB.js +1 -0
  170. package/out/web/assets/MintDashboard-BSR5XpXh.js +25 -0
  171. package/out/web/assets/MintDashboard-CWUcCGhd.css +1 -0
  172. package/out/web/assets/PluginsView-l7LSm503.js +1 -0
  173. package/out/web/assets/ScheduledTasksView-CttmLbq1.js +1 -0
  174. package/out/web/assets/SettingsWindow-BCMLYvEV.js +4 -0
  175. package/out/web/assets/SettingsWindow-DcMVmU9u.css +1 -0
  176. package/out/web/assets/SkillsView-rNs1qr_U.js +2 -0
  177. package/out/web/assets/ToolSurfacePage-B9DzEPWN.js +37 -0
  178. package/out/web/assets/ToolSurfacePage-CTfgggF2.css +1 -0
  179. package/out/web/assets/VeoStudioPanel-DiJs_Cg2.js +4 -0
  180. package/out/web/assets/WorkspaceSelector-B2xIp04D.js +1 -0
  181. package/out/web/assets/WorkspaceSelector-Ca8uJOsS.css +1 -0
  182. package/out/web/assets/{abnfDiagram-VCTEODGH-ByMyfanq.js → abnfDiagram-VCTEODGH-CD0CjoGl.js} +1 -1
  183. package/out/web/assets/{architecture-7GRP2DOG-CLq2kEOB.js → architecture-7GRP2DOG-RKV83OeZ.js} +1 -1
  184. package/out/web/assets/{architectureDiagram-5GKGNRK7-6PoVSmNG.js → architectureDiagram-5GKGNRK7-nN_HAFct.js} +1 -1
  185. package/out/web/assets/{blockDiagram-NRAW4CY4-cKw9mAKi.js → blockDiagram-NRAW4CY4-BrrqzHW5.js} +1 -1
  186. package/out/web/assets/{c4Diagram-UCG6FXSJ-Cqy8N5Ge.js → c4Diagram-UCG6FXSJ-DTdV8_zE.js} +1 -1
  187. package/out/web/assets/{chunk-75Z2AOVW-3qybijF5.js → chunk-75Z2AOVW-_M0z5olV.js} +1 -1
  188. package/out/web/assets/chunk-CKBBP62Z-BpQEgeHF.js +2 -0
  189. package/out/web/assets/{chunk-CLGD4ZFX-CndDQkww.js → chunk-CLGD4ZFX-D54yGvGS.js} +1 -1
  190. package/out/web/assets/{chunk-DSNCTWBM-Cc4NxvgD.js → chunk-DSNCTWBM-C-gvT2ru.js} +1 -1
  191. package/out/web/assets/{chunk-DU6HZSFF-drzh6GsU.js → chunk-DU6HZSFF-CnFngIhW.js} +1 -1
  192. package/out/web/assets/{chunk-F27PBJKO-FiY35l4v.js → chunk-F27PBJKO-BqtWrnEO.js} +1 -1
  193. package/out/web/assets/{chunk-G27WJ6UU-Bjt9Il4A.js → chunk-G27WJ6UU-Ckoq5mi2.js} +1 -1
  194. package/out/web/assets/{chunk-GMAD6QVW-Bppi3Ctd.js → chunk-GMAD6QVW-BuhEcgHq.js} +1 -1
  195. package/out/web/assets/{chunk-JQ64N6SF-DGg5kcEV.js → chunk-JQ64N6SF-CF8T7GiZ.js} +1 -1
  196. package/out/web/assets/{chunk-LCL6LL3I-CCYcjd7p.js → chunk-LCL6LL3I-BhXV9zyH.js} +1 -1
  197. package/out/web/assets/{chunk-OBVCFTLP-DJeNil5M.js → chunk-OBVCFTLP-CRIkunUB.js} +1 -1
  198. package/out/web/assets/{chunk-P2QGCYS3-lRzkRjBB.js → chunk-P2QGCYS3-UFEU6BxZ.js} +1 -1
  199. package/out/web/assets/{chunk-POPQ4Y6H-DouASMBW.js → chunk-POPQ4Y6H-CQcrNbjr.js} +1 -1
  200. package/out/web/assets/{chunk-PWAF6VOD-DMmD1vVf.js → chunk-PWAF6VOD-Cc2uVDsG.js} +1 -1
  201. package/out/web/assets/{chunk-R7TYR2AO-CYLA5UC5.js → chunk-R7TYR2AO-DiCxzdAY.js} +1 -1
  202. package/out/web/assets/{chunk-RHFEMEQ7-BiXwEcUT.js → chunk-RHFEMEQ7-b7hnPmrR.js} +1 -1
  203. package/out/web/assets/{chunk-SVP7TREG-BV-zPslF.js → chunk-SVP7TREG-Bu_yYb-V.js} +1 -1
  204. package/out/web/assets/{chunk-TEH6E4GO-B2RGEohZ.js → chunk-TEH6E4GO-G4Cdlyrt.js} +1 -1
  205. package/out/web/assets/classDiagram-DTDB5LWJ-RnC2mYO4.js +1 -0
  206. package/out/web/assets/classDiagram-v2-JRS7N3AN-RnC2mYO4.js +1 -0
  207. package/out/web/assets/config-CnvCChUM.js +2 -0
  208. package/out/web/assets/config-DM6oIVO3.css +1 -0
  209. package/out/web/assets/core-DsKkQ7ig.js +1 -0
  210. package/out/web/assets/{cynefin-OW5HDTMX-Cas4csHM.js → cynefin-OW5HDTMX-CnzQfOGp.js} +1 -1
  211. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-Bbpj66Y3.js → cynefinDiagram-5FMLGOSQ-DVtQnfpm.js} +1 -1
  212. package/out/web/assets/{dagre-3AP2YEHR-B0-2RDEd.js → dagre-3AP2YEHR-DG76_k5_.js} +1 -1
  213. package/out/web/assets/{diagram-S7CK7UJ4-BdzEjUyH.js → diagram-S7CK7UJ4-CaouUoze.js} +1 -1
  214. package/out/web/assets/{diagram-UQ7AKVKN-CJDwyPxF.js → diagram-UQ7AKVKN-B4bEc26B.js} +1 -1
  215. package/out/web/assets/{diagram-VSXAHHWV-BxaG1r_B.js → diagram-VSXAHHWV-BJ86Ov2Y.js} +1 -1
  216. package/out/web/assets/{diagram-VX7I27RA-BMmUqm8f.js → diagram-VX7I27RA-7hH5IPYf.js} +1 -1
  217. package/out/web/assets/{diagram-Z3DM3KII-CFtd03eE.js → diagram-Z3DM3KII-CCM4clo8.js} +1 -1
  218. package/out/web/assets/dist-js-BkYNznHX.js +1 -0
  219. package/out/web/assets/{ebnfDiagram-PWID7BFC-DwAen1hO.js → ebnfDiagram-PWID7BFC-Da7WAM3k.js} +1 -1
  220. package/out/web/assets/{erDiagram-SSCWMZ5O-BCZSHACj.js → erDiagram-SSCWMZ5O-CzzmnZWT.js} +1 -1
  221. package/out/web/assets/{event-ChBVpRy0.js → event-Cdt7Byyj.js} +1 -1
  222. package/out/web/assets/{eventmodeling-NTZA5JFV-XHStE4E_.js → eventmodeling-NTZA5JFV-xcfsA0XD.js} +1 -1
  223. package/out/web/assets/flowDiagram-A5DVABFB-JdtH1673.js +1 -0
  224. package/out/web/assets/{ganttDiagram-EL5Y4UJY-BAEu1Cql.js → ganttDiagram-EL5Y4UJY-B3ClFGmL.js} +1 -1
  225. package/out/web/assets/{gitGraph-4MIJSDKK-r32Vyper.js → gitGraph-4MIJSDKK-BZ_y_cDU.js} +1 -1
  226. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-z9o9qt6V.js → gitGraphDiagram-WWUBYQGX-drhRSYm3.js} +1 -1
  227. package/out/web/assets/index-BZ9xgmMP.css +1 -0
  228. package/out/web/assets/index-Ca8cCS4h.js +168 -0
  229. package/out/web/assets/{info-A6RAGUB7-BvLaC2cw.js → info-A6RAGUB7-B7I3K0F1.js} +1 -1
  230. package/out/web/assets/{infoDiagram-RXCK75RN-Be6WAXXw.js → infoDiagram-RXCK75RN-deX_1MPh.js} +1 -1
  231. package/out/web/assets/{ishikawaDiagram-5VMMS53U-DZjoHJHC.js → ishikawaDiagram-5VMMS53U-s38yo28y.js} +1 -1
  232. package/out/web/assets/{journeyDiagram-EYS64GPL-CtVkjkGm.js → journeyDiagram-EYS64GPL-CZn2kB8b.js} +1 -1
  233. package/out/web/assets/jsx-runtime-DN4NhQUK.js +1 -0
  234. package/out/web/assets/{kanban-definition-3QL26DDD-CW5FMv1w.js → kanban-definition-3QL26DDD-DodjgySL.js} +1 -1
  235. package/out/web/assets/markdown-CMhYIcWB.js +39 -0
  236. package/out/web/assets/{mermaid-parser.core-aUgCqsZ5.js → mermaid-parser.core-Whu2rlJC.js} +3 -3
  237. package/out/web/assets/{mermaid.core-D57sEiSL.js → mermaid.core-CKyPo8VH.js} +4 -4
  238. package/out/web/assets/{mindmap-definition-FBJOCRG2-DAmSpM3a.js → mindmap-definition-FBJOCRG2-260WBDxZ.js} +1 -1
  239. package/out/web/assets/modelManager-CUaGWMWu.js +2 -0
  240. package/out/web/assets/{oauthManager-9jxicDWJ.js → oauthManager-DlNhXJr6.js} +1 -1
  241. package/out/web/assets/{packet-AYTQ26CC-DJ27iIoJ.js → packet-AYTQ26CC-CEeIRKRG.js} +1 -1
  242. package/out/web/assets/{pegDiagram-XKGWAZYB-Bj3ii-gS.js → pegDiagram-XKGWAZYB-BYadI-KO.js} +1 -1
  243. package/out/web/assets/{pie-WAS4IAKB-DG5mCUbc.js → pie-WAS4IAKB-D8uf4dh5.js} +1 -1
  244. package/out/web/assets/{pieDiagram-E7YTZNPT-BrJLPnDA.js → pieDiagram-E7YTZNPT-CjSDy4hO.js} +1 -1
  245. package/out/web/assets/{quadrantDiagram-AXDQQJYC-3x_TmF3j.js → quadrantDiagram-AXDQQJYC-B8D3gU5d.js} +1 -1
  246. package/out/web/assets/{radar-RG4KPBEZ-CrXaTTuV.js → radar-RG4KPBEZ-DZJxnokY.js} +1 -1
  247. package/out/web/assets/{railroad-74A4TZTK-BpqMH9BE.js → railroad-74A4TZTK-DBysUiUi.js} +1 -1
  248. package/out/web/assets/{railroad-abnf-HS5TGJTU-DHI0uLlV.js → railroad-abnf-HS5TGJTU-CHWxoJYg.js} +1 -1
  249. package/out/web/assets/{railroad-ebnf-LZEXJU2U-CQCb316y.js → railroad-ebnf-LZEXJU2U-DDv7X6bY.js} +1 -1
  250. package/out/web/assets/{railroad-peg-WCYAUIDC-Ph1giKkH.js → railroad-peg-WCYAUIDC-DpfcYYNJ.js} +1 -1
  251. package/out/web/assets/{railroadDiagram-O6MQD6OU-BRq6wIxx.js → railroadDiagram-O6MQD6OU-CYtyv0t3.js} +1 -1
  252. package/out/web/assets/{requirementDiagram-EFPCY7ZU-DIJj6G1g.js → requirementDiagram-EFPCY7ZU-D2X-Ewik.js} +1 -1
  253. package/out/web/assets/{sankeyDiagram-P5KCCOFB-DZajSryq.js → sankeyDiagram-P5KCCOFB-Bn2x6C4Y.js} +1 -1
  254. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-DxJCWyZS.js → sequenceDiagram-WJ2MYXX4-BvFLKtPd.js} +1 -1
  255. package/out/web/assets/{stateDiagram-HBIQ2CUA-k6voi8Vf.js → stateDiagram-HBIQ2CUA-Bx0GWeaY.js} +1 -1
  256. package/out/web/assets/stateDiagram-v2-4QOOHH4V--hol1yg_.js +1 -0
  257. package/out/web/assets/{swimlanes-XN3QIQJK-igYhTjXB.js → swimlanes-XN3QIQJK-CCma0T7L.js} +1 -1
  258. package/out/web/assets/swimlanesDiagram-VK2B7HYN-BkohoVsU.js +8 -0
  259. package/out/web/assets/{timeline-definition-24CTP7MA-Dke2p93p.js → timeline-definition-24CTP7MA-iCRhhJ7C.js} +1 -1
  260. package/out/web/assets/{treeView-Q6P3EWNA-hNELnXUs.js → treeView-Q6P3EWNA-DZ-y08qp.js} +1 -1
  261. package/out/web/assets/{treemap-WGGIJYW6-D2B6wDsx.js → treemap-WGGIJYW6-De0CxzMx.js} +1 -1
  262. package/out/web/assets/{vennDiagram-4TSXK5OY-CDkW7rUP.js → vennDiagram-4TSXK5OY-w_7aZ6xe.js} +1 -1
  263. package/out/web/assets/{wardley-WFR3VGLG-BECdXETi.js → wardley-WFR3VGLG-DT0y0xJ4.js} +1 -1
  264. package/out/web/assets/{wardleyDiagram-VM6X3IG4-B5BycpQ4.js → wardleyDiagram-VM6X3IG4-DAdXELbQ.js} +1 -1
  265. package/out/web/assets/window-BJCVtyET.js +1 -0
  266. package/out/web/assets/{xychartDiagram-S5SC5T6Z-CtJS3b--.js → xychartDiagram-S5SC5T6Z-IZIXf3Vo.js} +1 -1
  267. package/out/web/index-web.html +3 -2
  268. package/out/web/index.html +3 -2
  269. package/package.json +71 -22
  270. package/shared-modules/.github/workflows/ci.yml +120 -0
  271. package/shared-modules/.github/workflows/update.yaml +28 -0
  272. package/slash-commands.json +7 -2
  273. package/src/bin/auto-build.js +66 -0
  274. package/src/bin/desktop.js +113 -0
  275. package/src/bin/desktop.test.js +113 -0
  276. package/src/bin/index.js +18 -25
  277. package/src/bin/index.test.js +89 -0
  278. package/src/bin/measure-effects.mjs +53 -0
  279. package/src/bin/package.test.js +28 -0
  280. package/src/bin/release-notes.test.js +84 -0
  281. package/src/bin/styles.test.js +20 -0
  282. package/src/bin/web.js +5 -0
  283. package/src/bin/web.test.js +39 -0
  284. package/src/renderer/index.html +0 -2
  285. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  286. package/src/renderer/shared/agentProgress.ts +33 -0
  287. package/src/renderer/shared/components/AgentActivityDrawer.tsx +3 -2
  288. package/src/renderer/shared/components/AgentActivityTable.tsx +1 -1
  289. package/src/renderer/shared/components/ApprovalCard.test.tsx +24 -0
  290. package/src/renderer/shared/components/ApprovalCard.tsx +10 -3
  291. package/src/renderer/shared/components/ArtifactPreviewPanel.tsx +88 -37
  292. package/src/renderer/shared/components/AuthGate.tsx +75 -39
  293. package/src/renderer/shared/components/BackgroundTerminals.tsx +181 -0
  294. package/src/renderer/shared/components/ChatCodeBlock.tsx +8 -4
  295. package/src/renderer/shared/components/ChatMessageItem.test.tsx +68 -0
  296. package/src/renderer/shared/components/ChatMessageItem.tsx +36 -16
  297. package/src/renderer/shared/components/ChatPanel.test.tsx +21 -0
  298. package/src/renderer/shared/components/ChatPanel.tsx +294 -272
  299. package/src/renderer/shared/components/ChunkErrorBoundary.tsx +4 -102
  300. package/src/renderer/shared/components/CliSessionsView.test.tsx +125 -0
  301. package/src/renderer/shared/components/CliSessionsView.tsx +61 -13
  302. package/src/renderer/shared/components/CodeReviewPage.tsx +111 -8
  303. package/src/renderer/shared/components/CollapsiblePasteInput.tsx +90 -0
  304. package/src/renderer/shared/components/ContextCompactionStatus.tsx +17 -0
  305. package/src/renderer/shared/components/DashboardSidebar.tsx +15 -128
  306. package/src/renderer/shared/components/DesktopTitlebar.tsx +45 -19
  307. package/src/renderer/shared/components/ErrorNotice.tsx +29 -0
  308. package/src/renderer/shared/components/FormattedMessage.tsx +11 -0
  309. package/src/renderer/shared/components/GeminiLiveOverlay.css +86 -0
  310. package/src/renderer/shared/components/GeminiLiveOverlay.test.tsx +24 -0
  311. package/src/renderer/shared/components/GeminiLiveOverlay.tsx +85 -101
  312. package/src/renderer/shared/components/GlideSelect.css +138 -0
  313. package/src/renderer/shared/components/GlideSelect.d.ts +31 -0
  314. package/src/renderer/shared/components/GlideSelect.jsx +266 -0
  315. package/src/renderer/shared/components/ImageSearchCard.tsx +46 -56
  316. package/src/renderer/shared/components/ImageStudioPanel.tsx +9 -8
  317. package/src/renderer/shared/components/LibraryItemCard.tsx +72 -0
  318. package/src/renderer/shared/components/LibraryPage.tsx +165 -0
  319. package/src/renderer/shared/components/LibraryPreview.tsx +59 -0
  320. package/src/renderer/shared/components/McpServersView.tsx +27 -8
  321. package/src/renderer/shared/components/McpToolAllowlist.tsx +2 -4
  322. package/src/renderer/shared/components/McpToolResults.tsx +99 -0
  323. package/src/renderer/shared/components/MintDashboard.tsx +308 -770
  324. package/src/renderer/shared/components/ModSessionControls.tsx +8 -0
  325. package/src/renderer/shared/components/ModUi.test.tsx +65 -0
  326. package/src/renderer/shared/components/ModUi.tsx +26 -0
  327. package/src/renderer/shared/components/ModelSelectorPopover.tsx +14 -7
  328. package/src/renderer/shared/components/ModsPanel.tsx +156 -0
  329. package/src/renderer/shared/components/ProgressActivity.tsx +17 -0
  330. package/src/renderer/shared/components/ReviewHistory.tsx +42 -0
  331. package/src/renderer/shared/components/RewindModal.tsx +5 -12
  332. package/src/renderer/shared/components/RunSummaryDashboard.tsx +6 -12
  333. package/src/renderer/shared/components/ScheduledTasksView.tsx +7 -6
  334. package/src/renderer/shared/components/SelectField.test.tsx +27 -0
  335. package/src/renderer/shared/components/SelectField.tsx +38 -0
  336. package/src/renderer/shared/components/SessionSources.tsx +48 -0
  337. package/src/renderer/shared/components/SessionSourcesPopover.tsx +61 -0
  338. package/src/renderer/shared/components/Settings/AgentsTab.tsx +7 -6
  339. package/src/renderer/shared/components/Settings/AudioTab.tsx +8 -7
  340. package/src/renderer/shared/components/Settings/AutomationTab.tsx +2 -2
  341. package/src/renderer/shared/components/Settings/GeneralTab.tsx +33 -32
  342. package/src/renderer/shared/components/Settings/MemoryTab.test.tsx +37 -0
  343. package/src/renderer/shared/components/Settings/MemoryTab.tsx +71 -31
  344. package/src/renderer/shared/components/Settings/NotificationsTab.test.tsx +15 -0
  345. package/src/renderer/shared/components/Settings/NotificationsTab.tsx +84 -0
  346. package/src/renderer/shared/components/Settings/ThemeTab.tsx +19 -11
  347. package/src/renderer/shared/components/SlashChoicePanel.tsx +88 -0
  348. package/src/renderer/shared/components/SlashSuggestions.test.tsx +33 -0
  349. package/src/renderer/shared/components/SlashSuggestions.tsx +38 -10
  350. package/src/renderer/shared/components/SourcesBlock.tsx +12 -20
  351. package/src/renderer/shared/components/ToolSurfacePage.test.tsx +32 -0
  352. package/src/renderer/shared/components/ToolSurfacePage.tsx +59 -11
  353. package/src/renderer/shared/components/ToolsPanelToggle.tsx +13 -0
  354. package/src/renderer/shared/components/VeoStudioPanel.tsx +9 -8
  355. package/src/renderer/shared/components/ViewedImages.tsx +36 -0
  356. package/src/renderer/shared/components/VirtualChatHistory.tsx +82 -0
  357. package/src/renderer/shared/components/VoicePill.css +85 -0
  358. package/src/renderer/shared/components/VoicePill.d.ts +12 -0
  359. package/src/renderer/shared/components/VoicePill.jsx +190 -0
  360. package/src/renderer/shared/components/VoicePill.test.tsx +22 -0
  361. package/src/renderer/shared/components/WorkspaceContextPopover.tsx +20 -15
  362. package/src/renderer/shared/components/WorkspacePaneResizer.tsx +92 -0
  363. package/src/renderer/shared/components/WorkspacePreviewActions.test.tsx +44 -0
  364. package/src/renderer/shared/components/WorkspacePreviewActions.tsx +19 -0
  365. package/src/renderer/shared/components/WorkspaceSelector.tsx +13 -8
  366. package/src/renderer/shared/components/background-terminals.css +292 -0
  367. package/src/renderer/shared/components/ui/README.md +52 -0
  368. package/src/renderer/shared/components/ui/button.tsx +37 -0
  369. package/src/renderer/shared/components/ui/demo.tsx +26 -0
  370. package/src/renderer/shared/components/ui/error-recovery.css +35 -0
  371. package/src/renderer/shared/components/ui/error-recovery.tsx +35 -0
  372. package/src/renderer/shared/components/ui/voice-powered-orb.css +6 -0
  373. package/src/renderer/shared/components/ui/voice-powered-orb.tsx +374 -0
  374. package/src/renderer/shared/components/workspace-selector.css +248 -0
  375. package/src/renderer/shared/constants/config.ts +30 -1
  376. package/src/renderer/shared/constants/slashCommands.ts +1 -1
  377. package/src/renderer/shared/conversation/lifecycle.test.ts +362 -0
  378. package/src/renderer/shared/conversation/lifecycle.ts +418 -0
  379. package/src/renderer/shared/conversation/projectNavigation.test.ts +105 -0
  380. package/src/renderer/shared/conversation/projectNavigation.ts +39 -0
  381. package/src/renderer/shared/conversation/sourcesTransport.ts +27 -0
  382. package/src/renderer/shared/conversation/syncView.test.ts +1 -34
  383. package/src/renderer/shared/conversation/syncView.ts +0 -37
  384. package/src/renderer/shared/conversation/transport.test.ts +163 -0
  385. package/src/renderer/shared/conversation/transport.ts +159 -0
  386. package/src/renderer/shared/conversation/useConversationCoordinator.ts +30 -72
  387. package/src/renderer/shared/css/agent-observability.css +20 -21
  388. package/src/renderer/shared/css/auth-gate.css +112 -13
  389. package/src/renderer/shared/css/code-review.css +33 -0
  390. package/src/renderer/shared/css/code-sessions.css +27 -0
  391. package/src/renderer/shared/css/collapsed-paste.css +35 -0
  392. package/src/renderer/shared/css/effects.css +15 -0
  393. package/src/renderer/shared/css/error-notice.css +60 -0
  394. package/src/renderer/shared/css/library.css +476 -0
  395. package/src/renderer/shared/css/management-views.css +138 -0
  396. package/src/renderer/shared/css/mcp-results.css +11 -0
  397. package/src/renderer/shared/css/model-selector.css +163 -105
  398. package/src/renderer/shared/css/mods.css +77 -0
  399. package/src/renderer/shared/css/notification-notice.css +28 -0
  400. package/src/renderer/shared/css/session-sources-popover.css +9 -0
  401. package/src/renderer/shared/css/session-sources.css +23 -0
  402. package/src/renderer/shared/css/settings/memory.css +154 -0
  403. package/src/renderer/shared/css/settings/notifications.css +11 -0
  404. package/src/renderer/shared/css/settings/theme.css +5 -0
  405. package/src/renderer/shared/css/slash-choice.css +95 -0
  406. package/src/renderer/shared/css/slash-suggestions.css +68 -0
  407. package/src/renderer/shared/css/sources-block.css +91 -0
  408. package/src/renderer/shared/css/thinking-status.css +46 -0
  409. package/src/renderer/shared/css/tools-panel.css +7 -0
  410. package/src/renderer/shared/css/ui-tailwind.css +19 -0
  411. package/src/renderer/shared/css/viewed-images.css +15 -0
  412. package/src/renderer/shared/fonts.css +46 -0
  413. package/src/renderer/shared/fonts.ts +3 -12
  414. package/src/renderer/shared/lib/utils.ts +6 -0
  415. package/src/renderer/shared/library/controller.test.ts +61 -0
  416. package/src/renderer/shared/library/controller.ts +57 -0
  417. package/src/renderer/shared/library/transport.test.ts +26 -0
  418. package/src/renderer/shared/library/transport.ts +60 -0
  419. package/src/renderer/shared/library/useLibraryFile.ts +38 -0
  420. package/src/renderer/shared/mods.ts +58 -0
  421. package/src/renderer/shared/platform.test.ts +57 -1
  422. package/src/renderer/shared/platform.ts +46 -1
  423. package/src/renderer/shared/theme/themeManager.test.ts +43 -0
  424. package/src/renderer/shared/theme/themeManager.ts +17 -4
  425. package/src/renderer/shared/types.ts +56 -2
  426. package/src/renderer/shared/utils/agentActivity.test.ts +75 -0
  427. package/src/renderer/shared/utils/agentActivity.ts +42 -3
  428. package/src/renderer/shared/utils/approval.tsx +7 -0
  429. package/src/renderer/shared/utils/artifactBrowser.test.ts +80 -0
  430. package/src/renderer/shared/utils/artifactBrowser.ts +41 -0
  431. package/src/renderer/shared/utils/contextCompaction.test.tsx +48 -0
  432. package/src/renderer/shared/utils/contextCompaction.ts +21 -0
  433. package/src/renderer/shared/utils/effectVisibility.ts +9 -0
  434. package/src/renderer/shared/utils/markdown.tsx +3 -3
  435. package/src/renderer/shared/utils/mcpResults.test.tsx +91 -0
  436. package/src/renderer/shared/utils/modViewport.ts +7 -0
  437. package/src/renderer/shared/utils/nativeRecordingSession.test.ts +61 -0
  438. package/src/renderer/shared/utils/nativeRecordingSession.ts +46 -0
  439. package/src/renderer/shared/utils/notificationSettings.test.ts +14 -0
  440. package/src/renderer/shared/utils/notificationSettings.ts +23 -0
  441. package/src/renderer/shared/utils/notifications.test.ts +51 -0
  442. package/src/renderer/shared/utils/notifications.ts +54 -0
  443. package/src/renderer/shared/utils/pastedText.test.ts +20 -0
  444. package/src/renderer/shared/utils/pastedText.ts +13 -0
  445. package/src/renderer/shared/utils/progressCache.test.ts +32 -0
  446. package/src/renderer/shared/utils/sessionSources.test.ts +31 -0
  447. package/src/renderer/shared/utils/sessionSources.ts +34 -0
  448. package/src/renderer/shared/utils/slashCommandProcessor.ts +1 -1
  449. package/src/renderer/shared/utils/sourceAttachments.ts +28 -0
  450. package/src/renderer/shared/utils/speech.ts +51 -9
  451. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +22 -0
  452. package/src/renderer/shared/utils/syntaxHighlight.tsx +26 -0
  453. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +49 -5
  454. package/src/renderer/shared/utils/useLatestCallback.ts +9 -0
  455. package/src/renderer/shared/utils/useNativeVoiceInput.ts +80 -72
  456. package/src/renderer/shared/utils/useSessionSources.ts +33 -0
  457. package/src/renderer/shared/utils/viewedImages.ts +26 -0
  458. package/src/renderer/shared/utils/workspacePane.test.ts +33 -0
  459. package/src/renderer/shared/utils/workspacePane.ts +20 -0
  460. package/src/renderer/shared/utils/workspacePreview.test.ts +24 -0
  461. package/src/renderer/shared/utils/workspacePreview.ts +16 -0
  462. package/src/renderer/shared/utils/workspaceSnapshot.test.ts +49 -0
  463. package/src/renderer/shared/utils/workspaceSnapshot.ts +26 -0
  464. package/src/renderer/shared/utils/workspaces.ts +10 -0
  465. package/src/renderer/shared/workspaceUndo.ts +88 -0
  466. package/src/renderer/src/App.tsx +9 -1
  467. package/src/renderer/src/components/LiveTranslateControls.tsx +65 -0
  468. package/src/renderer/src/components/PicturesLibrary.tsx +2 -288
  469. package/src/renderer/src/components/ScreenPicker.css +67 -30
  470. package/src/renderer/src/components/ScreenPicker.tsx +201 -60
  471. package/src/renderer/src/components/SettingsWindow.tsx +10 -5
  472. package/src/renderer/src/components/WorkspacePanel.tsx +222 -32
  473. package/src/renderer/src/components/browser-shell.css +1 -1
  474. package/src/renderer/src/components/browser-suggestions-popup.css +1 -1
  475. package/src/renderer/src/components/liveTranslateFrames.test.ts +26 -5
  476. package/src/renderer/src/components/liveTranslateFrames.ts +18 -7
  477. package/src/renderer/src/components/liveTranslateLanguage.ts +6 -0
  478. package/src/renderer/src/css/app-shell.css +52 -137
  479. package/src/renderer/src/css/chat.css +21 -495
  480. package/src/renderer/src/css/desktop-tools.css +199 -1
  481. package/src/renderer/src/css/misc.css +0 -52
  482. package/src/renderer/src/css/pictures.css +2 -415
  483. package/src/renderer/src/css/settings/base.css +50 -47
  484. package/src/renderer/src/css/sidebar-workspace.css +151 -94
  485. package/src/renderer/src/css/tool-surfaces.css +65 -9
  486. package/src/renderer/src/css/widget.css +1 -1
  487. package/src/renderer/src/env.d.ts +1 -9
  488. package/src/renderer/src/index.css +1 -1
  489. package/src/renderer/src/main.tsx +2 -1
  490. package/src/renderer/src/proactiveSuggestions.ts +29 -0
  491. package/src/renderer/src/tauri.ts +122 -330
  492. package/src/renderer/src/voiceInput.ts +2 -1
  493. package/src/renderer/src-web/components/PicturesLibrary.tsx +2 -285
  494. package/src/renderer/src-web/components/PullToRefresh.tsx +1 -1
  495. package/src/renderer/src-web/components/SettingsWindow.tsx +10 -5
  496. package/src/renderer/src-web/css/app-shell.css +24 -121
  497. package/src/renderer/src-web/css/chat.css +21 -495
  498. package/src/renderer/src-web/css/misc.css +0 -52
  499. package/src/renderer/src-web/css/mobile.css +15 -14
  500. package/src/renderer/src-web/css/pictures.css +2 -462
  501. package/src/renderer/src-web/css/settings/base.css +50 -47
  502. package/src/renderer/src-web/css/sidebar-workspace.css +9 -95
  503. package/src/renderer/src-web/css/widget.css +1 -1
  504. package/src/renderer/src-web/env.d.ts +1 -12
  505. package/src/renderer/src-web/index.css +1 -1
  506. package/src/renderer/src-web/main.tsx +2 -1
  507. package/src/renderer/src-web/proactiveSuggestions.ts +3 -0
  508. package/src/renderer/src-web/tauri.ts +129 -354
  509. package/src/renderer/tests/backgroundTerminals.browser.html +81 -0
  510. package/src/renderer/tests/chatPanelFixture.ts +23 -0
  511. package/src/renderer/tests/chatPerformance.browser.html +71 -0
  512. package/src/renderer/tests/cliSessions.browser.html +82 -0
  513. package/src/renderer/tests/collapsedPaste.browser.html +70 -0
  514. package/src/renderer/tests/composerMenus.browser.html +116 -0
  515. package/src/renderer/tests/contextCompaction.browser.html +17 -0
  516. package/src/renderer/tests/conversationLifecycle.browser.html +70 -0
  517. package/src/renderer/tests/effectsPerformance.browser.html +13 -0
  518. package/src/renderer/tests/errorRecovery.browser.html +21 -0
  519. package/src/renderer/tests/glideSelect.browser.html +71 -0
  520. package/src/renderer/tests/glideSelect.preview.html +27 -0
  521. package/src/renderer/tests/library.browser.html +1 -0
  522. package/src/renderer/tests/library.browser.tsx +78 -0
  523. package/src/renderer/tests/liveOrb.browser.html +49 -0
  524. package/src/renderer/tests/liveVoice.browser.html +29 -0
  525. package/src/renderer/tests/modInput.browser.html +22 -0
  526. package/src/renderer/tests/sessionSources.browser.html +1 -0
  527. package/src/renderer/tests/sessionSources.browser.tsx +107 -0
  528. package/src/renderer/tests/skills.browser.html +24 -0
  529. package/src/renderer/tests/toolsPanel.browser.html +1 -0
  530. package/src/renderer/tests/toolsPanel.browser.tsx +113 -0
  531. package/src/renderer/tests/viewedImages.browser.html +1 -0
  532. package/src/renderer/tests/viewedImages.browser.tsx +93 -0
  533. package/src/renderer/tests/voicePill.browser.html +80 -0
  534. package/src/renderer/tests/voicePill.preview.html +34 -0
  535. package/src/renderer/tests/webSources.browser.html +1 -0
  536. package/src/renderer/tests/webSources.browser.tsx +98 -0
  537. package/src/renderer/tests/workspaceUndo.browser.html +92 -0
  538. package/src-tauri/Cargo.toml +40 -0
  539. package/src-tauri/build.rs +3 -0
  540. package/src-tauri/src/app/lifecycle.rs +277 -0
  541. package/src-tauri/src/app/mod.rs +4 -0
  542. package/src-tauri/src/app/shortcuts.rs +40 -0
  543. package/src-tauri/src/app/tray.rs +52 -0
  544. package/src-tauri/src/background/events.rs +70 -0
  545. package/src-tauri/src/background/headless.rs +230 -0
  546. package/src-tauri/src/background/mod.rs +4 -0
  547. package/src-tauri/src/background/proactive.rs +262 -0
  548. package/src-tauri/src/commands/approvals.rs +20 -0
  549. package/src-tauri/src/commands/auth.rs +127 -0
  550. package/src-tauri/src/commands/conversation.rs +642 -0
  551. package/src-tauri/src/commands/integrations.rs +157 -0
  552. package/src-tauri/src/commands/media.rs +179 -0
  553. package/src-tauri/src/commands/mod.rs +11 -0
  554. package/src-tauri/src/commands/mods.rs +6 -0
  555. package/src-tauri/src/commands/settings.rs +141 -0
  556. package/src-tauri/src/commands/voice.rs +153 -0
  557. package/src-tauri/src/commands/workspace.rs +501 -0
  558. package/src-tauri/src/lib.rs +10 -0
  559. package/src-tauri/src/main.rs +3 -0
  560. package/src-tauri/src/native/actions.rs +206 -0
  561. package/src-tauri/src/native/browser.rs +252 -0
  562. package/src-tauri/src/native/capture.rs +695 -0
  563. package/src-tauri/src/native/discord_rpc.rs +99 -0
  564. package/src-tauri/src/native/integrations.rs +126 -0
  565. package/src-tauri/src/native/mod.rs +12 -0
  566. package/src-tauri/src/native/notifications.rs +51 -0
  567. package/src-tauri/src/native/plugins.rs +14 -0
  568. package/src-tauri/src/native/system.rs +250 -0
  569. package/src-tauri/src/native/terminal.rs +201 -0
  570. package/src-tauri/src/native/updater.rs +148 -0
  571. package/src-tauri/src/native/windows.rs +143 -0
  572. package/tsconfig.json +2 -0
  573. package/vite.config.ts +4 -1
  574. package/vite.config.web.ts +4 -0
  575. package/.cargo/config.toml +0 -13
  576. package/.claude/settings.local.json +0 -182
  577. package/.codex +0 -0
  578. package/CLAUDE.md +0 -102
  579. package/ROADMAP.md +0 -30
  580. package/mint-presentation-demo.mp4 +0 -0
  581. package/out/web/Live2DCubismCore.js +0 -9
  582. package/out/web/assets/CliSessionsView-BxUVWEEL.js +0 -1
  583. package/out/web/assets/CliSessionsView-CBDTXuQv.css +0 -1
  584. package/out/web/assets/CodeReviewPage-DkMgfcos.css +0 -1
  585. package/out/web/assets/CodeReviewPage-jKlmLJwT.js +0 -3
  586. package/out/web/assets/CommandPalette-DnwPA5hy.js +0 -1
  587. package/out/web/assets/ImageStudioPanel-BR1XFwhR.js +0 -1
  588. package/out/web/assets/LinkedFoldersView-bi-CWQ-I.js +0 -1
  589. package/out/web/assets/McpRegistryPicker-D5tEFnyq.js +0 -1
  590. package/out/web/assets/McpServersView-BGG1dhFM.js +0 -1
  591. package/out/web/assets/MintDashboard-BIglDEWX.js +0 -20
  592. package/out/web/assets/MintDashboard-DSMywSgs.css +0 -1
  593. package/out/web/assets/ModelPanel-CbJ1PFoZ.js +0 -1
  594. package/out/web/assets/PicturesLibrary-CQP0fLP1.css +0 -1
  595. package/out/web/assets/PicturesLibrary-DoYN7T1p.js +0 -1
  596. package/out/web/assets/PluginsView-CU1HWXGk.js +0 -1
  597. package/out/web/assets/ScheduledTasksView-Bnl36y2N.js +0 -1
  598. package/out/web/assets/SettingsWindow-DhWLAn9T.css +0 -1
  599. package/out/web/assets/SettingsWindow-Dw8L01pw.js +0 -2
  600. package/out/web/assets/SkillsView-cnl8aY5p.js +0 -2
  601. package/out/web/assets/ToolSurfacePage-CgsQw6l5.css +0 -1
  602. package/out/web/assets/ToolSurfacePage-Cz2VJAPZ.js +0 -37
  603. package/out/web/assets/VeoStudioPanel-D4JnERWR.js +0 -4
  604. package/out/web/assets/chunk-CKBBP62Z-cIrXq6jN.js +0 -2
  605. package/out/web/assets/classDiagram-DTDB5LWJ-Dq0smRWk.js +0 -1
  606. package/out/web/assets/classDiagram-v2-JRS7N3AN-Dq0smRWk.js +0 -1
  607. package/out/web/assets/config-C-ZFtipU.css +0 -1
  608. package/out/web/assets/config-eEDngJXO.js +0 -1
  609. package/out/web/assets/core-hQ2VA9Ti.js +0 -1
  610. package/out/web/assets/dist-js-Bf2bhoku.js +0 -1
  611. package/out/web/assets/flowDiagram-A5DVABFB-BLg7nEcd.js +0 -1
  612. package/out/web/assets/index-Btsf4L8M.css +0 -1
  613. package/out/web/assets/index-D8D9MeRA.js +0 -11
  614. package/out/web/assets/lucide-react-Ip3L6Jtn.js +0 -1
  615. package/out/web/assets/markdown-BSeeUbJH.js +0 -38
  616. package/out/web/assets/modelManager-tMVmJVAW.js +0 -2
  617. package/out/web/assets/prompt-latin-400-normal-BQ9zjSN8.woff2 +0 -0
  618. package/out/web/assets/prompt-latin-400-normal-CxU4ec_r.woff +0 -0
  619. package/out/web/assets/prompt-latin-500-normal-B1edBDQ9.woff +0 -0
  620. package/out/web/assets/prompt-latin-500-normal-CxzxEHZc.woff2 +0 -0
  621. package/out/web/assets/prompt-latin-600-normal-CSDDLorv.woff +0 -0
  622. package/out/web/assets/prompt-latin-600-normal-hKZWXsc1.woff2 +0 -0
  623. package/out/web/assets/prompt-latin-700-normal-BfbftDSq.woff +0 -0
  624. package/out/web/assets/prompt-latin-700-normal-I2gc831J.woff2 +0 -0
  625. package/out/web/assets/prompt-thai-400-normal-BrkKv8cO.woff2 +0 -0
  626. package/out/web/assets/prompt-thai-400-normal-DJypnfr3.woff +0 -0
  627. package/out/web/assets/prompt-thai-500-normal-C18pDUoL.woff2 +0 -0
  628. package/out/web/assets/prompt-thai-500-normal-D7YNqUJD.woff +0 -0
  629. package/out/web/assets/prompt-thai-600-normal-CYPViXds.woff +0 -0
  630. package/out/web/assets/prompt-thai-600-normal-MrdfU7zR.woff2 +0 -0
  631. package/out/web/assets/prompt-thai-700-normal-Cb5ilR-c.woff +0 -0
  632. package/out/web/assets/prompt-thai-700-normal-Cg4aQ0Nn.woff2 +0 -0
  633. package/out/web/assets/stateDiagram-v2-4QOOHH4V-DeEc0lGZ.js +0 -1
  634. package/out/web/assets/swimlanesDiagram-VK2B7HYN-DpVik9v7.js +0 -8
  635. package/out/web/assets/window-5qYXKoW7.js +0 -1
  636. package/skills-lock.json +0 -77
  637. package/src/renderer/public/Live2DCubismCore.js +0 -9
  638. package/src/renderer/src/companionWidget.ts +0 -95
  639. package/src/renderer/src/components/Live2DStage.tsx +0 -412
  640. package/src/renderer/src/components/ModelPanel.tsx +0 -155
  641. package/src/renderer/src/css/model.css +0 -628
  642. package/src/renderer/src-web/companionWidget.ts +0 -37
  643. package/src/renderer/src-web/components/ModelPanel.tsx +0 -12
  644. package/src/renderer/src-web/css/model.css +0 -628
@@ -11,6 +11,17 @@ use std::sync::Arc;
11
11
  use std::time::Instant;
12
12
  use thiserror::Error;
13
13
 
14
+ pub mod approvals;
15
+ mod mcp_review;
16
+ #[cfg(test)]
17
+ mod mod_authoring_regressions;
18
+ mod progress_clock;
19
+ mod repeated_failures;
20
+ #[cfg(all(test, unix))]
21
+ mod review_regressions;
22
+ pub(crate) mod run_control;
23
+ use repeated_failures::RepeatedFailures;
24
+
14
25
  use crate::chat::{
15
26
  ChatMessage, ChatRole, ChatStreamEvent, ContentBlock, send_chat_with_fallback,
16
27
  stream_chat_events_with_fallback, stream_chat_with_fallback,
@@ -76,6 +87,7 @@ where
76
87
  /// Owns one persisted turn, including its cross-process queue lease. Dropping a
77
88
  /// cancelled task marks its prompt interrupted instead of leaving it running.
78
89
  struct TurnLease {
90
+ chat_id: String,
79
91
  memory: MemoryStore,
80
92
  id: i64,
81
93
  heartbeat: tokio::task::JoinHandle<()>,
@@ -86,6 +98,7 @@ struct TurnLease {
86
98
  impl TurnLease {
87
99
  async fn start(memory: &MemoryStore, chat_id: &str, text: &str) -> Result<Self, MemoryError> {
88
100
  let id = memory.start_turn(chat_id, text)?;
101
+ crate::companion::queued(chat_id, id, text);
89
102
  let _ = TURN_START_LISTENER.try_with(|listener| {
90
103
  if listener.chat_id == chat_id {
91
104
  (listener.callback)(id);
@@ -101,6 +114,7 @@ impl TurnLease {
101
114
  }
102
115
  });
103
116
  let mut lease = Self {
117
+ chat_id: chat_id.to_owned(),
104
118
  memory: memory.clone(),
105
119
  id,
106
120
  heartbeat,
@@ -109,7 +123,15 @@ impl TurnLease {
109
123
  };
110
124
  loop {
111
125
  match memory.claim_turn(chat_id, id) {
112
- Ok(true) => return Ok(lease),
126
+ Ok(true) => {
127
+ crate::companion::update(
128
+ chat_id,
129
+ id,
130
+ mint_companion_protocol::Status::Thinking,
131
+ None,
132
+ );
133
+ return Ok(lease);
134
+ }
113
135
  Ok(false) => tokio::time::sleep(std::time::Duration::from_millis(250)).await,
114
136
  Err(error) => {
115
137
  lease.fail();
@@ -143,6 +165,12 @@ impl TurnLease {
143
165
  self.memory
144
166
  .finish_turn(self.id, summary, provider, model, fallback_provider)?;
145
167
  self.finished = true;
168
+ crate::companion::update(
169
+ &self.chat_id,
170
+ self.id,
171
+ mint_companion_protocol::Status::Completed,
172
+ Some(summary.to_owned()),
173
+ );
146
174
  Ok(())
147
175
  }
148
176
 
@@ -158,6 +186,24 @@ impl TurnLease {
158
186
  fn fail(&mut self) {
159
187
  self.persist_activity();
160
188
  let _ = self.memory.end_turn(self.id, "failed");
189
+ crate::companion::update(
190
+ &self.chat_id,
191
+ self.id,
192
+ mint_companion_protocol::Status::Failed,
193
+ None,
194
+ );
195
+ self.finished = true;
196
+ }
197
+
198
+ fn interrupt(&mut self) {
199
+ self.persist_activity();
200
+ let _ = self.memory.end_turn(self.id, "interrupted");
201
+ crate::companion::update(
202
+ &self.chat_id,
203
+ self.id,
204
+ mint_companion_protocol::Status::Interrupted,
205
+ None,
206
+ );
161
207
  self.finished = true;
162
208
  }
163
209
  }
@@ -168,6 +214,12 @@ impl Drop for TurnLease {
168
214
  if !self.finished {
169
215
  self.persist_activity();
170
216
  let _ = self.memory.end_turn(self.id, "interrupted");
217
+ crate::companion::update(
218
+ &self.chat_id,
219
+ self.id,
220
+ mint_companion_protocol::Status::Interrupted,
221
+ None,
222
+ );
171
223
  }
172
224
  }
173
225
  }
@@ -237,6 +289,13 @@ pub async fn orchestrate_chat(
237
289
  request: &ChatRequest,
238
290
  ) -> Result<ChatResponse, OrchestrationError> {
239
291
  let memory = MemoryStore::open_default()?;
292
+ if let Some(workspace) = request
293
+ .workspace_path
294
+ .as_deref()
295
+ .filter(|path| !path.trim().is_empty())
296
+ {
297
+ memory.set_chat_session_workspace(&request_chat_id(request), Some(workspace.trim()))?;
298
+ }
240
299
  let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
241
300
  let mut resolved_request = request.clone();
242
301
  resolved_request.message = resolve_github_links(&request.message, config).await;
@@ -249,11 +308,6 @@ pub async fn orchestrate_chat(
249
308
  }
250
309
  };
251
310
  turn.complete(&response)?;
252
- if let Some(ref ws) = request.workspace_path {
253
- if !ws.trim().is_empty() {
254
- let _ = memory.set_chat_session_workspace(&request_chat_id(request), Some(ws.trim()));
255
- }
256
- }
257
311
  spawn_auto_memory_update(
258
312
  config.clone(),
259
313
  request.message.clone(),
@@ -279,11 +333,28 @@ where
279
333
  F: FnMut(String),
280
334
  {
281
335
  let memory = MemoryStore::open_default()?;
336
+ if let Some(workspace) = request
337
+ .workspace_path
338
+ .as_deref()
339
+ .filter(|path| !path.trim().is_empty())
340
+ {
341
+ memory.set_chat_session_workspace(&request_chat_id(request), Some(workspace.trim()))?;
342
+ }
282
343
  let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
283
344
  let mut resolved_request = request.clone();
284
345
  resolved_request.message = resolve_github_links(&request.message, config).await;
285
346
  let enriched = enrich_request(config, &memory, &resolved_request)?;
286
- let response = match stream_chat(config, &enriched, on_chunk).await {
347
+ let response = match {
348
+ let mut on_chunk = on_chunk;
349
+ let chat = turn.chat_id.clone();
350
+ let id = turn.id;
351
+ stream_chat(config, &enriched, move |chunk: String| {
352
+ crate::companion::chunk(&chat, id, &chunk);
353
+ on_chunk(chunk);
354
+ })
355
+ }
356
+ .await
357
+ {
287
358
  Ok(response) => response,
288
359
  Err(error) => {
289
360
  turn.fail();
@@ -291,11 +362,6 @@ where
291
362
  }
292
363
  };
293
364
  turn.complete(&response)?;
294
- if let Some(ref ws) = request.workspace_path {
295
- if !ws.trim().is_empty() {
296
- let _ = memory.set_chat_session_workspace(&request_chat_id(request), Some(ws.trim()));
297
- }
298
- }
299
365
  spawn_auto_memory_update(
300
366
  config.clone(),
301
367
  request.message.clone(),
@@ -317,6 +383,13 @@ pub async fn orchestrate_chat_with_fallback(
317
383
  request: &ChatRequest,
318
384
  ) -> Result<(ChatResponse, Option<String>), OrchestrationError> {
319
385
  let memory = MemoryStore::open_default()?;
386
+ if let Some(workspace) = request
387
+ .workspace_path
388
+ .as_deref()
389
+ .filter(|path| !path.trim().is_empty())
390
+ {
391
+ memory.set_chat_session_workspace(&request_chat_id(request), Some(workspace.trim()))?;
392
+ }
320
393
  let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
321
394
  let mut resolved_request = request.clone();
322
395
  resolved_request.message = resolve_github_links(&request.message, config).await;
@@ -354,11 +427,28 @@ where
354
427
  F: FnMut(String),
355
428
  {
356
429
  let memory = MemoryStore::open_default()?;
430
+ if let Some(workspace) = request
431
+ .workspace_path
432
+ .as_deref()
433
+ .filter(|path| !path.trim().is_empty())
434
+ {
435
+ memory.set_chat_session_workspace(&request_chat_id(request), Some(workspace.trim()))?;
436
+ }
357
437
  let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
358
438
  let mut resolved_request = request.clone();
359
439
  resolved_request.message = resolve_github_links(&request.message, config).await;
360
440
  let enriched = enrich_request(config, &memory, &resolved_request)?;
361
- let (response, fallback) = match stream_chat_with_fallback(config, &enriched, on_chunk).await {
441
+ let (response, fallback) = match {
442
+ let mut on_chunk = on_chunk;
443
+ let chat = turn.chat_id.clone();
444
+ let id = turn.id;
445
+ stream_chat_with_fallback(config, &enriched, move |chunk: String| {
446
+ crate::companion::chunk(&chat, id, &chunk);
447
+ on_chunk(chunk);
448
+ })
449
+ }
450
+ .await
451
+ {
362
452
  Ok(result) => result,
363
453
  Err(error) => {
364
454
  turn.fail();
@@ -366,11 +456,6 @@ where
366
456
  }
367
457
  };
368
458
  turn.complete(&response)?;
369
- if let Some(ref ws) = request.workspace_path {
370
- if !ws.trim().is_empty() {
371
- let _ = memory.set_chat_session_workspace(&request_chat_id(request), Some(ws.trim()));
372
- }
373
- }
374
459
  spawn_auto_memory_update(
375
460
  config.clone(),
376
461
  request.message.clone(),
@@ -387,6 +472,28 @@ where
387
472
  Ok((response, fallback))
388
473
  }
389
474
 
475
+ // Full skill reads must reach the model exactly; other tools keep bounded output.
476
+ fn model_tool_result(action: &str, result: &str) -> String {
477
+ if action.starts_with("browser_")
478
+ || (action == "read_file" && result.starts_with("[Loaded skill: "))
479
+ {
480
+ result.to_owned()
481
+ } else {
482
+ truncate(result)
483
+ }
484
+ }
485
+
486
+ fn tool_progress_input(root: &Path, decision: &AgentDecision) -> Value {
487
+ let mut input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
488
+ if decision.action == "read_file"
489
+ && let Some(skill) = crate::skills::skill_for_read_path(root, &decision.input.path)
490
+ && let Some(object) = input.as_object_mut()
491
+ {
492
+ object.insert("skill_name".into(), Value::String(skill.name));
493
+ }
494
+ input
495
+ }
496
+
390
497
  /// Truncates `text` to at most `max_chars` characters (not bytes, so this
391
498
  /// never splits a multi-byte UTF-8 character) for injection into a "recent
392
499
  /// context" summary. Cheap no-op for the common case of a short message.
@@ -522,6 +629,8 @@ use workspace_helpers::*;
522
629
  /// tasks earlier, but 24 turned out too tight for legitimate multi-file
523
630
  /// work, cutting it off mid-task — raised to 40 as a middle ground.
524
631
  const MAX_STEPS: usize = 40;
632
+ // A tool gets its own correction budget, renewed after successful execution.
633
+ const MAX_TOOL_ARGUMENT_RETRIES: usize = 5;
525
634
  const MAX_OBSERVATION_BYTES: usize = 16_000;
526
635
  /// Compact `native_messages` once a step's reported token usage crosses this
527
636
  /// fraction of `MintConfig::context_window_tokens`. Every step resends the whole
@@ -554,6 +663,12 @@ pub struct AskUserOption {
554
663
 
555
664
  #[derive(Debug, Clone, Serialize, Deserialize)]
556
665
  pub enum AgentApproval {
666
+ /// Persistent agent instructions need a decision for each concrete diff.
667
+ SkillWrite {
668
+ path: String,
669
+ content: String,
670
+ diff: String,
671
+ },
557
672
  WriteFile {
558
673
  path: String,
559
674
  content: String,
@@ -621,6 +736,31 @@ pub const MCP_ALLOW_ALL_SENTINEL: &str = "__mcp_allow_all__";
621
736
  #[derive(Debug, Clone, Serialize, Deserialize)]
622
737
  #[serde(tag = "type", content = "data")]
623
738
  pub enum AgentProgress {
739
+ ToolDiscovery {
740
+ server: String,
741
+ tool: String,
742
+ message: String,
743
+ },
744
+ DeviceState {
745
+ #[serde(rename = "callId")]
746
+ call_id: String,
747
+ server: String,
748
+ tool: String,
749
+ update: crate::mcp::device::DeviceUpdate,
750
+ },
751
+ ToolProgress {
752
+ #[serde(rename = "callId")]
753
+ call_id: String,
754
+ server: String,
755
+ tool: String,
756
+ update: crate::mcp::McpProgress,
757
+ },
758
+ ToolArtifacts {
759
+ #[serde(rename = "callId")]
760
+ call_id: String,
761
+ artifacts: Vec<crate::integrations::mcp_result::McpArtifact>,
762
+ warnings: Vec<String>,
763
+ },
624
764
  Thinking {
625
765
  elapsed_secs: u64,
626
766
  #[serde(skip_serializing_if = "Option::is_none")]
@@ -685,6 +825,13 @@ pub enum AgentProgress {
685
825
  #[serde(default, skip_serializing_if = "Option::is_none")]
686
826
  elapsed_ms: Option<u64>,
687
827
  },
828
+ /// Lifecycle of native context summarization, including actionable failures.
829
+ ContextCompaction {
830
+ #[serde(default, skip_serializing_if = "Option::is_none")]
831
+ subagent: Option<String>,
832
+ status: String,
833
+ message: String,
834
+ },
688
835
  /// Emitted while retrying a step after every configured provider was
689
836
  /// unreachable (`ChatError::NetworkUnavailable`) — distinct from
690
837
  /// `Thinking` because there's nothing to wait *on* here except the
@@ -694,6 +841,8 @@ pub enum AgentProgress {
694
841
  max_attempts: usize,
695
842
  },
696
843
  ToolStart {
844
+ #[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
845
+ call_id: Option<String>,
697
846
  action: String,
698
847
  input: Value,
699
848
  /// Name of the subagent this tool call happened inside, if any —
@@ -706,6 +855,12 @@ pub enum AgentProgress {
706
855
  subagent: Option<String>,
707
856
  },
708
857
  ToolEnd {
858
+ #[serde(default, alias = "elapsedMs", skip_serializing_if = "Option::is_none")]
859
+ elapsed_ms: Option<u64>,
860
+ #[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
861
+ call_id: Option<String>,
862
+ #[serde(default, skip_serializing_if = "Option::is_none")]
863
+ status: Option<crate::integrations::mcp_result::ToolStatus>,
709
864
  action: String,
710
865
  input: Value,
711
866
  result: String,
@@ -762,7 +917,32 @@ pub fn compact_agent_progress(progress: &[AgentProgress]) -> Vec<AgentProgress>
762
917
  let mut interrupted: std::collections::HashMap<String, (String, Option<u64>)> =
763
918
  std::collections::HashMap::new();
764
919
 
765
- for event in progress {
920
+ let latest_progress: std::collections::HashMap<(u8, &str), usize> = progress
921
+ .iter()
922
+ .enumerate()
923
+ .filter_map(|(i, event)| match event {
924
+ AgentProgress::ToolProgress { call_id, .. } => Some(((0, call_id.as_str()), i)),
925
+ AgentProgress::DeviceState {
926
+ call_id, update, ..
927
+ } if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
928
+ Some(((1, call_id.as_str()), i))
929
+ }
930
+ _ => None,
931
+ })
932
+ .collect();
933
+ for (index, event) in progress.iter().enumerate() {
934
+ let key = match event {
935
+ AgentProgress::ToolProgress { call_id, .. } => Some((0, call_id.as_str())),
936
+ AgentProgress::DeviceState {
937
+ call_id, update, ..
938
+ } if !matches!(update.state, crate::mcp::device::DevicePhase::Accepted) => {
939
+ Some((1, call_id.as_str()))
940
+ }
941
+ _ => None,
942
+ };
943
+ if key.is_some_and(|key| latest_progress.get(&key) != Some(&index)) {
944
+ continue;
945
+ }
766
946
  match event {
767
947
  AgentProgress::ThinkingDelta {
768
948
  id,
@@ -1092,7 +1272,59 @@ fn deserialize_agent_input<'de, D>(deserializer: D) -> Result<AgentInput, D::Err
1092
1272
  where
1093
1273
  D: serde::Deserializer<'de>,
1094
1274
  {
1095
- Ok(Option::<AgentInput>::deserialize(deserializer)?.unwrap_or_default())
1275
+ let value = Option::<Value>::deserialize(deserializer)?;
1276
+ value
1277
+ .map(decode_agent_input)
1278
+ .transpose()
1279
+ .map_err(serde::de::Error::custom)
1280
+ .map(|input| input.unwrap_or_default())
1281
+ }
1282
+
1283
+ // Some providers encode the structured patch twice. Decode only that field;
1284
+ // retain strict typing for all other arguments and never infer a target file.
1285
+ fn decode_agent_input(mut value: Value) -> Result<AgentInput, String> {
1286
+ let outer_path = value.get("path").and_then(Value::as_str).map(str::to_owned);
1287
+ if let Some(patch) = value.get_mut("patch").filter(|patch| !patch.is_null()) {
1288
+ if let Value::String(encoded) = patch {
1289
+ *patch = serde_json::from_str(encoded).map_err(|_| {
1290
+ "patch must contain a valid JSON object with path and hunks".to_owned()
1291
+ })?;
1292
+ }
1293
+ let object = patch
1294
+ .as_object_mut()
1295
+ .ok_or_else(|| "patch must be an object with path and hunks".to_owned())?;
1296
+ if !object.contains_key("path") {
1297
+ if let Some(path) = &outer_path {
1298
+ object.insert("path".into(), Value::String(path.clone()));
1299
+ }
1300
+ }
1301
+ let path = object
1302
+ .get("path")
1303
+ .and_then(Value::as_str)
1304
+ .filter(|path| !path.trim().is_empty())
1305
+ .ok_or_else(|| {
1306
+ "patch.path is required; supply the target file explicitly".to_owned()
1307
+ })?;
1308
+ if outer_path
1309
+ .as_deref()
1310
+ .is_some_and(|outer| !outer.is_empty() && outer != path)
1311
+ {
1312
+ return Err("path and patch.path must name the same target file".into());
1313
+ }
1314
+ serde_json::from_value::<AgentPatch>(patch.clone())
1315
+ .map_err(|_| "patch requires a string path and hunks containing string oldText/newText and optional boolean replaceAll".to_owned())?;
1316
+ }
1317
+ serde_json::from_value(value).map_err(|error| {
1318
+ let message = error.to_string();
1319
+ if message.chars().count() > 400 {
1320
+ format!(
1321
+ "{}… (argument value omitted)",
1322
+ message.chars().take(400).collect::<String>()
1323
+ )
1324
+ } else {
1325
+ message
1326
+ }
1327
+ })
1096
1328
  }
1097
1329
 
1098
1330
  fn resolve_agent_config(
@@ -1248,6 +1480,7 @@ async fn stream_chat_with_network_retry(
1248
1480
  Ok((response, fallback)) => {
1249
1481
  if !stream_json_prompt
1250
1482
  && allow_final_stream
1483
+ && !provider_truncated_response(response.stop_reason.as_deref())
1251
1484
  && response
1252
1485
  .tool_calls
1253
1486
  .as_ref()
@@ -1456,38 +1689,83 @@ where
1456
1689
  Progress: FnMut(AgentProgress) + Send + 'a,
1457
1690
  Chunk: FnMut(String) + Send + 'a,
1458
1691
  {
1459
- Box::pin(async move {
1460
- let started_at = Instant::now();
1461
- let root = root.canonicalize().map_err(|e| {
1462
- OrchestrationError::Agent(format!(
1463
- "unable to resolve workspace root {}: {}",
1464
- root.display(),
1465
- e
1466
- ))
1467
- })?;
1468
- let chat_id = chat_id
1692
+ Box::pin(run_control::scope(
1693
+ chat_id
1469
1694
  .map(str::trim)
1470
- .filter(|chat_id| !chat_id.is_empty())
1471
- .unwrap_or(DEFAULT_CONVERSATION_ID);
1472
- // `root` is always resolved to a real, canonicalized directory above
1473
- // (never optional here, unlike the plain-chat path's `ChatRequest.
1474
- // workspace_path`), so an agent-mode conversation is always scoped to
1475
- // *some* workspace — it just never falls back to the plain global
1476
- // "cli" bucket the way plain chat can. Idempotent on chat ids that
1477
- // are already scoped or aren't "cli" at all (see `scoped_chat_id`).
1478
- let chat_id = crate::agent::memory::scoped_chat_id(chat_id, Some(&root.to_string_lossy()));
1479
- let chat_id = chat_id.as_str();
1480
- let memory = MemoryStore::open_default()?;
1481
- let mut turn = TurnLease::start(&memory, chat_id, task).await?;
1482
- let record_activity = Arc::clone(&turn.activity);
1483
- let mut progress = move |event: AgentProgress| {
1484
- if let Ok(mut lock) = record_activity.lock() {
1485
- lock.push(event.clone());
1486
- }
1487
- progress(event);
1488
- };
1489
- let resolved_task = resolve_github_links(task, config).await;
1490
- let agent_result = crate::browser::run_session(async {
1695
+ .filter(|id| !id.is_empty())
1696
+ .unwrap_or(DEFAULT_CONVERSATION_ID),
1697
+ async move {
1698
+ run_control::check()?;
1699
+ let started_at = Instant::now();
1700
+ let root = root.canonicalize().map_err(|e| {
1701
+ OrchestrationError::Agent(format!(
1702
+ "unable to resolve workspace root {}: {}",
1703
+ root.display(),
1704
+ e
1705
+ ))
1706
+ })?;
1707
+ let chat_id = chat_id
1708
+ .map(str::trim)
1709
+ .filter(|chat_id| !chat_id.is_empty())
1710
+ .unwrap_or(DEFAULT_CONVERSATION_ID);
1711
+ // `root` is always resolved to a real, canonicalized directory above
1712
+ // (never optional here, unlike the plain-chat path's `ChatRequest.
1713
+ // workspace_path`), so an agent-mode conversation is always scoped to
1714
+ // *some* workspace — it just never falls back to the plain global
1715
+ // "cli" bucket the way plain chat can. Idempotent on chat ids that
1716
+ // are already scoped or aren't "cli" at all (see `scoped_chat_id`).
1717
+ let chat_id =
1718
+ crate::agent::memory::scoped_chat_id(chat_id, Some(&root.to_string_lossy()));
1719
+ let chat_id = chat_id.as_str();
1720
+ let memory = MemoryStore::open_default()?;
1721
+ // Agent requests use a real workspace even when the provider fails.
1722
+ // Persist it before the first turn so all session lists group it correctly.
1723
+ memory.set_chat_session_workspace(chat_id, Some(root.to_string_lossy().as_ref()))?;
1724
+ let mut turn = run_control::run(TurnLease::start(&memory, chat_id, task)).await?;
1725
+ let record_activity = Arc::clone(&turn.activity);
1726
+ let progress_chat_id = turn.chat_id.clone();
1727
+ let companion_turn_id = turn.id;
1728
+ let chunk_chat_id = turn.chat_id.clone();
1729
+ let mut on_chunk = move |chunk: String| {
1730
+ crate::companion::chunk(&chunk_chat_id, companion_turn_id, &chunk);
1731
+ on_chunk(chunk);
1732
+ };
1733
+ let approval_chat_id = turn.chat_id.clone();
1734
+ let mut approve = move |request: &AgentApproval| {
1735
+ crate::companion::update(
1736
+ &approval_chat_id,
1737
+ companion_turn_id,
1738
+ mint_companion_protocol::Status::Waiting,
1739
+ None,
1740
+ );
1741
+ let result = approve(request);
1742
+ crate::companion::update(
1743
+ &approval_chat_id,
1744
+ companion_turn_id,
1745
+ mint_companion_protocol::Status::Thinking,
1746
+ None,
1747
+ );
1748
+ result
1749
+ };
1750
+ let mut progress_clock = progress_clock::ProgressClock::default();
1751
+ let mut progress = move |mut event: AgentProgress| {
1752
+ progress_clock.stamp(&mut event, Instant::now());
1753
+ crate::companion::progress(&progress_chat_id, companion_turn_id, &event);
1754
+ if let Ok(mut lock) = record_activity.lock() {
1755
+ lock.push(event.clone());
1756
+ }
1757
+ progress(event);
1758
+ };
1759
+ let _ = crate::mods::event(
1760
+ &root,
1761
+ chat_id,
1762
+ "turn_start",
1763
+ serde_json::json!({"turnId":companion_turn_id}),
1764
+ None,
1765
+ );
1766
+ let mut mod_usage = crate::mods::UsageTracker::new(&root, chat_id, companion_turn_id);
1767
+ let resolved_task = resolve_github_links(task, config).await;
1768
+ let agent_result = crate::browser::run_session(async {
1491
1769
  // Subagent runs use a synthetic `{parent_chat_id}::subagent::{name}` chat id
1492
1770
  // (see the `dispatch_subagent` arm in `execute_tool`) so their own memory
1493
1771
  // interaction doesn't leak into the parent conversation's history. That
@@ -1497,7 +1775,7 @@ where
1497
1775
  let allow_subagent_dispatch = !chat_id.contains("::subagent::");
1498
1776
  let skills =
1499
1777
  crate::skills::learned_skills_context(Some(&root), Some(chat_id)).unwrap_or_default();
1500
- let mut observation = initial_observation(&resolved_task, &root, &skills);
1778
+ let mut observation = initial_observation(&resolved_task, &root, "See the current skill catalog in system instructions.");
1501
1779
  let mut pending_image = image_data_uri;
1502
1780
  let mut pending_audio = audio_data_uri;
1503
1781
  let mut pending_video = video_data_uri;
@@ -1555,6 +1833,8 @@ where
1555
1833
  let mut final_fallback = None;
1556
1834
  let mut final_fallback_reason = None;
1557
1835
  let mut action_counts = BTreeMap::<String, usize>::new();
1836
+ let mut invalid_tool_calls = ToolArgumentRetries::default();
1837
+ let mut repeated_failures = RepeatedFailures::default();
1558
1838
  let mut streamed_finish_summary = String::new();
1559
1839
  let mut streamed_direct_text = String::new();
1560
1840
  // Track the most recent step (if any) that successfully modified a file
@@ -1604,12 +1884,17 @@ where
1604
1884
  let mut last_input_tokens: u64 = 0;
1605
1885
  let mut turn_generated_tokens: u64 = 0;
1606
1886
  let mut executed_tools: Vec<ToolExecutionRecord> = Vec::new();
1887
+ let mut mod_truncation_retries = 0;
1888
+ let mut mcp_review = mcp_review::McpReview::default();
1607
1889
  let mut files_modified: std::collections::BTreeSet<String> =
1608
1890
  std::collections::BTreeSet::new();
1609
1891
  let mut files_created: std::collections::BTreeSet<String> =
1610
1892
  std::collections::BTreeSet::new();
1611
1893
 
1612
1894
  'steps: for step in 1..=MAX_STEPS {
1895
+ crate::mods::set_turn_policy(&root, chat_id, plan_mode, pinned_mcp_server);
1896
+ let _ = execute_mod_requests(&root, chat_id, config, plan_mode, &mut approve, &mut progress, None).await;
1897
+ if run_control::cancelled() { turn.interrupt(); return Err(OrchestrationError::Agent("Agent turn canceled by user".into())); }
1613
1898
  let thought_id = if let Some(name) = chat_id.split("::subagent::").nth(1) {
1614
1899
  format!("subagent-{name}-step-{step}")
1615
1900
  } else {
@@ -1631,6 +1916,16 @@ where
1631
1916
  });
1632
1917
 
1633
1918
  let mut active_system_prompt = system_prompt.clone();
1919
+ let mcp_context = mcp_review.context();
1920
+ if !mcp_context.is_empty() { active_system_prompt.push_str(&format!("\n\n{mcp_context}")); }
1921
+ // Pin the catalog and only successfully read, unchanged skill bodies
1922
+ // to every request, including after JSON history rebuilds/compaction.
1923
+ let visible_history = if active_config.tool_calling_mode() == ToolCallingMode::Native { render_messages_as_text(&native_messages) } else { observation.clone() };
1924
+ let skill_context = crate::skills::learned_skills_context_with_history(Some(&root), Some(chat_id), &visible_history)
1925
+ .unwrap_or_default();
1926
+ if !skill_context.is_empty() {
1927
+ active_system_prompt.push_str(&format!("\n\n[Available skills and retained instructions]\n{skill_context}"));
1928
+ }
1634
1929
  if let Some(ref model_name) = active_model_name {
1635
1930
  active_system_prompt.push_str(&format!(
1636
1931
  "\n\n[Active Environment Context]\n\
@@ -1718,7 +2013,7 @@ where
1718
2013
  on_chunk(delta);
1719
2014
  }
1720
2015
  };
1721
- stream_chat_with_network_retry(
2016
+ run_control::run(stream_chat_with_network_retry(
1722
2017
  &active_config,
1723
2018
  &ChatRequest {
1724
2019
  message: String::new(),
@@ -1749,7 +2044,7 @@ where
1749
2044
  false,
1750
2045
  allow_final_stream,
1751
2046
  &mut emit_stream_chunk,
1752
- )
2047
+ ))
1753
2048
  .await?
1754
2049
  }
1755
2050
  } else {
@@ -1764,7 +2059,7 @@ where
1764
2059
  on_chunk(delta);
1765
2060
  }
1766
2061
  };
1767
- stream_chat_with_network_retry(
2062
+ run_control::run(stream_chat_with_network_retry(
1768
2063
  &active_config,
1769
2064
  &ChatRequest {
1770
2065
  message: observation.clone(),
@@ -1790,7 +2085,7 @@ where
1790
2085
  true,
1791
2086
  allow_final_stream,
1792
2087
  &mut emit_stream_chunk,
1793
- )
2088
+ ))
1794
2089
  .await?
1795
2090
  }
1796
2091
  };
@@ -1798,6 +2093,8 @@ where
1798
2093
  final_provider = response.provider.clone();
1799
2094
  final_model = response.model.clone();
1800
2095
  turn_total_tokens += response.total_tokens.unwrap_or(0) as u64;
2096
+ let usage_payload = mod_usage.record(&active_config, &response);
2097
+ let _ = crate::mods::event(&root, chat_id, "usage_update", usage_payload, None);
1801
2098
  turn_generated_tokens += response.output_tokens.unwrap_or(0) as u64;
1802
2099
  if let Some(input) = response.input_tokens {
1803
2100
  last_input_tokens = input as u64;
@@ -1824,35 +2121,53 @@ where
1824
2121
  }
1825
2122
  }
1826
2123
 
2124
+ // A provider-limited design draft is not a completed mod. Recover
2125
+ // only after successful mod preparation, before publication, and
2126
+ // within a per-turn budget. All subsequent tools keep their normal
2127
+ // plan-mode, capability and approval checks.
2128
+ if tool_mode == ToolCallingMode::Native
2129
+ && !plan_mode
2130
+ && !active_config.disabled_tools.iter().any(|tool| tool == "mod_publish")
2131
+ && response.tool_calls.as_ref().is_none_or(|calls| calls.is_empty())
2132
+ && provider_truncated_response(response.stop_reason.as_deref())
2133
+ && mod_truncation_retries < 2
2134
+ && executed_tools.iter().any(|tool| tool.success && matches!(tool.action.as_str(), "mod_help" | "mod_validate"))
2135
+ && !executed_tools.iter().any(|tool| tool.success && tool.action == "mod_publish")
2136
+ {
2137
+ mod_truncation_retries += 1;
2138
+ let instruction = "Your previous response reached the provider output-length limit before completing the request. Use the already loaded Mint Mods guide and choose reasonable defaults. If the user requested a mod, send the source through mod_validate and then mod_publish using their tool arguments, with at most one short progress sentence. Follow the existing permissions and approvals. If the request cannot be completed, call finish with a concise explanation. Otherwise answer the original question concisely. Avoid repeating the design discussion.";
2139
+ native_messages.push(ChatMessage {
2140
+ role: ChatRole::User,
2141
+ content: vec![ContentBlock::Text { text: instruction.into() }],
2142
+ });
2143
+ trajectory.push(format_trajectory_step(step, "", "mod_authoring_recovery", instruction));
2144
+ progress(AgentProgress::Thought {
2145
+ thought: "Mod preparation reached the provider output limit; continuing with the authoring tools.".into(),
2146
+ });
2147
+ continue 'steps;
2148
+ }
2149
+
1827
2150
  // `decisions` is normally a single `(call_id, AgentDecision)`, matching the
1828
2151
  // original one-action-per-step design. Native tool-calling can return
1829
2152
  // several tool calls in one model turn, in which case they're executed
1830
2153
  // sequentially and all their results are fed back before the next call —
1831
2154
  // `finish` never appears alongside real tool calls (see below), so this
1832
2155
  // never conflicts with the early-return finish handling.
2156
+ let mut step_tool_results: Vec<ToolResultEntry> = Vec::new();
1833
2157
  let decisions: Vec<(String, AgentDecision)> = if tool_mode == ToolCallingMode::Native {
1834
2158
  match response.tool_calls.clone() {
1835
- Some(calls) if !calls.is_empty() => calls
1836
- .into_iter()
1837
- .enumerate()
1838
- .map(|(index, call)| {
1839
- let thought = if index == 0 {
1840
- response.text.trim().to_string()
1841
- } else {
1842
- String::new()
1843
- };
1844
- let input: AgentInput =
1845
- serde_json::from_value(call.input).unwrap_or_default();
1846
- (
1847
- call.id,
1848
- AgentDecision {
1849
- thought,
1850
- action: call.name,
1851
- input,
1852
- },
1853
- )
1854
- })
1855
- .collect(),
2159
+ Some(calls) if !calls.is_empty() => {
2160
+ let (decisions, errors) = prepare_native_tool_calls(
2161
+ calls, response.text.trim(), &mut invalid_tool_calls,
2162
+ )?;
2163
+ for ToolResultEntry{action,input,text:error,..} in &errors {
2164
+ progress(AgentProgress::ToolStart { call_id: None, action: action.clone(), input: input.clone(), subagent: None });
2165
+ progress(AgentProgress::ToolEnd { elapsed_ms: None, call_id: None, status: None, action: action.clone(), input: input.clone(), result: error.clone(), subagent: None });
2166
+ trajectory.push(format_trajectory_step(step, "", action, error));
2167
+ }
2168
+ step_tool_results.extend(errors);
2169
+ decisions
2170
+ }
1856
2171
  // No tool calls means the model answered directly — treat exactly
1857
2172
  // like the JSON-prompt path's fallback for plain, non-JSON text
1858
2173
  // (see `parse_decision_or_finish`): finish with that text as the
@@ -1889,7 +2204,7 @@ where
1889
2204
  let mut decision = match parse_decision_or_finish(&response.text) {
1890
2205
  Ok(decision) => decision,
1891
2206
  Err(_) => {
1892
- let (repaired, _) = send_chat_with_fallback(
2207
+ let (repaired, _) = run_control::run(send_chat_with_fallback(
1893
2208
  &active_config,
1894
2209
  &ChatRequest {
1895
2210
  message: format!(
@@ -1913,7 +2228,7 @@ where
1913
2228
  temperature: active_config.temperature,
1914
2229
  ..Default::default()
1915
2230
  },
1916
- )
2231
+ ))
1917
2232
  .await?;
1918
2233
  parse_decision_or_finish(&repaired.text).map_err(|e| {
1919
2234
  OrchestrationError::Agent(format!(
@@ -1951,7 +2266,6 @@ where
1951
2266
  extended_emitted = true;
1952
2267
  }
1953
2268
 
1954
- let mut step_tool_results: Vec<(String, String, Value, String)> = Vec::new();
1955
2269
  // Screenshots (and any future binary/image tool result) are kept out of
1956
2270
  // `step_tool_results`'s plain-text `final_result` — that string is what
1957
2271
  // both the text `trajectory` and the JSON-prompt `observation` are built
@@ -1960,11 +2274,11 @@ where
1960
2274
  // data URI is stashed here by `call_id` instead, and re-attached as a
1961
2275
  // real `ContentBlock::Image` when building `native_messages` below, so
1962
2276
  // the model actually sees the pixels instead of a truncated text blob.
1963
- let mut step_images: std::collections::HashMap<String, String> =
2277
+ let mut step_images: std::collections::HashMap<String, Vec<String>> =
1964
2278
  std::collections::HashMap::new();
1965
2279
 
1966
2280
  if decisions_are_parallel_subagent_batch(&decisions) {
1967
- step_tool_results = run_parallel_subagent_batch(
2281
+ let completed = run_parallel_subagent_batch(
1968
2282
  &decisions,
1969
2283
  step,
1970
2284
  &root,
@@ -1976,8 +2290,10 @@ where
1976
2290
  &mut trajectory,
1977
2291
  )
1978
2292
  .await;
2293
+ invalid_tool_calls.observe_completed(&completed);
2294
+ step_tool_results.extend(completed);
1979
2295
  } else if decisions_are_parallel_read_only_batch(&decisions) {
1980
- step_tool_results = run_parallel_read_only_batch(
2296
+ let completed = run_parallel_read_only_batch(
1981
2297
  &decisions,
1982
2298
  step,
1983
2299
  &root,
@@ -1993,8 +2309,11 @@ where
1993
2309
  fast_mode,
1994
2310
  )
1995
2311
  .await;
2312
+ invalid_tool_calls.observe_completed(&completed);
2313
+ step_tool_results.extend(completed);
1996
2314
  } else {
1997
2315
  for (call_id, decision) in decisions {
2316
+ if run_control::cancelled() { turn.interrupt(); return Err(OrchestrationError::Agent("Agent turn canceled by user".into())); }
1998
2317
  let d_thought = decision.thought.trim();
1999
2318
  if !fast_mode && !d_thought.is_empty() {
2000
2319
  let already_extended = extended_emitted
@@ -2127,6 +2446,20 @@ where
2127
2446
  on_chunk(remainder.to_owned());
2128
2447
  }
2129
2448
 
2449
+ if config.auto_skill_writing && allow_subagent_dispatch && !run_control::cancelled() && looks_skill_worthy(step, &action_counts) {
2450
+ match auto_write_skill(config, task, &summary, &root, &skills, &mut approve).await {
2451
+ Ok(Some(message)) => {
2452
+ on_chunk(format!("\n\n{message}"));
2453
+ summary.push_str(&format!("\n\n{message}"));
2454
+ }
2455
+ Ok(None) => {}
2456
+ Err(error) => {
2457
+ let message = format!("Skill proposal was not saved: {error}");
2458
+ on_chunk(format!("\n\n{message}"));
2459
+ summary.push_str(&format!("\n\n{message}"));
2460
+ }
2461
+ }
2462
+ }
2130
2463
  memory.save_workspace_session(
2131
2464
  &root.to_string_lossy(),
2132
2465
  &summary,
@@ -2145,15 +2478,6 @@ where
2145
2478
  summary.clone(),
2146
2479
  turn.id,
2147
2480
  );
2148
- if config.auto_skill_writing && looks_skill_worthy(step, &action_counts) {
2149
- spawn_auto_skill_write(
2150
- config.clone(),
2151
- task.to_string(),
2152
- summary.clone(),
2153
- root.clone(),
2154
- skills.clone(),
2155
- );
2156
- }
2157
2481
  let run_summary = RunTelemetrySummary {
2158
2482
  run_id: format!("run-{}", chrono::Local::now().format("%Y%m%d%H%M%S")),
2159
2483
  task: task.to_string(),
@@ -2170,6 +2494,7 @@ where
2170
2494
  tool_timeline: executed_tools.clone(),
2171
2495
  retries_count: executed_tools.iter().filter(|t| t.retried).count(),
2172
2496
  };
2497
+ let _ = execute_mod_requests(&root, chat_id, config, plan_mode, &mut approve, &mut progress, None).await;
2173
2498
  progress(AgentProgress::RunCompleted {
2174
2499
  summary: run_summary,
2175
2500
  });
@@ -2194,6 +2519,27 @@ where
2194
2519
  });
2195
2520
  }
2196
2521
 
2522
+ if decision.action == "mcp_tool" && mcp_review.blocks_repeat(&decision.input.server,&decision.input.tool,&decision.input.arguments) {
2523
+ let message="Error: Automatic repeat blocked: this Device command's completion is unconfirmed. Read its status or wait for a new user instruction; do not send the command again this turn.".to_string();
2524
+ trajectory.push(format_trajectory_step(step,&decision.thought,&decision.action,&message));
2525
+ step_tool_results.push((call_id.clone(),decision.action.clone(),tool_progress_input(&root,&decision),message).into());
2526
+ continue;
2527
+ }
2528
+
2529
+ if decision.action == "mcp_tool" && !plan_mode
2530
+ && pinned_mcp_server.is_none_or(|p|p==decision.input.server)
2531
+ && let Some(preparation) = tools::plugins_mcp::prepare(
2532
+ &decision.input,config,chat_id,&mut mcp_review,&mut approve,&mut progress,
2533
+ ).await? {
2534
+ if preparation.status == crate::mcp_result::ToolStatus::Cancelled || run_control::cancelled() {
2535
+ progress(AgentProgress::Thought{thought:preparation.text.clone()});
2536
+ turn.interrupt();return Err(OrchestrationError::Agent(preparation.text));
2537
+ }
2538
+ trajectory.push(format_trajectory_step(step,&decision.thought,&decision.action,&preparation.text));
2539
+ step_tool_results.push(ToolResultEntry{call_id:call_id.clone(),action:decision.action.clone(),input:tool_progress_input(&root,&decision),text:preparation.text,status:preparation.status});
2540
+ continue;
2541
+ }
2542
+
2197
2543
  let action_key = action_fingerprint(&decision);
2198
2544
  let action_count = {
2199
2545
  let count = action_counts.entry(action_key).or_insert(0);
@@ -2205,6 +2551,10 @@ where
2205
2551
  // stays false for plan-mode/hook blocks and the duplicate-shell skip, since
2206
2552
  // those don't actually run anything and shouldn't count toward verification.
2207
2553
  let mut action_succeeded = false;
2554
+ let mut outcome_status = None;
2555
+ // Provider tool IDs may restart at call_0 on the next model step.
2556
+ // Activity and artifact IDs must identify this invocation across the chat.
2557
+ let activity_call_id = uuid::Uuid::new_v4().to_string();
2208
2558
  let target_file_existed = match decision.action.as_str() {
2209
2559
  "write_file" => {
2210
2560
  !decision.input.path.is_empty()
@@ -2303,8 +2653,8 @@ where
2303
2653
  )
2304
2654
  } else {
2305
2655
  let input_val =
2306
- serde_json::to_value(&decision.input).unwrap_or(Value::Null);
2307
- progress(AgentProgress::ToolStart {
2656
+ tool_progress_input(&root, &decision);
2657
+ progress(AgentProgress::ToolStart { call_id: Some(activity_call_id.clone()),
2308
2658
  action: decision.action.clone(),
2309
2659
  input: input_val.clone(),
2310
2660
  subagent: None,
@@ -2342,20 +2692,31 @@ where
2342
2692
  );
2343
2693
  }
2344
2694
 
2345
- let (tool_result, success) = match execute_tool(
2695
+ let (tool_result, success) = match execute_tool_outcome_reviewed(
2346
2696
  &root,
2347
2697
  config,
2348
2698
  &decision,
2349
2699
  chat_id,
2350
2700
  &mut approve,
2351
2701
  &mut progress,
2702
+ &activity_call_id,
2703
+ Some(&mcp_review),
2352
2704
  )
2353
2705
  .await
2354
2706
  {
2355
- Ok(result) => (result, true),
2707
+ Ok(outcome) => {
2708
+ if decision.action=="mcp_tool" {mcp_review.record_result(&decision.input.server,&decision.input.tool,&decision.input.arguments,&outcome);}
2709
+ if matches!(decision.action.as_str(),"mcp_tool"|"mcp_list_tools") { outcome_status = Some(outcome.status); }
2710
+ if !outcome.images.is_empty() { step_images.insert(call_id.clone(), outcome.images); }
2711
+ let success = outcome.status == crate::integrations::mcp_result::ToolStatus::Success;
2712
+ (outcome.text, success)
2713
+ },
2356
2714
  Err(error) => (format!("Error: {}", error), false),
2357
2715
  };
2358
2716
  action_succeeded = success;
2717
+ if success {
2718
+ invalid_tool_calls.succeeded(&decision.action);
2719
+ }
2359
2720
  let hook_messages = crate::hooks::run_post_tool_hooks(
2360
2721
  &hooks,
2361
2722
  &decision.action,
@@ -2381,9 +2742,17 @@ where
2381
2742
  }
2382
2743
  };
2383
2744
 
2384
- progress(AgentProgress::ToolEnd {
2745
+ if outcome_status.is_none() && matches!(decision.action.as_str(), "mcp_tool" | "mcp_list_tools") {
2746
+ outcome_status = Some(if action_succeeded {
2747
+ crate::integrations::mcp_result::ToolStatus::Success
2748
+ } else {
2749
+ crate::integrations::mcp_result::ToolStatus::Failed
2750
+ });
2751
+ }
2752
+ if run_control::cancelled() {outcome_status = Some(crate::mcp_result::ToolStatus::Cancelled);}
2753
+ progress(AgentProgress::ToolEnd { elapsed_ms: None, call_id: Some(activity_call_id), status: outcome_status,
2385
2754
  action: decision.action.clone(),
2386
- input: serde_json::to_value(&decision.input).unwrap_or(Value::Null),
2755
+ input: tool_progress_input(&root, &decision),
2387
2756
  result: result.clone(),
2388
2757
  subagent: None,
2389
2758
  });
@@ -2409,6 +2778,11 @@ where
2409
2778
  retried,
2410
2779
  });
2411
2780
 
2781
+ if outcome_status == Some(crate::integrations::mcp_result::ToolStatus::Cancelled) {
2782
+ turn.interrupt();
2783
+ return Err(OrchestrationError::Agent(result));
2784
+ }
2785
+
2412
2786
  if action_succeeded {
2413
2787
  match decision.action.as_str() {
2414
2788
  "apply_patch" | "write_file" | "note_write" => {
@@ -2452,7 +2826,7 @@ where
2452
2826
  | "video_waveform"
2453
2827
  | "view_image"
2454
2828
  ) {
2455
- step_images.insert(call_id.clone(), result.clone());
2829
+ step_images.insert(call_id.clone(), vec![result.clone()]);
2456
2830
  match decision.action.as_str() {
2457
2831
  "video_filmstrip" => "[Filmstrip generated — see attached image: \
2458
2832
  sampled frames across the video timeline]"
@@ -2464,7 +2838,7 @@ where
2464
2838
  _ => "[Screenshot captured — see attached image]".to_string(),
2465
2839
  }
2466
2840
  } else {
2467
- if decision.action.starts_with("browser_") {result.clone()} else {truncate(&result)}
2841
+ model_tool_result(&decision.action, &result)
2468
2842
  };
2469
2843
  if matches!(
2470
2844
  decision.action.as_str(),
@@ -2505,54 +2879,42 @@ where
2505
2879
  &final_result,
2506
2880
  ));
2507
2881
 
2508
- step_tool_results.push((
2509
- call_id,
2510
- decision.action.clone(),
2511
- serde_json::to_value(&decision.input).unwrap_or(Value::Null),
2512
- final_result,
2513
- ));
2882
+ step_tool_results.push(ToolResultEntry {
2883
+ call_id, action:decision.action.clone(), input:tool_progress_input(&root,&decision),
2884
+ text:final_result, status:outcome_status.unwrap_or(if success {crate::mcp_result::ToolStatus::Success}else{crate::mcp_result::ToolStatus::Failed}),
2885
+ });
2514
2886
  } // end `for (call_id, decision) in decisions`
2515
2887
  } // end `else` (sequential path)
2516
2888
 
2517
- if tool_mode == ToolCallingMode::Native {
2518
- let mut assistant_content: Vec<ContentBlock> = Vec::new();
2519
- let response_text = response.text.trim();
2520
- if !response_text.is_empty() {
2521
- assistant_content.push(ContentBlock::Text {
2522
- text: response_text.to_string(),
2523
- });
2524
- }
2525
- let mut tool_result_content: Vec<ContentBlock> = Vec::new();
2526
- for (call_id, action, input_value, final_result) in &step_tool_results {
2527
- assistant_content.push(ContentBlock::ToolUse {
2528
- id: call_id.clone(),
2529
- name: action.clone(),
2530
- input: input_value.clone(),
2531
- thought_signature: step_thought_signatures.get(call_id).cloned(),
2532
- });
2533
- tool_result_content.push(ContentBlock::ToolResult {
2534
- tool_use_id: call_id.clone(),
2535
- content: final_result.clone(),
2536
- is_error: false,
2537
- });
2538
- if let Some(data_uri) = step_images.get(call_id) {
2539
- tool_result_content.push(ContentBlock::Image {
2540
- data_uri: data_uri.clone(),
2541
- });
2889
+ if run_control::cancelled() { turn.interrupt(); return Err(OrchestrationError::Agent("Agent turn canceled by user".into())); }
2890
+
2891
+ // All interfaces and both native/JSON tool modes share this guard.
2892
+ // Inspect failures, not action counts: legitimate repeated reads
2893
+ // and successful edits must not be stopped as loops.
2894
+ match repeated_failures.observe(step, &mut step_tool_results) {
2895
+ Ok(warnings) => {
2896
+ for warning in warnings {
2897
+ progress(AgentProgress::Thought { thought: warning.clone() });
2898
+ trajectory.push(format!("Step {step} loop warning: {warning}"));
2542
2899
  }
2543
2900
  }
2544
- if !assistant_content.is_empty() {
2545
- native_messages.push(ChatMessage {
2546
- role: ChatRole::Assistant,
2547
- content: assistant_content,
2548
- });
2549
- }
2550
- if !tool_result_content.is_empty() {
2551
- native_messages.push(ChatMessage {
2552
- role: ChatRole::Tool,
2553
- content: tool_result_content,
2554
- });
2901
+ Err(error) => {
2902
+ progress(AgentProgress::Thought { thought: error.to_string() });
2903
+ return Err(error);
2555
2904
  }
2905
+ }
2906
+
2907
+ if tool_mode == ToolCallingMode::JsonPrompt && !step_images.is_empty() {
2908
+ pending_image = Some(step_images.values().flatten().cloned().collect::<Vec<_>>().join(" "));
2909
+ }
2910
+ if tool_mode == ToolCallingMode::Native {
2911
+ append_native_tool_results(
2912
+ &mut native_messages,
2913
+ response.text.trim(),
2914
+ &step_tool_results,
2915
+ &step_thought_signatures,
2916
+ &step_images,
2917
+ );
2556
2918
 
2557
2919
  if let Some(total_tokens) = response.total_tokens {
2558
2920
  let window = active_config.context_window_tokens();
@@ -2560,38 +2922,20 @@ where
2560
2922
  ((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 255.0) as u8,
2561
2923
  );
2562
2924
  if (total_tokens as f64) >= (window as f64) * COMPACTION_TRIGGER_RATIO {
2563
- match compact_native_messages(&active_config, &native_messages).await {
2564
- Ok(Some((compacted, fallback_provider))) => {
2565
- native_messages = compacted;
2566
- let fallback_note = fallback_provider
2567
- .as_deref()
2568
- .map(|p| {
2569
- format!(
2570
- " (summary generated via fallback provider \
2571
- \"{p}\" — the primary provider failed for this \
2572
- call)"
2573
- )
2574
- })
2575
- .unwrap_or_default();
2576
- progress(AgentProgress::Thought {
2577
- thought: format!(
2578
- "[Context] Compacted earlier steps to stay under the \
2579
- context window ({total_tokens}/{window} tokens before \
2580
- compaction){fallback_note}."
2581
- ),
2582
- });
2583
- }
2584
- // Nothing worth compacting yet (too little history) — routine, no warning.
2585
- Ok(None) => {}
2586
- Err(error) => {
2587
- progress(AgentProgress::Thought {
2588
- thought: format!(
2589
- "[Context] Context is approaching the model's window \
2590
- ({total_tokens}/{window} tokens) but compaction failed: \
2591
- {error}. Continuing without compacting this step."
2592
- ),
2593
- });
2594
- }
2925
+ let eligible = native_messages.len().saturating_sub(1) / 2 > COMPACTION_KEEP_RECENT_STEPS;
2926
+ if let Ok(Some((compacted, _))) = run_control::run(report_context_compaction(
2927
+ eligible,
2928
+ total_tokens,
2929
+ window,
2930
+ compact_native_messages(&active_config, &native_messages),
2931
+ &mut progress,
2932
+ )).await {
2933
+ native_messages = compacted;
2934
+ let payload = mod_usage.compacted();
2935
+ let _ = crate::mods::event(&root, chat_id, "context_compacted", payload, None);
2936
+ // Wait for actual usage from the next request, rather than
2937
+ // displaying the pre-compaction percentage as current.
2938
+ last_context_pct = None;
2595
2939
  }
2596
2940
  }
2597
2941
  }
@@ -2626,11 +2970,23 @@ where
2626
2970
  MAX_STEPS, crate::browser::session_last_state().await
2627
2971
  )))
2628
2972
  }).await;
2629
- if agent_result.is_err() {
2630
- turn.fail();
2631
- }
2632
- agent_result
2633
- })
2973
+ if agent_result.is_err() && !turn.finished {
2974
+ if run_control::cancelled() {
2975
+ turn.interrupt();
2976
+ } else {
2977
+ turn.fail();
2978
+ }
2979
+ }
2980
+ let mut mod_turn_end = mod_usage.finish();
2981
+ mod_turn_end["success"] = serde_json::json!(agent_result.is_ok());
2982
+ mod_turn_end["cancelled"] = serde_json::json!(run_control::cancelled());
2983
+ let _ = crate::mods::event(&root, chat_id, "turn_end", mod_turn_end, None);
2984
+ if run_control::cancelled() {
2985
+ crate::mods::close_chat(&root, chat_id);
2986
+ }
2987
+ agent_result
2988
+ },
2989
+ ))
2634
2990
  }
2635
2991
 
2636
2992
  /// Whether `action` may run while plan mode is active. `run_shell` gets a
@@ -2770,22 +3126,41 @@ async fn dispatch_one_subagent(
2770
3126
  let subagent_name = definition.name.clone();
2771
3127
  let mut nested_progress = |event: AgentProgress| {
2772
3128
  let tagged = match event {
2773
- AgentProgress::ToolStart { action, input, .. } => AgentProgress::ToolStart {
3129
+ AgentProgress::ToolStart {
3130
+ action,
3131
+ input,
3132
+ call_id,
3133
+ ..
3134
+ } => AgentProgress::ToolStart {
3135
+ call_id,
2774
3136
  action,
2775
3137
  input,
2776
3138
  subagent: Some(subagent_name.clone()),
2777
3139
  },
2778
3140
  AgentProgress::ToolEnd {
3141
+ elapsed_ms,
2779
3142
  action,
2780
3143
  input,
2781
3144
  result,
3145
+ call_id,
3146
+ status,
2782
3147
  ..
2783
3148
  } => AgentProgress::ToolEnd {
3149
+ elapsed_ms,
3150
+ call_id,
3151
+ status,
2784
3152
  action,
2785
3153
  input,
2786
3154
  result,
2787
3155
  subagent: Some(subagent_name.clone()),
2788
3156
  },
3157
+ AgentProgress::ContextCompaction {
3158
+ status, message, ..
3159
+ } => AgentProgress::ContextCompaction {
3160
+ status,
3161
+ message,
3162
+ subagent: Some(subagent_name.clone()),
3163
+ },
2789
3164
  other => other,
2790
3165
  };
2791
3166
  progress(tagged);
@@ -2828,17 +3203,9 @@ async fn dispatch_one_subagent(
2828
3203
  outcome
2829
3204
  }
2830
3205
 
2831
- /// Runs a step's `dispatch_subagent` decisions concurrently (see
2832
- /// `decisions_are_parallel_subagent_batch` for when this is used instead of
2833
- /// the normal one-at-a-time loop), capped at `PARALLEL_SUBAGENT_LIMIT` in
2834
- /// flight at once. `progress`/`action_counts`/`trajectory` are only touched
2835
- /// after every future has completed — back in single-threaded code, in the
2836
- /// decisions' original order — so this never needs to share those across
2837
- /// concurrent tasks. `approve` is the one resource genuinely needed *during*
2838
- /// concurrent execution (a subagent's own risky actions still need real user
2839
- /// approval), so it's wrapped in a `std::sync::Mutex` for the batch: each
2840
- /// concurrent subagent gets a small adapter closure over a shared `&Mutex`,
2841
- /// serializing just the moment of invoking it rather than the whole call.
3206
+ /// Runs subagent decisions concurrently, capped at `PARALLEL_SUBAGENT_LIMIT`.
3207
+ /// Progress and approvals use short Mutex-backed adapters during execution;
3208
+ /// action counts and trajectory are updated afterward in decision order.
2842
3209
  #[allow(clippy::too_many_arguments)]
2843
3210
  async fn run_parallel_subagent_batch(
2844
3211
  decisions: &[(String, AgentDecision)],
@@ -2850,7 +3217,7 @@ async fn run_parallel_subagent_batch(
2850
3217
  progress: &mut (dyn FnMut(AgentProgress) + Send),
2851
3218
  action_counts: &mut BTreeMap<String, usize>,
2852
3219
  trajectory: &mut Vec<String>,
2853
- ) -> Vec<(String, String, Value, String)> {
3220
+ ) -> Vec<ToolResultEntry> {
2854
3221
  let approve_mutex = std::sync::Mutex::new(approve);
2855
3222
  // Concurrently-running subagents share one real `progress` sink the same
2856
3223
  // way they share one real `approve` gate above — each gets a small
@@ -2865,13 +3232,23 @@ async fn run_parallel_subagent_batch(
2865
3232
  let call_id = call_id.clone();
2866
3233
  let thought = decision.thought.clone();
2867
3234
  let action = decision.action.clone();
2868
- let input_val = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3235
+ let input_val = tool_progress_input(&root, &decision);
2869
3236
  let action_key = action_fingerprint(decision);
2870
3237
  let name = decision.input.name.clone();
2871
3238
  let task_text = decision.input.instruction.clone();
2872
3239
  let approve_mutex = &approve_mutex;
2873
3240
  let progress_mutex = &progress_mutex;
2874
3241
  dispatches.push(async move {
3242
+ let mut progress_adapter = |event: AgentProgress| {
3243
+ let mut guard = progress_mutex.lock().unwrap();
3244
+ (*guard)(event);
3245
+ };
3246
+ progress_adapter(AgentProgress::ToolStart {
3247
+ call_id: Some(call_id.clone()),
3248
+ action: action.clone(),
3249
+ input: input_val.clone(),
3250
+ subagent: None,
3251
+ });
2875
3252
  let result: Result<String, OrchestrationError> = if name.trim().is_empty() {
2876
3253
  Err(OrchestrationError::Agent("name is required".into()))
2877
3254
  } else if task_text.trim().is_empty() {
@@ -2882,10 +3259,6 @@ async fn run_parallel_subagent_batch(
2882
3259
  let mut guard = approve_mutex.lock().unwrap();
2883
3260
  (*guard)(approval)
2884
3261
  };
2885
- let mut progress_adapter = |event: AgentProgress| {
2886
- let mut guard = progress_mutex.lock().unwrap();
2887
- (*guard)(event);
2888
- };
2889
3262
  dispatch_one_subagent(
2890
3263
  root,
2891
3264
  config,
@@ -2897,8 +3270,27 @@ async fn run_parallel_subagent_batch(
2897
3270
  )
2898
3271
  .await
2899
3272
  };
3273
+ let tool_result = match result {
3274
+ Ok(text) => text,
3275
+ Err(error) => format!("Error: {}", error),
3276
+ };
3277
+ progress_adapter(AgentProgress::ToolEnd {
3278
+ elapsed_ms: None,
3279
+ call_id: Some(call_id.clone()),
3280
+ status: None,
3281
+ action: action.clone(),
3282
+ input: input_val.clone(),
3283
+ result: tool_result.clone(),
3284
+ subagent: None,
3285
+ });
2900
3286
  (
2901
- index, call_id, thought, action, input_val, action_key, result,
3287
+ index,
3288
+ call_id,
3289
+ thought,
3290
+ action,
3291
+ input_val,
3292
+ action_key,
3293
+ tool_result,
2902
3294
  )
2903
3295
  });
2904
3296
  }
@@ -2908,35 +3300,14 @@ async fn run_parallel_subagent_batch(
2908
3300
  .collect::<Vec<_>>()
2909
3301
  .await;
2910
3302
  results.sort_by_key(|(index, ..)| *index);
2911
- // All `dispatches` futures (the only borrowers of `progress_mutex`) have
2912
- // finished by now, so this is the sole remaining handle — safe to unwrap
2913
- // back into a plain `&mut dyn FnMut` for the rest of this function.
2914
- let progress = progress_mutex.into_inner().unwrap();
2915
-
2916
3303
  let mut step_tool_results = Vec::with_capacity(results.len());
2917
- for (_, call_id, thought, action, input_val, action_key, result) in results {
2918
- progress(AgentProgress::ToolStart {
2919
- action: action.clone(),
2920
- input: input_val.clone(),
2921
- subagent: None,
2922
- });
2923
- let tool_result = match result {
2924
- Ok(text) => text,
2925
- Err(error) => format!("Error: {}", error),
2926
- };
2927
- progress(AgentProgress::ToolEnd {
2928
- action: action.clone(),
2929
- input: input_val.clone(),
2930
- result: tool_result.clone(),
2931
- subagent: None,
2932
- });
2933
-
3304
+ for (_, call_id, thought, action, input_val, action_key, tool_result) in results {
2934
3305
  let action_count = {
2935
3306
  let count = action_counts.entry(action_key).or_insert(0);
2936
3307
  *count += 1;
2937
3308
  *count
2938
3309
  };
2939
- let mut final_result = truncate(&tool_result);
3310
+ let mut final_result = model_tool_result(&action, &tool_result);
2940
3311
  if action_count >= 3 && !action.starts_with("browser_") {
2941
3312
  final_result.push_str(
2942
3313
  "\n\n[System Tip: You repeated the same tool action three or more times. \
@@ -2951,7 +3322,7 @@ async fn run_parallel_subagent_batch(
2951
3322
  &action,
2952
3323
  &final_result,
2953
3324
  ));
2954
- step_tool_results.push((call_id, action, input_val, final_result));
3325
+ step_tool_results.push((call_id, action, input_val, final_result).into());
2955
3326
  }
2956
3327
  step_tool_results
2957
3328
  }
@@ -2967,11 +3338,11 @@ async fn run_parallel_read_only_batch(
2967
3338
  progress: &mut (dyn FnMut(AgentProgress) + Send),
2968
3339
  action_counts: &mut BTreeMap<String, usize>,
2969
3340
  trajectory: &mut Vec<String>,
2970
- step_images: &mut std::collections::HashMap<String, String>,
3341
+ step_images: &mut std::collections::HashMap<String, Vec<String>>,
2971
3342
  hooks: &[crate::hooks::HookEntry],
2972
3343
  pinned_mcp_server: Option<&str>,
2973
3344
  fast_mode: bool,
2974
- ) -> Vec<(String, String, Value, String)> {
3345
+ ) -> Vec<ToolResultEntry> {
2975
3346
  let approve_mutex = std::sync::Mutex::new(approve);
2976
3347
  let progress_mutex = std::sync::Mutex::new(progress);
2977
3348
 
@@ -2980,7 +3351,7 @@ async fn run_parallel_read_only_batch(
2980
3351
  let call_id = call_id.clone();
2981
3352
  let thought = decision.thought.clone();
2982
3353
  let action = decision.action.clone();
2983
- let input_val = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3354
+ let input_val = tool_progress_input(&root, &decision);
2984
3355
  let action_key = action_fingerprint(decision);
2985
3356
  let approve_mutex = &approve_mutex;
2986
3357
  let progress_mutex = &progress_mutex;
@@ -3005,7 +3376,7 @@ async fn run_parallel_read_only_batch(
3005
3376
 
3006
3377
  {
3007
3378
  let mut guard = progress_mutex.lock().unwrap();
3008
- (*guard)(AgentProgress::ToolStart {
3379
+ (*guard)(AgentProgress::ToolStart { call_id: Some(call_id.clone()),
3009
3380
  action: action.clone(),
3010
3381
  input: input_val.clone(),
3011
3382
  subagent: None,
@@ -3080,7 +3451,7 @@ async fn run_parallel_read_only_batch(
3080
3451
 
3081
3452
  {
3082
3453
  let mut guard = progress_mutex.lock().unwrap();
3083
- (*guard)(AgentProgress::ToolEnd {
3454
+ (*guard)(AgentProgress::ToolEnd { elapsed_ms: None, call_id: Some(call_id.clone()), status: None,
3084
3455
  action: action.clone(),
3085
3456
  input: input_val.clone(),
3086
3457
  result: result.clone(),
@@ -3114,7 +3485,7 @@ async fn run_parallel_read_only_batch(
3114
3485
  action.as_str(),
3115
3486
  "browser_screenshot" | "video_filmstrip" | "video_waveform" | "view_image"
3116
3487
  ) {
3117
- step_images.insert(call_id.clone(), result.clone());
3488
+ step_images.insert(call_id.clone(), vec![result.clone()]);
3118
3489
  match action.as_str() {
3119
3490
  "video_filmstrip" => {
3120
3491
  "[Filmstrip generated — see attached image: sampled frames across the video timeline]".to_string()
@@ -3126,7 +3497,7 @@ async fn run_parallel_read_only_batch(
3126
3497
  _ => "[Screenshot captured — see attached image]".to_string(),
3127
3498
  }
3128
3499
  } else {
3129
- truncate(&result)
3500
+ model_tool_result(&action, &result)
3130
3501
  };
3131
3502
 
3132
3503
  if action_count >= 3 && !action.starts_with("browser_") {
@@ -3144,7 +3515,7 @@ async fn run_parallel_read_only_batch(
3144
3515
  &final_result,
3145
3516
  ));
3146
3517
 
3147
- step_tool_results.push((call_id, action, input_val, final_result));
3518
+ step_tool_results.push((call_id, action, input_val, final_result).into());
3148
3519
  }
3149
3520
 
3150
3521
  step_tool_results
@@ -3166,22 +3537,222 @@ async fn execute_tool(
3166
3537
  config: &MintConfig,
3167
3538
  decision: &AgentDecision,
3168
3539
  chat_id: &str,
3169
- approve_cb: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3540
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3170
3541
  progress: &mut (dyn FnMut(AgentProgress) + Send),
3171
3542
  ) -> Result<String, OrchestrationError> {
3172
- let input = &decision.input;
3173
- match decision.action.as_str() {
3174
- "list_files" | "read_file" | "note_write" | "apply_patch" | "write_file" => {
3175
- tools::files::execute(
3176
- decision.action.as_str(),
3543
+ Ok(execute_tool_outcome(
3544
+ root,
3545
+ config,
3546
+ decision,
3547
+ chat_id,
3548
+ approve,
3549
+ progress,
3550
+ &uuid::Uuid::new_v4().to_string(),
3551
+ )
3552
+ .await?
3553
+ .text)
3554
+ }
3555
+
3556
+ async fn execute_tool_outcome(
3557
+ root: &Path,
3558
+ config: &MintConfig,
3559
+ decision: &AgentDecision,
3560
+ chat_id: &str,
3561
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3562
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3563
+ call_id: &str,
3564
+ ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3565
+ execute_tool_outcome_reviewed(
3566
+ root, config, decision, chat_id, approve, progress, call_id, None,
3567
+ )
3568
+ .await
3569
+ }
3570
+
3571
+ #[allow(clippy::too_many_arguments)]
3572
+ async fn execute_tool_outcome_reviewed(
3573
+ root: &Path,
3574
+ config: &MintConfig,
3575
+ decision: &AgentDecision,
3576
+ chat_id: &str,
3577
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3578
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3579
+ call_id: &str,
3580
+ expected: Option<&mcp_review::McpReview>,
3581
+ ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3582
+ run_control::check()?;
3583
+ let origin = MOD_ORIGIN.try_with(Clone::clone).ok().flatten();
3584
+ let input = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3585
+ crate::mods::event(
3586
+ root,
3587
+ chat_id,
3588
+ "pre_tool_use",
3589
+ serde_json::json!({"callId":call_id,"action":decision.action,"input":input}),
3590
+ origin.as_deref(),
3591
+ )
3592
+ .map_err(OrchestrationError::Agent)?;
3593
+ let result = execute_tool_outcome_reviewed_inner(
3594
+ root, config, decision, chat_id, approve, progress, call_id, expected,
3595
+ )
3596
+ .await;
3597
+ let _ = crate::mods::event(
3598
+ root,
3599
+ chat_id,
3600
+ "post_tool_use",
3601
+ 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()}),
3602
+ origin.as_deref(),
3603
+ );
3604
+ result
3605
+ }
3606
+
3607
+ tokio::task_local! { static MOD_ORIGIN: Option<String>; }
3608
+
3609
+ /// Run queued requests through the same policy and approval path as agent tools.
3610
+ pub async fn execute_mod_requests(
3611
+ root: &Path,
3612
+ chat: &str,
3613
+ config: &MintConfig,
3614
+ plan_mode: bool,
3615
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3616
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3617
+ selected: Option<&[String]>,
3618
+ ) -> Result<(), String> {
3619
+ let requests = match selected {
3620
+ Some(ids) => crate::mods::take_selected_requests(root, chat, ids)?,
3621
+ None => crate::mods::take_requests(root, chat)?,
3622
+ };
3623
+ let (remembered_plan, pin) = crate::mods::turn_policy(root, chat);
3624
+ let plan_mode = plan_mode || remembered_plan;
3625
+ for request in requests {
3626
+ let action = request["action"].as_str().unwrap_or("");
3627
+ let id = request["modId"].as_str().unwrap_or("");
3628
+ let input = match decode_agent_input(request["input"].clone()) {
3629
+ Ok(input) => input,
3630
+ Err(error) => {
3631
+ crate::mods::complete_request(
3632
+ root,
3633
+ chat,
3634
+ id,
3635
+ request["hash"].as_str().unwrap_or(""),
3636
+ serde_json::json!({"requestId":request["id"],"action":action,"error":error}),
3637
+ )?;
3638
+ continue;
3639
+ }
3640
+ };
3641
+ let allowed = crate::prompts::agent::base_allowed_actions().contains(&action)
3642
+ && !action.starts_with("mod_")
3643
+ && !config.disabled_tools.iter().any(|t| t == action)
3644
+ && (!plan_mode || plan_mode_allows(action, &input))
3645
+ && pin.as_deref().is_none_or(|p| {
3646
+ !matches!(action, "mcp_tool" | "mcp_list_tools") || input.server == p
3647
+ });
3648
+ let result = if allowed {
3649
+ let decision = AgentDecision {
3650
+ thought: String::new(),
3651
+ action: action.into(),
3177
3652
  input,
3178
- root,
3179
- config,
3180
- chat_id,
3181
- approve_cb,
3182
- )
3183
- .await
3184
- }
3653
+ };
3654
+ MOD_ORIGIN
3655
+ .scope(
3656
+ Some(id.into()),
3657
+ execute_tool_outcome_reviewed(
3658
+ root,
3659
+ config,
3660
+ &decision,
3661
+ chat,
3662
+ approve,
3663
+ progress,
3664
+ request["id"].as_str().unwrap_or(""),
3665
+ None,
3666
+ ),
3667
+ )
3668
+ .await
3669
+ .map(|r| r.text)
3670
+ .map_err(|e| e.to_string())
3671
+ } else {
3672
+ Err("This tool is unavailable to mods in the current mode".into())
3673
+ };
3674
+ crate::mods::complete_request(
3675
+ root,
3676
+ chat,
3677
+ id,
3678
+ request["hash"].as_str().unwrap_or(""),
3679
+ serde_json::json!({"requestId":request["id"],"action":action,"result":result.as_ref().ok(),"error":result.as_ref().err()}),
3680
+ )?;
3681
+ }
3682
+ Ok(())
3683
+ }
3684
+
3685
+ #[allow(clippy::too_many_arguments)]
3686
+ async fn execute_tool_outcome_reviewed_inner(
3687
+ root: &Path,
3688
+ config: &MintConfig,
3689
+ decision: &AgentDecision,
3690
+ chat_id: &str,
3691
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3692
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3693
+ call_id: &str,
3694
+ expected: Option<&mcp_review::McpReview>,
3695
+ ) -> Result<crate::integrations::mcp_result::ToolOutcome, OrchestrationError> {
3696
+ run_control::check()?;
3697
+ if matches!(decision.action.as_str(), "mcp_tool" | "mcp_list_tools") {
3698
+ let mut out = tools::plugins_mcp::execute_reviewed(
3699
+ &decision.action,
3700
+ &decision.input,
3701
+ config,
3702
+ chat_id,
3703
+ call_id,
3704
+ expected,
3705
+ approve,
3706
+ progress,
3707
+ )
3708
+ .await?;
3709
+ if !out.images.is_empty()
3710
+ && config.extra.get("mcpImageInput").and_then(Value::as_bool) == Some(false)
3711
+ {
3712
+ out.images.clear();
3713
+ let warning="Model image input is disabled for MCP. Use attachment metadata only; do not claim visual inspection.".to_string();
3714
+ out.text.push_str(&format!("\nWarning: {warning}"));
3715
+ out.warnings.push(warning);
3716
+ }
3717
+ if !out.artifacts.is_empty() || !out.warnings.is_empty() {
3718
+ progress(AgentProgress::ToolArtifacts {
3719
+ call_id: call_id.into(),
3720
+ artifacts: out.artifacts.clone(),
3721
+ warnings: out.warnings.clone(),
3722
+ });
3723
+ }
3724
+ Ok(out)
3725
+ } else {
3726
+ execute_tool_text(root, config, decision, chat_id, approve, progress)
3727
+ .await
3728
+ .map(Into::into)
3729
+ }
3730
+ }
3731
+
3732
+ async fn execute_tool_text(
3733
+ root: &Path,
3734
+ config: &MintConfig,
3735
+ decision: &AgentDecision,
3736
+ chat_id: &str,
3737
+ approve_cb: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3738
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3739
+ ) -> Result<String, OrchestrationError> {
3740
+ let input = &decision.input;
3741
+ match decision.action.as_str() {
3742
+ "mod_help" | "mod_list" | "mod_validate" | "mod_publish" => {
3743
+ tools::mods::execute(&decision.action, input, root, config, chat_id, approve_cb).await
3744
+ }
3745
+ "list_files" | "read_file" | "note_write" | "apply_patch" | "write_file" => {
3746
+ tools::files::execute(
3747
+ decision.action.as_str(),
3748
+ input,
3749
+ root,
3750
+ config,
3751
+ chat_id,
3752
+ approve_cb,
3753
+ )
3754
+ .await
3755
+ }
3185
3756
  "search_code" | "symbols" | "find_definition" | "find_references" | "repo_map"
3186
3757
  | "semantic_index" | "semantic_search" => {
3187
3758
  tools::code_search::execute(
@@ -3374,18 +3945,545 @@ where
3374
3945
  let decision = AgentDecision {
3375
3946
  thought: String::new(),
3376
3947
  action: action.to_string(),
3377
- input: serde_json::from_value(input).unwrap_or_default(),
3948
+ input: parse_tool_input(action, input)?,
3378
3949
  };
3379
3950
  // No progress channel from the realtime voice session — tool-call
3380
3951
  // activity isn't surfaced there the way it is in the CLI/GUI.
3381
3952
  execute_tool(root, config, &decision, chat_id, approve_cb, &mut |_| {}).await
3382
3953
  }
3383
3954
 
3955
+ fn parse_tool_input(action: &str, input: Value) -> Result<AgentInput, OrchestrationError> {
3956
+ decode_agent_input(input).map_err(|error| {
3957
+ OrchestrationError::Agent(format!("invalid arguments for tool '{action}': {error}"))
3958
+ })
3959
+ }
3960
+
3961
+ fn append_native_tool_results(
3962
+ messages: &mut Vec<ChatMessage>,
3963
+ response_text: &str,
3964
+ results: &[ToolResultEntry],
3965
+ signatures: &std::collections::HashMap<String, String>,
3966
+ images: &std::collections::HashMap<String, Vec<String>>,
3967
+ ) {
3968
+ let mut assistant_content: Vec<ContentBlock> = Vec::new();
3969
+ if !response_text.is_empty() {
3970
+ assistant_content.push(ContentBlock::Text {
3971
+ text: response_text.to_string(),
3972
+ });
3973
+ }
3974
+ let mut tool_result_content: Vec<ContentBlock> = Vec::new();
3975
+ for ToolResultEntry {
3976
+ call_id,
3977
+ action,
3978
+ input: input_value,
3979
+ text: final_result,
3980
+ status,
3981
+ } in results
3982
+ {
3983
+ // Activity annotations are for the UI, not parameters of the provider tool schema.
3984
+ let mut tool_input = input_value.clone();
3985
+ if let Some(object) = tool_input.as_object_mut() {
3986
+ object.remove("skill_name");
3987
+ }
3988
+ assistant_content.push(ContentBlock::ToolUse {
3989
+ id: call_id.clone(),
3990
+ name: action.clone(),
3991
+ input: tool_input,
3992
+ thought_signature: signatures.get(call_id).cloned(),
3993
+ });
3994
+ tool_result_content.push(ContentBlock::ToolResult {
3995
+ tool_use_id: call_id.clone(),
3996
+ content: final_result.clone(),
3997
+ is_error: *status != crate::mcp_result::ToolStatus::Success,
3998
+ });
3999
+ if let Some(data_uris) = images.get(call_id) {
4000
+ for data_uri in data_uris {
4001
+ tool_result_content.push(ContentBlock::Image {
4002
+ data_uri: data_uri.clone(),
4003
+ });
4004
+ }
4005
+ }
4006
+ }
4007
+ if !assistant_content.is_empty() {
4008
+ messages.push(ChatMessage {
4009
+ role: ChatRole::Assistant,
4010
+ content: assistant_content,
4011
+ });
4012
+ }
4013
+ if !tool_result_content.is_empty() {
4014
+ messages.push(ChatMessage {
4015
+ role: ChatRole::Tool,
4016
+ content: tool_result_content,
4017
+ });
4018
+ }
4019
+ }
4020
+
4021
+ #[derive(Debug, Clone)]
4022
+ struct ToolResultEntry {
4023
+ call_id: String,
4024
+ action: String,
4025
+ input: Value,
4026
+ text: String,
4027
+ status: crate::mcp_result::ToolStatus,
4028
+ }
4029
+
4030
+ // Legacy tools determine their outcome once at this adapter; MCP supplies its
4031
+ // explicit status directly, regardless of the wording of server text.
4032
+ impl From<(String, String, Value, String)> for ToolResultEntry {
4033
+ fn from((call_id, action, input, text): (String, String, Value, String)) -> Self {
4034
+ let mut status = crate::mcp_result::ToolOutcome::from(text.clone()).status;
4035
+ if text.starts_with("Skipped")
4036
+ || (matches!(
4037
+ action.as_str(),
4038
+ "run_shell" | "verify" | "run_tests" | "run_typecheck" | "run_linter"
4039
+ ) && shell_result_failed(&text))
4040
+ {
4041
+ status = crate::mcp_result::ToolStatus::Failed;
4042
+ }
4043
+ Self {
4044
+ call_id,
4045
+ action,
4046
+ input,
4047
+ text,
4048
+ status,
4049
+ }
4050
+ }
4051
+ }
4052
+ type PreparedToolCalls = (Vec<(String, AgentDecision)>, Vec<ToolResultEntry>);
4053
+
4054
+ #[derive(Debug, Default)]
4055
+ struct ToolArgumentRetries {
4056
+ failures: BTreeMap<String, usize>,
4057
+ }
4058
+
4059
+ impl ToolArgumentRetries {
4060
+ fn succeeded(&mut self, tool: &str) {
4061
+ self.failures.remove(tool);
4062
+ }
4063
+
4064
+ fn observe_completed(&mut self, results: &[ToolResultEntry]) {
4065
+ for ToolResultEntry {
4066
+ action: tool,
4067
+ status,
4068
+ ..
4069
+ } in results
4070
+ {
4071
+ // Parallel batches encode execution errors and hook blocks in
4072
+ // their results. Neither is a successful execution.
4073
+ if *status == crate::mcp_result::ToolStatus::Success {
4074
+ self.succeeded(tool);
4075
+ }
4076
+ }
4077
+ }
4078
+ }
4079
+
4080
+ /// Invalid calls never become executable decisions. Return their original
4081
+ /// arguments and call IDs so the next model turn can repair them.
4082
+ fn prepare_native_tool_calls(
4083
+ calls: Vec<crate::chat::ToolCall>,
4084
+ thought: &str,
4085
+ retries: &mut ToolArgumentRetries,
4086
+ ) -> Result<PreparedToolCalls, OrchestrationError> {
4087
+ let mut decisions = Vec::new();
4088
+ let mut errors = Vec::new();
4089
+ for (index, call) in calls.into_iter().enumerate() {
4090
+ match parse_tool_input(&call.name, call.input.clone()) {
4091
+ Ok(input) => decisions.push((
4092
+ call.id,
4093
+ AgentDecision {
4094
+ thought: if index == 0 {
4095
+ thought.to_owned()
4096
+ } else {
4097
+ String::new()
4098
+ },
4099
+ action: call.name,
4100
+ input,
4101
+ },
4102
+ )),
4103
+ Err(error) => {
4104
+ let invalid_count = retries.failures.entry(call.name.clone()).or_default();
4105
+ if *invalid_count >= MAX_TOOL_ARGUMENT_RETRIES {
4106
+ return Err(OrchestrationError::Agent(format!(
4107
+ "Tool '{}' exhausted its {MAX_TOOL_ARGUMENT_RETRIES} argument-correction retries without a successful execution. Other tools have independent budgets. {error}",
4108
+ call.name
4109
+ )));
4110
+ }
4111
+ *invalid_count += 1;
4112
+ errors.push((call.id, call.name, call.input, format!(
4113
+ "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.",
4114
+ *invalid_count,
4115
+ )).into());
4116
+ }
4117
+ }
4118
+ }
4119
+ Ok((decisions, errors))
4120
+ }
4121
+
3384
4122
  #[cfg(test)]
3385
4123
  mod tests {
3386
4124
  use super::*;
3387
4125
  use crate::config::AgentConfig;
3388
4126
 
4127
+ #[test]
4128
+ fn stringified_patch_uses_explicit_outer_target() {
4129
+ let patch =
4130
+ serde_json::json!({"hunks": [{"oldText": "old", "newText": "new"}]}).to_string();
4131
+ let input = parse_tool_input(
4132
+ "apply_patch",
4133
+ serde_json::json!({"path": "style.css", "patch": patch}),
4134
+ )
4135
+ .unwrap();
4136
+ let patch = input.patch.unwrap();
4137
+ assert_eq!(patch.path, PathBuf::from("style.css"));
4138
+ assert_eq!(patch.hunks.len(), 1);
4139
+ }
4140
+
4141
+ #[tokio::test]
4142
+ async fn stringified_patch_executes_only_after_approval_and_can_be_undone() {
4143
+ let root = std::env::temp_dir().join(format!("mint-patch-repro-{}", uuid::Uuid::new_v4()));
4144
+ std::fs::create_dir_all(&root).unwrap();
4145
+ std::fs::write(root.join("style.css"), "body { color: black; }").unwrap();
4146
+ let config = MintConfig {
4147
+ allowed_read_paths: vec![root.clone()],
4148
+ allowed_write_paths: vec![root.clone()],
4149
+ blocked_paths: vec![],
4150
+ blocked_file_names: vec![],
4151
+ ..MintConfig::default()
4152
+ };
4153
+ let input = serde_json::json!({"path": "style.css", "patch": serde_json::json!({"hunks": [{"oldText": "black", "newText": "orange"}]}).to_string()});
4154
+ execute_tool_from_json(
4155
+ &root,
4156
+ &config,
4157
+ "apply_patch",
4158
+ input.clone(),
4159
+ "patch-repro",
4160
+ &mut |_| Ok(ApprovalOutcome::Denied),
4161
+ )
4162
+ .await
4163
+ .unwrap();
4164
+ assert_eq!(
4165
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
4166
+ "body { color: black; }"
4167
+ );
4168
+ execute_tool_from_json(
4169
+ &root,
4170
+ &config,
4171
+ "apply_patch",
4172
+ input,
4173
+ "patch-repro",
4174
+ &mut |_| Ok(ApprovalOutcome::Approved),
4175
+ )
4176
+ .await
4177
+ .unwrap();
4178
+ assert_eq!(
4179
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
4180
+ "body { color: orange; }"
4181
+ );
4182
+ crate::system::workspace_history::undo(&root, None).unwrap();
4183
+ assert_eq!(
4184
+ std::fs::read_to_string(root.join("style.css")).unwrap(),
4185
+ "body { color: black; }"
4186
+ );
4187
+ std::fs::remove_dir_all(root).unwrap();
4188
+ }
4189
+
4190
+ #[test]
4191
+ fn patch_decoding_preserves_strict_types_and_rejects_ambiguous_targets() {
4192
+ let patch = serde_json::json!({"path": "style.css", "hunks": [{"oldText": "old", "newText": "new"}]});
4193
+ assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": patch})).is_ok());
4194
+ assert!(
4195
+ parse_tool_input(
4196
+ "apply_patch",
4197
+ serde_json::json!({"path": "other.css", "patch": patch.to_string()})
4198
+ )
4199
+ .is_err()
4200
+ );
4201
+ assert!(
4202
+ parse_tool_input(
4203
+ "apply_patch",
4204
+ serde_json::json!({"patch": "{\"hunks\": []}"})
4205
+ )
4206
+ .is_err()
4207
+ );
4208
+ assert!(parse_tool_input("apply_patch", serde_json::json!({"patch": "not JSON"})).is_err());
4209
+ assert!(parse_tool_input("list_files", serde_json::json!({"limit": "100"})).is_err());
4210
+ let huge = "private-code-marker".repeat(1000);
4211
+ let error = parse_tool_input(
4212
+ "apply_patch",
4213
+ serde_json::json!({"patch": {"path": "style.css", "hunks": huge}}),
4214
+ )
4215
+ .unwrap_err()
4216
+ .to_string();
4217
+ assert!(error.len() < 600);
4218
+ assert!(!error.contains("private-code-marker"));
4219
+ 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();
4220
+ assert_eq!(
4221
+ decision.input.patch.unwrap().path,
4222
+ PathBuf::from("style.css")
4223
+ );
4224
+ }
4225
+
4226
+ fn test_list_call(id: &str, input: Value) -> crate::chat::ToolCall {
4227
+ crate::chat::ToolCall {
4228
+ id: id.into(),
4229
+ name: "list_files".into(),
4230
+ input,
4231
+ thought_signature: Some("signature".into()),
4232
+ }
4233
+ }
4234
+
4235
+ #[tokio::test]
4236
+ async fn native_argument_error_is_returned_and_corrected_call_can_execute() {
4237
+ let raw = serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": "100"});
4238
+ let mut count = ToolArgumentRetries::default();
4239
+ let (decisions, errors) =
4240
+ prepare_native_tool_calls(vec![test_list_call("bad", raw.clone())], "", &mut count)
4241
+ .unwrap();
4242
+ assert!(decisions.is_empty());
4243
+ assert_eq!(errors.len(), 1);
4244
+ let mut messages = Vec::new();
4245
+ let signatures = std::collections::HashMap::from([("bad".into(), "signature".into())]);
4246
+ append_native_tool_results(&mut messages, "", &errors, &signatures, &Default::default());
4247
+ assert!(
4248
+ matches!(&messages[0].content[0], ContentBlock::ToolUse { id, input, thought_signature, .. }
4249
+ if id == "bad" && input == &raw && thought_signature.as_deref() == Some("signature"))
4250
+ );
4251
+ assert!(
4252
+ matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, content, is_error }
4253
+ if tool_use_id == "bad" && *is_error && content.contains("Correct the arguments"))
4254
+ );
4255
+
4256
+ let root =
4257
+ std::env::temp_dir().join(format!("mint-tool-recovery-{}", uuid::Uuid::new_v4()));
4258
+ let nested = root.join("Downloads/YOUTUBE_DL/playlist");
4259
+ std::fs::create_dir_all(&nested).unwrap();
4260
+ std::fs::write(nested.join("song.txt"), "song").unwrap();
4261
+ let config = MintConfig {
4262
+ allowed_read_paths: vec![root.clone()],
4263
+ blocked_paths: vec![],
4264
+ blocked_file_names: vec![],
4265
+ ..MintConfig::default()
4266
+ };
4267
+ let (decisions, errors) = prepare_native_tool_calls(
4268
+ vec![test_list_call(
4269
+ "fixed",
4270
+ serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
4271
+ )],
4272
+ "",
4273
+ &mut count,
4274
+ )
4275
+ .unwrap();
4276
+ assert!(errors.is_empty());
4277
+ let output = execute_tool(
4278
+ &root,
4279
+ &config,
4280
+ &decisions[0].1,
4281
+ "regression",
4282
+ &mut |_| panic!("listing must not request approval"),
4283
+ &mut |_| {},
4284
+ )
4285
+ .await
4286
+ .unwrap();
4287
+ let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
4288
+ assert_eq!(entries.len(), 1);
4289
+ assert_eq!(
4290
+ entries[0]["path"],
4291
+ serde_json::json!(nested.join("song.txt"))
4292
+ );
4293
+ // Parsing the repaired call alone must not renew its budget.
4294
+ assert_eq!(count.failures["list_files"], 1);
4295
+ count.observe_completed(&[
4296
+ ("fixed".into(), "list_files".into(), Value::Null, output).into()
4297
+ ]);
4298
+ assert!(!count.failures.contains_key("list_files"));
4299
+ std::fs::remove_dir_all(root).unwrap();
4300
+ }
4301
+
4302
+ #[test]
4303
+ fn native_invalid_calls_are_bounded_without_blocking_valid_alternatives() {
4304
+ let mut count = ToolArgumentRetries::default();
4305
+ for _ in 0..MAX_TOOL_ARGUMENT_RETRIES {
4306
+ let (decisions, errors) = prepare_native_tool_calls(
4307
+ vec![test_list_call(
4308
+ "bad",
4309
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
4310
+ )],
4311
+ "",
4312
+ &mut count,
4313
+ )
4314
+ .unwrap();
4315
+ assert!(decisions.is_empty());
4316
+ assert_eq!(errors.len(), 1);
4317
+ }
4318
+ let alternative = crate::chat::ToolCall {
4319
+ id: "alternative".into(),
4320
+ name: "run_shell".into(),
4321
+ input: serde_json::json!({"command": "ls ~/Downloads/YOUTUBE_DL"}),
4322
+ thought_signature: None,
4323
+ };
4324
+ let (decisions, errors) =
4325
+ prepare_native_tool_calls(vec![alternative], "", &mut count).unwrap();
4326
+ assert_eq!(decisions[0].1.action, "run_shell");
4327
+ assert!(errors.is_empty());
4328
+ assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
4329
+ let error =
4330
+ prepare_native_tool_calls(vec![test_list_call("bad", Value::Null)], "", &mut count)
4331
+ .unwrap_err();
4332
+ assert!(
4333
+ error
4334
+ .to_string()
4335
+ .contains("exhausted its 5 argument-correction retries")
4336
+ );
4337
+ // One exhausted tool must not consume another tool's corrections.
4338
+ let other = crate::chat::ToolCall {
4339
+ id: "other-bad".into(),
4340
+ name: "read_file".into(),
4341
+ input: serde_json::json!({"path": 123}),
4342
+ thought_signature: None,
4343
+ };
4344
+ let (_, errors) = prepare_native_tool_calls(vec![other], "", &mut count).unwrap();
4345
+ assert_eq!(errors.len(), 1);
4346
+ assert_eq!(count.failures["read_file"], 1);
4347
+ for result in [
4348
+ "Error: execution failed",
4349
+ "Blocked by hook",
4350
+ "Skipped duplicate",
4351
+ ] {
4352
+ count.observe_completed(&[(
4353
+ "x".into(),
4354
+ "list_files".into(),
4355
+ Value::Null,
4356
+ result.into(),
4357
+ )
4358
+ .into()]);
4359
+ assert_eq!(count.failures["list_files"], MAX_TOOL_ARGUMENT_RETRIES);
4360
+ }
4361
+ count.succeeded("list_files");
4362
+ assert_eq!(count.failures["read_file"], 1);
4363
+ assert!(!count.failures.contains_key("list_files"));
4364
+ assert!(
4365
+ prepare_native_tool_calls(vec![test_list_call("fresh", Value::Null)], "", &mut count)
4366
+ .is_ok()
4367
+ );
4368
+ }
4369
+
4370
+ #[test]
4371
+ fn native_mixed_batch_keeps_valid_calls_and_matches_each_error_to_its_id() {
4372
+ let mut count = ToolArgumentRetries::default();
4373
+ let (decisions, errors) = prepare_native_tool_calls(
4374
+ vec![
4375
+ test_list_call("bad", Value::Null),
4376
+ test_list_call("valid-1", serde_json::json!({"path": "."})),
4377
+ test_list_call("valid-2", serde_json::json!({"path": "src"})),
4378
+ ],
4379
+ "",
4380
+ &mut count,
4381
+ )
4382
+ .unwrap();
4383
+ assert_eq!(decisions.len(), 2);
4384
+ assert!(decisions_are_parallel_read_only_batch(&decisions));
4385
+ assert_eq!(errors[0].call_id, "bad");
4386
+ let mut results = errors;
4387
+ results.extend(decisions.iter().map(|(id, d)| {
4388
+ (
4389
+ id.clone(),
4390
+ d.action.clone(),
4391
+ serde_json::to_value(&d.input).unwrap(),
4392
+ "[]".into(),
4393
+ )
4394
+ .into()
4395
+ }));
4396
+ let mut messages = Vec::new();
4397
+ append_native_tool_results(
4398
+ &mut messages,
4399
+ "",
4400
+ &results,
4401
+ &Default::default(),
4402
+ &Default::default(),
4403
+ );
4404
+ assert_eq!(messages[0].content.len(), 3);
4405
+ assert_eq!(messages[1].content.len(), 3);
4406
+ assert!(
4407
+ matches!(&messages[1].content[0], ContentBlock::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "bad")
4408
+ );
4409
+ assert!(
4410
+ matches!(&messages[1].content[1], ContentBlock::ToolResult { tool_use_id, is_error: false, .. } if tool_use_id == "valid-1")
4411
+ );
4412
+ }
4413
+
4414
+ #[tokio::test]
4415
+ async fn list_files_rejects_invalid_arguments_instead_of_listing_workspace() {
4416
+ let config = MintConfig::default();
4417
+ // A nonexistent root ensures no filesystem lookup occurs for bad input.
4418
+ let root = Path::new("/nonexistent-mint-list-files-regression");
4419
+ for input in [
4420
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": "100"}),
4421
+ serde_json::json!({"path": "~/Downloads/YOUTUBE_DL", "limit": -1}),
4422
+ serde_json::json!({"path": null}),
4423
+ Value::String(r#"{"path":"~/Downloads/YOUTUBE_DL""#.into()),
4424
+ Value::Null,
4425
+ ] {
4426
+ let error = execute_tool_from_json(
4427
+ root,
4428
+ &config,
4429
+ "list_files",
4430
+ input,
4431
+ "regression",
4432
+ &mut |_| panic!("invalid arguments must not request approval"),
4433
+ )
4434
+ .await
4435
+ .unwrap_err();
4436
+ assert!(
4437
+ error
4438
+ .to_string()
4439
+ .contains("invalid arguments for tool 'list_files'")
4440
+ );
4441
+ }
4442
+ }
4443
+
4444
+ #[tokio::test]
4445
+ async fn list_files_preserves_nested_paths_and_default_directory() {
4446
+ let root =
4447
+ std::env::temp_dir().join(format!("mint-list-files-paths-{}", std::process::id()));
4448
+ let nested = root.join("Downloads/YOUTUBE_DL/playlist");
4449
+ std::fs::create_dir_all(&nested).unwrap();
4450
+ std::fs::write(nested.join("song.txt"), "song").unwrap();
4451
+ let config = MintConfig {
4452
+ allowed_read_paths: vec![root.clone()],
4453
+ blocked_paths: vec![],
4454
+ blocked_file_names: vec![],
4455
+ ..MintConfig::default()
4456
+ };
4457
+ for input in [
4458
+ serde_json::json!({"path": "Downloads/YOUTUBE_DL/playlist", "limit": 100}),
4459
+ serde_json::json!({"path": nested}),
4460
+ serde_json::json!({}),
4461
+ ] {
4462
+ let expected = if input.get("path").is_some() {
4463
+ &nested
4464
+ } else {
4465
+ &root
4466
+ };
4467
+ let output = execute_tool_from_json(
4468
+ &root,
4469
+ &config,
4470
+ "list_files",
4471
+ input,
4472
+ "regression",
4473
+ &mut |_| panic!("listing must not request approval"),
4474
+ )
4475
+ .await
4476
+ .unwrap();
4477
+ let entries: Vec<Value> = serde_json::from_str(&output).unwrap();
4478
+ assert_eq!(entries.len(), 1);
4479
+ assert_eq!(
4480
+ Path::new(entries[0]["path"].as_str().unwrap()).parent(),
4481
+ Some(expected.as_path())
4482
+ );
4483
+ }
4484
+ std::fs::remove_dir_all(root).unwrap();
4485
+ }
4486
+
3389
4487
  #[tokio::test]
3390
4488
  async fn turn_start_listener_reports_only_the_matching_chat() {
3391
4489
  let memory = MemoryStore::open(std::env::temp_dir().join(format!(
@@ -3416,6 +4514,7 @@ mod tests {
3416
4514
  fn test_compact_agent_progress_cleans_deltas_and_preserves_tools() {
3417
4515
  let events = vec![
3418
4516
  AgentProgress::ToolStart {
4517
+ call_id: None,
3419
4518
  action: "read_file".into(),
3420
4519
  input: serde_json::json!({ "path": "src/main.rs" }),
3421
4520
  subagent: None,
@@ -3436,6 +4535,9 @@ mod tests {
3436
4535
  elapsed_ms: Some(200),
3437
4536
  },
3438
4537
  AgentProgress::ToolEnd {
4538
+ elapsed_ms: None,
4539
+ call_id: None,
4540
+ status: None,
3439
4541
  action: "read_file".into(),
3440
4542
  input: serde_json::json!({ "path": "src/main.rs" }),
3441
4543
  result: "fn main() {}".into(),
@@ -3485,11 +4587,15 @@ mod tests {
3485
4587
  .lock()
3486
4588
  .unwrap()
3487
4589
  .push(AgentProgress::ToolStart {
4590
+ call_id: None,
3488
4591
  action: "run_shell".into(),
3489
4592
  input: serde_json::json!({ "command": "ls -la" }),
3490
4593
  subagent: None,
3491
4594
  });
3492
4595
  turn.activity.lock().unwrap().push(AgentProgress::ToolEnd {
4596
+ elapsed_ms: None,
4597
+ call_id: None,
4598
+ status: None,
3493
4599
  action: "run_shell".into(),
3494
4600
  input: serde_json::json!({ "command": "ls -la" }),
3495
4601
  result: "file.txt".into(),
@@ -3527,6 +4633,7 @@ mod tests {
3527
4633
  .lock()
3528
4634
  .unwrap()
3529
4635
  .push(AgentProgress::ToolStart {
4636
+ call_id: None,
3530
4637
  action: "read_file".into(),
3531
4638
  input: serde_json::json!({ "path": "test.txt" }),
3532
4639
  subagent: None,
@@ -4194,3 +5301,791 @@ mod tests {
4194
5301
  assert!(buffer.contains("Step 3:\n- Thought: All done"));
4195
5302
  }
4196
5303
  }
5304
+
5305
+ #[cfg(test)]
5306
+ mod full_skill_result_tests {
5307
+ use super::*;
5308
+ #[test]
5309
+ fn large_loaded_skill_results_reach_the_model_without_losing_middle_instructions() {
5310
+ let content = format!(
5311
+ "[Loaded skill: review]\n{}\nREQUIRED-MIDDLE-INSTRUCTION\n{}",
5312
+ "a".repeat(40_000),
5313
+ "z".repeat(40_000)
5314
+ );
5315
+ assert_eq!(model_tool_result("read_file", &content), content);
5316
+ assert!(model_tool_result("run_shell", &"x".repeat(80_000)).len() < 20_000);
5317
+ }
5318
+ }
5319
+
5320
+ #[cfg(all(test, unix))]
5321
+ mod mcp_outcome_tests {
5322
+ use super::*;
5323
+ use std::time::Duration;
5324
+ fn generic_server(tool: &str, response: Value) -> String {
5325
+ format!(
5326
+ r#"
5327
+ import sys,json
5328
+ response=json.loads({:?})
5329
+ for line in sys.stdin:
5330
+ r=json.loads(line);m=r.get('method')
5331
+ if m=='initialize': result={{}}
5332
+ elif m=='tools/list': result={{'tools':[{{'name':{:?},'inputSchema':{{'type':'object'}}}}]}}
5333
+ elif m=='tools/call': result=response
5334
+ else: continue
5335
+ print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
5336
+ "#,
5337
+ response.to_string(),
5338
+ tool
5339
+ )
5340
+ }
5341
+
5342
+ #[test]
5343
+ fn compact_device_progress_keeps_acceptance_and_latest_observation() {
5344
+ 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>>();
5345
+ let compacted = compact_agent_progress(&events);
5346
+ assert_eq!(
5347
+ compacted.len(),
5348
+ 2,
5349
+ "saved history should retain acceptance and the final fresh observation"
5350
+ );
5351
+ assert!(matches!(
5352
+ &compacted[1],
5353
+ AgentProgress::DeviceState {
5354
+ update: crate::mcp::device::DeviceUpdate {
5355
+ state: crate::mcp::device::DevicePhase::Verified,
5356
+ ..
5357
+ },
5358
+ ..
5359
+ }
5360
+ ));
5361
+ }
5362
+
5363
+ #[tokio::test]
5364
+ async fn mcp_definitions_reach_native_and_json_models_before_any_command() {
5365
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
5366
+ for native in [true, false] {
5367
+ let marker =
5368
+ std::env::temp_dir().join(format!("mint-model-command-{}", uuid::Uuid::new_v4()));
5369
+ let server = format!("model-mcp-{}", uuid::Uuid::new_v4());
5370
+ let mut cfg = MintConfig::default();
5371
+ 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'");
5372
+ cfg.extra.insert("mcpServers".into(),serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":5}}));
5373
+ cfg.extra.insert(
5374
+ "allowedMcpTools".into(),
5375
+ serde_json::json!({server.clone():["*"]}),
5376
+ );
5377
+ cfg.ai_provider = "ollama".into();
5378
+ cfg.ollama_model = "qwen2.5".into();
5379
+ cfg.extra
5380
+ .insert("forceJsonPromptMode".into(), serde_json::json!(!native));
5381
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5382
+ cfg.ollama_host = format!("http://{}", listener.local_addr().unwrap());
5383
+ let recorded = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
5384
+ let requests = Arc::clone(&recorded);
5385
+ let observed_marker = marker.clone();
5386
+ let name = server.clone();
5387
+ let provider = tokio::spawn(async move {
5388
+ for step in 0..3 {
5389
+ let (mut socket, _) = listener.accept().await.unwrap();
5390
+ let mut bytes = Vec::new();
5391
+ let mut chunk = [0u8; 4096];
5392
+ let (end, len) = loop {
5393
+ let n = socket.read(&mut chunk).await.unwrap();
5394
+ assert!(n > 0);
5395
+ bytes.extend_from_slice(&chunk[..n]);
5396
+ if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
5397
+ let header = String::from_utf8_lossy(&bytes[..end]);
5398
+ let len = header
5399
+ .lines()
5400
+ .find_map(|l| {
5401
+ l.to_ascii_lowercase()
5402
+ .strip_prefix("content-length:")
5403
+ .and_then(|v| v.trim().parse::<usize>().ok())
5404
+ })
5405
+ .unwrap();
5406
+ break (end + 4, len);
5407
+ }
5408
+ };
5409
+ while bytes.len() < end + len {
5410
+ let n = socket.read(&mut chunk).await.unwrap();
5411
+ assert!(n > 0);
5412
+ bytes.extend_from_slice(&chunk[..n]);
5413
+ }
5414
+ requests
5415
+ .lock()
5416
+ .unwrap()
5417
+ .push(serde_json::from_slice(&bytes[end..end + len]).unwrap());
5418
+ if step == 1 {
5419
+ assert!(
5420
+ !observed_marker.exists(),
5421
+ "initial preparation sent a tools/call"
5422
+ );
5423
+ }
5424
+ let (action, input) = if step < 2 {
5425
+ (
5426
+ "mcp_tool",
5427
+ serde_json::json!({"server":name,"tool":"move","arguments":{}}),
5428
+ )
5429
+ } else {
5430
+ (
5431
+ "finish",
5432
+ serde_json::json!({"summary":"done","verification":"Mock command verified"}),
5433
+ )
5434
+ };
5435
+ let message = if native {
5436
+ serde_json::json!({"role":"assistant","content":"","tool_calls":[{"function":{"name":action,"arguments":input}}]})
5437
+ } else {
5438
+ serde_json::json!({"role":"assistant","content":serde_json::json!({"thought":"test","action":action,"input":input}).to_string()})
5439
+ };
5440
+ let body = format!(
5441
+ "{}\n",
5442
+ serde_json::json!({"model":"qwen2.5","message":message,"done":true,"prompt_eval_count":100,"eval_count":10})
5443
+ );
5444
+ 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();
5445
+ }
5446
+ });
5447
+ let chat = format!("model-review-{}", uuid::Uuid::new_v4());
5448
+ let mut events = Vec::new();
5449
+ let outcome = tokio::time::timeout(
5450
+ Duration::from_secs(15),
5451
+ orchestrate_agent_loop(
5452
+ &cfg,
5453
+ "Use the mock MCP move tool",
5454
+ Path::new("."),
5455
+ None,
5456
+ None,
5457
+ None,
5458
+ Some(&chat),
5459
+ None,
5460
+ None,
5461
+ None,
5462
+ true,
5463
+ false,
5464
+ |_| Ok(ApprovalOutcome::Denied),
5465
+ |e| events.push(e),
5466
+ |_| {},
5467
+ ),
5468
+ )
5469
+ .await;
5470
+ provider.abort();
5471
+ crate::mcp::close_mcp_session(&server);
5472
+ assert!(
5473
+ matches!(outcome, Ok(Ok(_))),
5474
+ "native={native}, {outcome:?}; requests={}, events={:?}",
5475
+ recorded.lock().unwrap().len(),
5476
+ events.iter().rev().take(5).collect::<Vec<_>>()
5477
+ );
5478
+ let requests = recorded.lock().unwrap();
5479
+ assert_eq!(requests.len(), 3);
5480
+ assert!(
5481
+ requests[1].to_string().contains("Move the mock axis"),
5482
+ "full tool definition missing from model request: {}",
5483
+ requests[1]
5484
+ );
5485
+ assert!(
5486
+ requests[2].to_string().contains("Move the mock axis"),
5487
+ "definition must remain in later requests"
5488
+ );
5489
+ assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1);
5490
+ std::fs::remove_file(marker).unwrap();
5491
+ assert_eq!(
5492
+ events
5493
+ .iter()
5494
+ .filter(
5495
+ |e| matches!(e,AgentProgress::ToolStart {action,..} if action=="mcp_tool")
5496
+ )
5497
+ .count(),
5498
+ 1
5499
+ );
5500
+ let memory = MemoryStore::open_default().unwrap();
5501
+ let _ = memory.delete_chat_session(&chat);
5502
+ }
5503
+ }
5504
+
5505
+ #[tokio::test]
5506
+ async fn device_command_waits_for_a_fresh_verified_observation() {
5507
+ let server = format!("device-{}", uuid::Uuid::new_v4());
5508
+ let mut cfg = MintConfig::default();
5509
+ 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}}));
5510
+ cfg.extra.insert(
5511
+ "allowedMcpTools".into(),
5512
+ serde_json::json!({server.clone():["*"]}),
5513
+ );
5514
+ 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();
5515
+ let mut events = Vec::new();
5516
+ let out = execute_tool_outcome(
5517
+ Path::new("."),
5518
+ &cfg,
5519
+ &decision,
5520
+ "device-test",
5521
+ &mut |_| Ok(ApprovalOutcome::Denied),
5522
+ &mut |e| events.push(e),
5523
+ "device-command",
5524
+ )
5525
+ .await
5526
+ .unwrap();
5527
+ crate::mcp::close_mcp_session(&server);
5528
+ assert_eq!(
5529
+ out.status,
5530
+ crate::integrations::mcp_result::ToolStatus::Success
5531
+ );
5532
+ let events = serde_json::to_value(events).unwrap();
5533
+ assert!(
5534
+ events
5535
+ .as_array()
5536
+ .unwrap()
5537
+ .iter()
5538
+ .any(|e| e["type"] == "DeviceState" && e["data"]["update"]["state"] == "verified"),
5539
+ "receipt must not be considered a verified device state: {events}"
5540
+ );
5541
+ }
5542
+
5543
+ #[tokio::test]
5544
+ async fn device_capture_preserves_verified_images_and_explicit_stop_confirms_zero() {
5545
+ let server = format!("device-photo-{}", uuid::Uuid::new_v4());
5546
+ let mut cfg = MintConfig::default();
5547
+ 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}}));
5548
+ cfg.extra.insert(
5549
+ "allowedMcpTools".into(),
5550
+ serde_json::json!({server.clone():["*"]}),
5551
+ );
5552
+ let photo:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"capture_photo","arguments":{"deviceId":"mock-device"}}})).unwrap();
5553
+ let out = execute_tool_outcome(
5554
+ Path::new("."),
5555
+ &cfg,
5556
+ &photo,
5557
+ "photo-test",
5558
+ &mut |_| Ok(ApprovalOutcome::Denied),
5559
+ &mut |_| {},
5560
+ "photo-command",
5561
+ )
5562
+ .await
5563
+ .unwrap();
5564
+ assert_eq!(
5565
+ out.status,
5566
+ crate::integrations::mcp_result::ToolStatus::Success
5567
+ );
5568
+ assert_eq!(out.images.len(), 1);
5569
+ assert!(out.warnings.is_empty());
5570
+ remove_test_artifacts(&out.artifacts);
5571
+ crate::mcp::call_mcp_tool_async(
5572
+ &cfg,
5573
+ &server,
5574
+ "set_speed",
5575
+ serde_json::json!({"deviceId":"mock-device","rpm":1000}),
5576
+ "photo-test",
5577
+ |_| {},
5578
+ )
5579
+ .await
5580
+ .unwrap();
5581
+ let stop:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"stop","arguments":{"deviceId":"mock-device"}}})).unwrap();
5582
+ let mut events = Vec::new();
5583
+ let out = execute_tool_outcome(
5584
+ Path::new("."),
5585
+ &cfg,
5586
+ &stop,
5587
+ "photo-test",
5588
+ &mut |_| Ok(ApprovalOutcome::Denied),
5589
+ &mut |e| events.push(e),
5590
+ "stop-command",
5591
+ )
5592
+ .await
5593
+ .unwrap();
5594
+ crate::mcp::close_mcp_session(&server);
5595
+ assert_eq!(
5596
+ out.status,
5597
+ crate::integrations::mcp_result::ToolStatus::Success
5598
+ );
5599
+ assert!(events.iter().any(|e|matches!(e,AgentProgress::DeviceState {update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified) && update.actual["rpm"]==0)));
5600
+ }
5601
+
5602
+ fn device_fixture(status: Value, timeout: u64) -> (MintConfig, String, PathBuf) {
5603
+ let name = format!("device-fault-{}", uuid::Uuid::new_v4());
5604
+ let log = std::env::temp_dir().join(format!("mint-device-calls-{}", uuid::Uuid::new_v4()));
5605
+ let schema = serde_json::json!({"type":"object","properties":{"deviceId":{"type":"string"},"operationId":{"type":"string"},"rpm":{"type":"integer"}},"required":["deviceId"],"additionalProperties":false});
5606
+ 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"}}]);
5607
+ let receipt = serde_json::json!({"deviceId":"mock","operationId":"one","state":"accepted","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":0}});
5608
+ let script = format!(
5609
+ r#"
5610
+ import sys,json
5611
+ tools=json.loads({:?});receipt=json.loads({:?});status=json.loads({:?})
5612
+ for line in sys.stdin:
5613
+ r=json.loads(line);m=r.get('method')
5614
+ if m=='initialize': result={{}}
5615
+ elif m=='tools/list': result={{'tools':tools}}
5616
+ elif m=='tools/call':
5617
+ with open({:?},'a') as f: f.write(json.dumps(r['params'])+'\n')
5618
+ result={{'structuredContent':receipt if r['params']['name']=='set_speed' else status,'content':[]}}
5619
+ else: continue
5620
+ print(json.dumps({{'jsonrpc':'2.0','id':r['id'],'result':result}}),flush=True)
5621
+ "#,
5622
+ tools.to_string(),
5623
+ receipt.to_string(),
5624
+ status.to_string(),
5625
+ log.to_string_lossy()
5626
+ );
5627
+ let mut cfg = MintConfig::default();
5628
+ cfg.extra.insert("mcpServers".into(),serde_json::json!({name.clone():{"command":"python3","args":["-u","-c",script],"timeoutSecs":timeout}}));
5629
+ cfg.extra.insert(
5630
+ "allowedMcpTools".into(),
5631
+ serde_json::json!({name.clone():["*"]}),
5632
+ );
5633
+ (cfg, name, log)
5634
+ }
5635
+
5636
+ #[tokio::test]
5637
+ async fn device_stale_observations_cannot_confirm_completion() {
5638
+ let (cfg, server, log) = device_fixture(
5639
+ serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":1,"target":{"rpm":100},"actual":{"rpm":100}}),
5640
+ 1,
5641
+ );
5642
+ 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();
5643
+ let out = execute_tool_outcome(
5644
+ Path::new("."),
5645
+ &cfg,
5646
+ &decision,
5647
+ "stale-test",
5648
+ &mut |_| Ok(ApprovalOutcome::Denied),
5649
+ &mut |_| {},
5650
+ "stale-command",
5651
+ )
5652
+ .await
5653
+ .unwrap();
5654
+ crate::mcp::close_mcp_session(&server);
5655
+ let calls = std::fs::read_to_string(&log).unwrap();
5656
+ std::fs::remove_file(log).unwrap();
5657
+ assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
5658
+ assert_eq!(
5659
+ out.status,
5660
+ crate::integrations::mcp_result::ToolStatus::TimedOut
5661
+ );
5662
+ assert!(
5663
+ out.completion_unconfirmed,
5664
+ "a dispatched command remains uncertain after stale observations"
5665
+ );
5666
+ }
5667
+
5668
+ #[tokio::test]
5669
+ async fn review_device_session_loss_preserves_receipt_without_reconnecting_or_repeating() {
5670
+ let (mut cfg, server, log) = device_fixture(
5671
+ serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":50}}),
5672
+ 5,
5673
+ );
5674
+ let starts = log.with_extension("starts");
5675
+ let script = cfg.extra["mcpServers"][&server]["args"][2]
5676
+ .as_str()
5677
+ .unwrap()
5678
+ .replace(
5679
+ "import sys,json",
5680
+ &format!(
5681
+ "import sys,json,os\nopen({:?},'a').write('start\\n')",
5682
+ starts.to_string_lossy()
5683
+ ),
5684
+ );
5685
+ cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(format!(
5686
+ "{script}\n if m=='tools/call' and r['params']['name']=='get_status': os._exit(0)\n"
5687
+ ));
5688
+ let decision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"set_speed","arguments":{"deviceId":"mock","rpm":100}}})).unwrap();
5689
+ let mut events = Vec::new();
5690
+ let out = execute_tool_outcome(
5691
+ Path::new("."),
5692
+ &cfg,
5693
+ &decision,
5694
+ "review-device-loss",
5695
+ &mut |_| Ok(ApprovalOutcome::Denied),
5696
+ &mut |e| events.push(e),
5697
+ "command",
5698
+ )
5699
+ .await
5700
+ .unwrap();
5701
+ crate::mcp::close_mcp_session(&server);
5702
+ let calls = std::fs::read_to_string(&log).unwrap();
5703
+ assert_eq!(std::fs::read_to_string(&starts).unwrap(), "start\n");
5704
+ let _ = std::fs::remove_file(log);
5705
+ let _ = std::fs::remove_file(starts);
5706
+ assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
5707
+ assert_eq!(
5708
+ calls.lines().filter(|l| l.contains("get_status")).count(),
5709
+ 1
5710
+ );
5711
+ assert_eq!(out.status, crate::mcp_result::ToolStatus::Failed);
5712
+ assert!(out.completion_unconfirmed);
5713
+ assert!(
5714
+ out.text.contains("one"),
5715
+ "operation receipt lost: {}",
5716
+ out.text
5717
+ );
5718
+ 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"))));
5719
+ assert!(!events.iter().any(|e|matches!(e,AgentProgress::DeviceState{update,..} if matches!(update.state,crate::mcp::device::DevicePhase::Verified))));
5720
+ }
5721
+
5722
+ #[tokio::test]
5723
+ async fn device_wrong_operation_and_physical_failure_never_report_success() {
5724
+ for status in [
5725
+ serde_json::json!({"deviceId":"mock","operationId":"other","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
5726
+ serde_json::json!({"deviceId":"mock","operationId":"one","state":"failed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":0},"error":"jammed"}),
5727
+ ] {
5728
+ let (cfg, server, log) = device_fixture(status, 3);
5729
+ 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();
5730
+ let out = execute_tool_outcome(
5731
+ Path::new("."),
5732
+ &cfg,
5733
+ &decision,
5734
+ "wrong-operation",
5735
+ &mut |_| Ok(ApprovalOutcome::Denied),
5736
+ &mut |_| {},
5737
+ "wrong-command",
5738
+ )
5739
+ .await
5740
+ .unwrap();
5741
+ crate::mcp::close_mcp_session(&server);
5742
+ std::fs::remove_file(log).unwrap();
5743
+ assert_eq!(
5744
+ out.status,
5745
+ crate::integrations::mcp_result::ToolStatus::Failed
5746
+ );
5747
+ assert!(
5748
+ out.text.starts_with("Error:"),
5749
+ "Native tool results must carry an error marker for failed verification: {}",
5750
+ out.text
5751
+ );
5752
+ assert!(
5753
+ !out.text.contains("no tool was executed"),
5754
+ "verification errors must acknowledge the already dispatched command: {}",
5755
+ out.text
5756
+ );
5757
+ }
5758
+ }
5759
+
5760
+ #[tokio::test]
5761
+ async fn device_failed_receipt_is_terminal_without_status_polling() {
5762
+ let (mut cfg, server, log) = device_fixture(
5763
+ serde_json::json!({"deviceId":"mock","operationId":"one","state":"completed","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":100}}),
5764
+ 3,
5765
+ );
5766
+ let script = cfg.extra["mcpServers"][&server]["args"][2]
5767
+ .as_str()
5768
+ .unwrap()
5769
+ .replace("accepted", "failed");
5770
+ cfg.extra.get_mut("mcpServers").unwrap()[&server]["args"][2] = serde_json::json!(script);
5771
+ 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();
5772
+ let out = execute_tool_outcome(
5773
+ Path::new("."),
5774
+ &cfg,
5775
+ &decision,
5776
+ "failed-receipt",
5777
+ &mut |_| Ok(ApprovalOutcome::Denied),
5778
+ &mut |_| {},
5779
+ "failed-command",
5780
+ )
5781
+ .await
5782
+ .unwrap();
5783
+ crate::mcp::close_mcp_session(&server);
5784
+ let calls = std::fs::read_to_string(&log).unwrap();
5785
+ std::fs::remove_file(log).unwrap();
5786
+ assert_eq!(
5787
+ out.status,
5788
+ crate::integrations::mcp_result::ToolStatus::Failed
5789
+ );
5790
+ assert!(
5791
+ !calls.contains("get_status"),
5792
+ "failed receipt must not be overwritten by a later status"
5793
+ );
5794
+ }
5795
+
5796
+ #[tokio::test]
5797
+ async fn device_denied_status_access_blocks_movement_before_dispatch() {
5798
+ let (mut cfg, server, log) = device_fixture(serde_json::json!({}), 3);
5799
+ cfg.extra.insert(
5800
+ "allowedMcpTools".into(),
5801
+ serde_json::json!({server.clone():["set_speed"]}),
5802
+ );
5803
+ 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();
5804
+ let mut approvals = Vec::new();
5805
+ let out = execute_tool_outcome(
5806
+ Path::new("."),
5807
+ &cfg,
5808
+ &decision,
5809
+ "denied-status",
5810
+ &mut |a| {
5811
+ approvals.push(serde_json::to_value(a).unwrap());
5812
+ Ok(ApprovalOutcome::Denied)
5813
+ },
5814
+ &mut |_| {},
5815
+ "blocked-command",
5816
+ )
5817
+ .await
5818
+ .unwrap();
5819
+ crate::mcp::close_mcp_session(&server);
5820
+ assert!(
5821
+ !log.exists(),
5822
+ "command was dispatched without access to status verification"
5823
+ );
5824
+ assert_eq!(
5825
+ out.status,
5826
+ crate::integrations::mcp_result::ToolStatus::Failed
5827
+ );
5828
+ assert!(
5829
+ serde_json::to_string(&approvals)
5830
+ .unwrap()
5831
+ .contains("get_status")
5832
+ );
5833
+ }
5834
+
5835
+ #[tokio::test]
5836
+ async fn canceling_between_device_observations_does_not_issue_another_command() {
5837
+ let (cfg, server, log) = device_fixture(
5838
+ serde_json::json!({"deviceId":"mock","operationId":"one","state":"running","observationSeq":2,"target":{"rpm":100},"actual":{"rpm":20}}),
5839
+ 15,
5840
+ );
5841
+ 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();
5842
+ let chat = format!("cancel-between-{}", uuid::Uuid::new_v4());
5843
+ let name = chat.clone();
5844
+ let (tx, rx) = tokio::sync::oneshot::channel();
5845
+ let mut tx = Some(tx);
5846
+ let running = tokio::spawn(async move {
5847
+ execute_tool_outcome(
5848
+ Path::new("."),
5849
+ &cfg,
5850
+ &decision,
5851
+ &name,
5852
+ &mut |_| Ok(ApprovalOutcome::Denied),
5853
+ &mut |e| {
5854
+ if matches!(
5855
+ e,
5856
+ AgentProgress::DeviceState {
5857
+ update: crate::mcp::device::DeviceUpdate {
5858
+ state: crate::mcp::device::DevicePhase::Running,
5859
+ ..
5860
+ },
5861
+ ..
5862
+ }
5863
+ ) && let Some(tx) = tx.take()
5864
+ {
5865
+ let _ = tx.send(());
5866
+ }
5867
+ },
5868
+ "cancel-command",
5869
+ )
5870
+ .await
5871
+ .unwrap()
5872
+ });
5873
+ tokio::time::timeout(Duration::from_secs(10), rx)
5874
+ .await
5875
+ .unwrap()
5876
+ .unwrap();
5877
+ tokio::time::sleep(Duration::from_millis(100)).await;
5878
+ assert!(crate::mcp::cancel_mcp_calls(&chat) > 0);
5879
+ let out = tokio::time::timeout(Duration::from_secs(2), running)
5880
+ .await
5881
+ .unwrap()
5882
+ .unwrap();
5883
+ crate::mcp::close_mcp_session(&server);
5884
+ assert_eq!(
5885
+ out.status,
5886
+ crate::integrations::mcp_result::ToolStatus::Cancelled
5887
+ );
5888
+ let calls = std::fs::read_to_string(&log).unwrap();
5889
+ std::fs::remove_file(log).unwrap();
5890
+ assert_eq!(calls.lines().filter(|l| l.contains("set_speed")).count(), 1);
5891
+ assert!(!calls.contains("\"stop\""));
5892
+ }
5893
+
5894
+ #[tokio::test]
5895
+ async fn approval_feedback_does_not_mark_an_unexecuted_mcp_call_successful() {
5896
+ let decision: AgentDecision = serde_json::from_value(serde_json::json!({
5897
+ "action":"mcp_tool", "input":{"server":"feedback-server","tool":"render","arguments":{}}
5898
+ }))
5899
+ .unwrap();
5900
+ let mut config = MintConfig::default();
5901
+ config.extra.insert("mcpServers".into(),serde_json::json!({"feedback-server":{"command":"python3","args":["-u","-c",generic_server("render",serde_json::json!({}))]}}));
5902
+ let out = execute_tool_outcome(
5903
+ Path::new("."),
5904
+ &config,
5905
+ &decision,
5906
+ "feedback",
5907
+ &mut |_| {
5908
+ Ok(ApprovalOutcome::Intercepted(
5909
+ "Use a different device".into(),
5910
+ ))
5911
+ },
5912
+ &mut |_| {},
5913
+ "not-executed",
5914
+ )
5915
+ .await
5916
+ .unwrap();
5917
+ assert!(out.text.contains("Use a different device"));
5918
+ assert!(out.text.contains("no command was executed"));
5919
+ assert_eq!(
5920
+ out.status,
5921
+ crate::integrations::mcp_result::ToolStatus::Failed
5922
+ );
5923
+ }
5924
+
5925
+ #[tokio::test]
5926
+ async fn text_only_model_keeps_artifacts_without_receiving_image_blocks() {
5927
+ use base64::Engine as _;
5928
+ let mut bytes = std::io::Cursor::new(Vec::new());
5929
+ image::DynamicImage::new_rgb8(1, 1)
5930
+ .write_to(&mut bytes, image::ImageFormat::Png)
5931
+ .unwrap();
5932
+ let encoded = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
5933
+ let response =
5934
+ serde_json::json!({"content":[{"type":"image","mimeType":"image/png","data":encoded}]})
5935
+ .to_string();
5936
+ let script = generic_server("preview", serde_json::from_str(&response).unwrap());
5937
+ let server = format!("text-model-{}", uuid::Uuid::new_v4());
5938
+ let mut cfg = MintConfig::default();
5939
+ cfg.extra.insert(
5940
+ "mcpServers".into(),
5941
+ serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
5942
+ );
5943
+ cfg.extra.insert(
5944
+ "allowedMcpTools".into(),
5945
+ serde_json::json!({server.clone():["preview"]}),
5946
+ );
5947
+ cfg.extra.insert("mcpImageInput".into(), Value::Bool(false));
5948
+ let decision:AgentDecision=serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
5949
+ let out = execute_tool_outcome(
5950
+ Path::new("."),
5951
+ &cfg,
5952
+ &decision,
5953
+ "text-only",
5954
+ &mut |_| Ok(ApprovalOutcome::Denied),
5955
+ &mut |_| {},
5956
+ "preview",
5957
+ )
5958
+ .await
5959
+ .unwrap();
5960
+ assert!(out.images.is_empty());
5961
+ assert_eq!(out.artifacts.len(), 1);
5962
+ assert!(out.text.contains("do not claim visual inspection"));
5963
+ crate::mcp::close_mcp_session(&server);
5964
+ remove_test_artifacts(&out.artifacts);
5965
+ }
5966
+
5967
+ #[test]
5968
+ fn multiple_images_attach_to_the_correct_native_tool_result() {
5969
+ let images = std::collections::HashMap::from([(
5970
+ "call-2".to_string(),
5971
+ vec![
5972
+ "data:image/png;base64,first".into(),
5973
+ "data:image/png;base64,second".into(),
5974
+ ],
5975
+ )]);
5976
+ let results = vec![
5977
+ (
5978
+ "call-1".into(),
5979
+ "mcp_tool".into(),
5980
+ Value::Null,
5981
+ "one".into(),
5982
+ )
5983
+ .into(),
5984
+ (
5985
+ "call-2".into(),
5986
+ "mcp_tool".into(),
5987
+ Value::Null,
5988
+ "two".into(),
5989
+ )
5990
+ .into(),
5991
+ ];
5992
+ let mut messages = Vec::new();
5993
+ append_native_tool_results(&mut messages, "", &results, &Default::default(), &images);
5994
+ assert!(
5995
+ matches!(&messages[1].content[1],ContentBlock::ToolResult {tool_use_id,..} if tool_use_id=="call-2")
5996
+ );
5997
+ assert!(
5998
+ matches!(&messages[1].content[2],ContentBlock::Image {data_uri} if data_uri.ends_with("first"))
5999
+ );
6000
+ assert!(
6001
+ matches!(&messages[1].content[3],ContentBlock::Image {data_uri} if data_uri.ends_with("second"))
6002
+ );
6003
+ }
6004
+
6005
+ #[tokio::test]
6006
+ async fn mcp_images_do_not_enter_the_text_observation() {
6007
+ let server = format!("image-{}", uuid::Uuid::new_v4());
6008
+ let mut bytes = std::io::Cursor::new(Vec::new());
6009
+ image::DynamicImage::new_rgb8(1, 1)
6010
+ .write_to(&mut bytes, image::ImageFormat::Png)
6011
+ .unwrap();
6012
+ let png = BASE64_STANDARD.encode(bytes.into_inner());
6013
+ let result = serde_json::json!({"content":[{"type":"text","text":"preview"},{"type":"image","mimeType":"image/png","data":png}]}).to_string();
6014
+ let script = generic_server("preview", serde_json::from_str(&result).unwrap());
6015
+ let mut config = MintConfig::default();
6016
+ config.extra.insert(
6017
+ "mcpServers".into(),
6018
+ serde_json::json!({server.clone():{"command":"python3","args":["-u","-c",script]}}),
6019
+ );
6020
+ let decision: AgentDecision = serde_json::from_value(serde_json::json!({"action":"mcp_tool","input":{"server":server,"tool":"preview","arguments":{}}})).unwrap();
6021
+ let mut events = Vec::new();
6022
+ let out = execute_tool_outcome(
6023
+ Path::new("."),
6024
+ &config,
6025
+ &decision,
6026
+ "image-test",
6027
+ &mut |_| Ok(ApprovalOutcome::Approved),
6028
+ &mut |event| events.push(event),
6029
+ "image-invocation",
6030
+ )
6031
+ .await
6032
+ .unwrap();
6033
+ assert!(
6034
+ !out.text.contains(&png),
6035
+ "binary data must be removed from the model's text observation"
6036
+ );
6037
+ assert_eq!(
6038
+ out.status,
6039
+ crate::integrations::mcp_result::ToolStatus::Success
6040
+ );
6041
+ assert_eq!(out.images.len(), 1);
6042
+ assert_eq!(out.artifacts[0].call_id, "image-invocation");
6043
+ assert!(out.text.contains("preview"));
6044
+ assert!(!serde_json::to_string(&events).unwrap().contains(&png));
6045
+ assert!(
6046
+ !crate::mcp::is_mcp_tool_allowed(&config, &server, "preview"),
6047
+ "one-call approval must not persist a grant"
6048
+ );
6049
+ crate::mcp::close_mcp_session(&server);
6050
+ remove_test_artifacts(&out.artifacts);
6051
+ }
6052
+
6053
+ fn remove_test_artifacts(artifacts: &[crate::integrations::mcp_result::McpArtifact]) {
6054
+ let directory = crate::integrations::mcp_result::artifact_directory().unwrap();
6055
+ for artifact in artifacts {
6056
+ std::fs::remove_file(directory.join(&artifact.id)).unwrap();
6057
+ std::fs::remove_file(directory.join(format!("{}.json", artifact.id))).unwrap();
6058
+ }
6059
+ }
6060
+
6061
+ // A transport-successful tool failure must not become a successful agent step.
6062
+ #[tokio::test]
6063
+ async fn mcp_tool_failure_is_visible_to_the_agent() {
6064
+ let server = format!("outcome-{}", uuid::Uuid::new_v4());
6065
+ let mut config = MintConfig::default();
6066
+ 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"}]}))]}}));
6067
+ config.extra.insert(
6068
+ "allowedMcpTools".into(),
6069
+ serde_json::json!({server.clone():["render"]}),
6070
+ );
6071
+ let decision: AgentDecision = serde_json::from_value(serde_json::json!({
6072
+ "thought":"render", "action":"mcp_tool", "input":{"server":server,"tool":"render","arguments":{}}
6073
+ })).unwrap();
6074
+ let result = execute_tool(
6075
+ Path::new("."),
6076
+ &config,
6077
+ &decision,
6078
+ "outcome-test",
6079
+ &mut |_| Ok(ApprovalOutcome::Denied),
6080
+ &mut |_| {},
6081
+ )
6082
+ .await
6083
+ .unwrap();
6084
+ assert!(
6085
+ result.starts_with("Error:"),
6086
+ "tool failure must reach the model as a failure: {result}"
6087
+ );
6088
+ assert!(result.contains("render failed"));
6089
+ crate::mcp::close_mcp_session(&server);
6090
+ }
6091
+ }