@pheem49/mint 1.14.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 (925) hide show
  1. package/Cargo.lock +1258 -35
  2. package/Cargo.toml +5 -2
  3. package/LICENSE +21 -654
  4. package/README.md +93 -756
  5. package/Release_Note.md +53 -446
  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 +9 -1
  13. package/crates/mint-cli/src/agent/approval_prompts.rs +16 -3
  14. package/crates/mint-cli/src/agent/live_status.rs +545 -148
  15. package/crates/mint-cli/src/agent/markdown_render.rs +601 -61
  16. package/crates/mint-cli/src/agent.rs +723 -258
  17. package/crates/mint-cli/src/commands/agent.rs +83 -74
  18. package/crates/mint-cli/src/commands/eval.rs +9 -5
  19. package/crates/mint-cli/src/commands/git.rs +204 -0
  20. package/crates/mint-cli/src/commands/integrations.rs +35 -0
  21. package/crates/mint-cli/src/commands/knowledge.rs +13 -32
  22. package/crates/mint-cli/src/commands/mod.rs +81 -2
  23. package/crates/mint-cli/src/commands/tailnet_web.rs +235 -0
  24. package/crates/mint-cli/src/image.rs +32 -11
  25. package/crates/mint-cli/src/interactive/chat_tui.rs +7853 -0
  26. package/crates/mint-cli/src/interactive/command_ui.rs +477 -0
  27. package/crates/mint-cli/src/interactive/format.rs +8 -0
  28. package/crates/mint-cli/src/interactive/input_box.rs +94 -8
  29. package/crates/mint-cli/src/interactive/picker.rs +180 -37
  30. package/crates/mint-cli/src/interactive/resume_picker.rs +686 -0
  31. package/crates/mint-cli/src/interactive/slash_commands.rs +1071 -501
  32. package/crates/mint-cli/src/interactive/thought_viewer.rs +286 -0
  33. package/crates/mint-cli/src/interactive/tool_activity.rs +328 -0
  34. package/crates/mint-cli/src/interactive.rs +454 -166
  35. package/crates/mint-cli/src/main.rs +93 -26
  36. package/crates/mint-cli/src/markdown.rs +12 -5
  37. package/crates/mint-cli/src/mcp.rs +5 -0
  38. package/crates/mint-cli/src/mod_host.rs +6 -0
  39. package/crates/mint-cli/src/mods_cli.rs +62 -0
  40. package/crates/mint-cli/src/mods_ui.rs +187 -0
  41. package/crates/mint-cli/src/onboard.rs +37 -61
  42. package/crates/mint-cli/src/plugins_cli.rs +8 -5
  43. package/crates/mint-cli/src/setup.rs +10 -8
  44. package/crates/mint-cli/src/terminal_theme.rs +189 -0
  45. package/crates/mint-cli/src/updater.rs +112 -23
  46. package/crates/mint-cli/tests/library_intake.rs +77 -0
  47. package/crates/mint-cli/tests/mods_testing.rs +133 -0
  48. package/crates/mint-companion-protocol/Cargo.toml +10 -0
  49. package/crates/mint-companion-protocol/src/lib.rs +163 -0
  50. package/crates/mint-core/Cargo.toml +6 -3
  51. package/crates/mint-core/build.rs +26 -0
  52. package/crates/mint-core/src/agent/agent_loop.rs +1 -0
  53. package/crates/mint-core/src/agent/chat.rs +1003 -209
  54. package/crates/mint-core/src/agent/code_tools.rs +98 -2
  55. package/crates/mint-core/src/agent/memory/sources.rs +240 -0
  56. package/crates/mint-core/src/agent/memory.rs +805 -33
  57. package/crates/mint-core/src/agent/skill_inventory.rs +325 -0
  58. package/crates/mint-core/src/agent/skills.rs +437 -90
  59. package/crates/mint-core/src/agent.rs +1 -0
  60. package/crates/mint-core/src/api_server/routes/background_jobs.rs +310 -0
  61. package/crates/mint-core/src/api_server/routes/chat.rs +233 -190
  62. package/crates/mint-core/src/api_server/routes/html_preview.rs +77 -0
  63. package/crates/mint-core/src/api_server/routes/library.rs +97 -0
  64. package/crates/mint-core/src/api_server/routes/linked_folders.rs +118 -3
  65. package/crates/mint-core/src/api_server/routes/media_gen.rs +10 -57
  66. package/crates/mint-core/src/api_server/routes/misc.rs +114 -121
  67. package/crates/mint-core/src/api_server/routes/mod.rs +6 -0
  68. package/crates/mint-core/src/api_server/routes/mods.rs +31 -0
  69. package/crates/mint-core/src/api_server/routes/sessions.rs +85 -0
  70. package/crates/mint-core/src/api_server/routes/skills_subagents.rs +12 -42
  71. package/crates/mint-core/src/api_server/routes/slash.rs +50 -1
  72. package/crates/mint-core/src/api_server/routes/sources.rs +101 -0
  73. package/crates/mint-core/src/api_server.rs +140 -12
  74. package/crates/mint-core/src/avatar_bridge.rs +13 -2
  75. package/crates/mint-core/src/browser/cdp.rs +74 -83
  76. package/crates/mint-core/src/browser/evidence.rs +260 -0
  77. package/crates/mint-core/src/browser/input.rs +1 -1
  78. package/crates/mint-core/src/browser/interact.rs +98 -124
  79. package/crates/mint-core/src/browser/lifecycle.rs +75 -4
  80. package/crates/mint-core/src/browser/mod.rs +16 -2
  81. package/crates/mint-core/src/browser/navigate.rs +27 -19
  82. package/crates/mint-core/src/browser/observe.js +40 -0
  83. package/crates/mint-core/src/browser/session.rs +1610 -0
  84. package/crates/mint-core/src/companion.rs +716 -0
  85. package/crates/mint-core/src/cron/scheduler.rs +34 -2
  86. package/crates/mint-core/src/git/branches.rs +348 -0
  87. package/crates/mint-core/src/git/checkpoint.rs +40 -0
  88. package/crates/mint-core/src/git/diff.rs +206 -0
  89. package/crates/mint-core/src/git/mod.rs +10 -2
  90. package/crates/mint-core/src/integrations/channels.rs +88 -345
  91. package/crates/mint-core/src/integrations/mcp.rs +397 -50
  92. package/crates/mint-core/src/integrations/mcp_calls.rs +823 -0
  93. package/crates/mint-core/src/integrations/mcp_catalog.rs +267 -0
  94. package/crates/mint-core/src/integrations/mcp_device.rs +319 -0
  95. package/crates/mint-core/src/integrations/mcp_result.rs +356 -0
  96. package/crates/mint-core/src/integrations/webhooks.rs +539 -0
  97. package/crates/mint-core/src/integrations.rs +2 -0
  98. package/crates/mint-core/src/lib.rs +107 -40
  99. package/crates/mint-core/src/live_sync.rs +28 -9
  100. package/crates/mint-core/src/media/auto_shorts.rs +1 -0
  101. package/crates/mint-core/src/media/gemini_live.rs +145 -14
  102. package/crates/mint-core/src/media/image_models.rs +3 -27
  103. package/crates/mint-core/src/media/image_workflow.rs +216 -0
  104. package/crates/mint-core/src/media/library/intake.rs +144 -0
  105. package/crates/mint-core/src/media/library/migration.rs +56 -0
  106. package/crates/mint-core/src/media/library/removal.rs +55 -0
  107. package/crates/mint-core/src/media/library/tests.rs +730 -0
  108. package/crates/mint-core/src/media/library/thumbnails.rs +72 -0
  109. package/crates/mint-core/src/media/library.rs +545 -0
  110. package/crates/mint-core/src/media/mic_transcribe.rs +1 -0
  111. package/crates/mint-core/src/media/model_catalog.rs +375 -0
  112. package/crates/mint-core/src/media/pictures.rs +79 -222
  113. package/crates/mint-core/src/media/subtitle.rs +1 -0
  114. package/crates/mint-core/src/media/video_models.rs +3 -22
  115. package/crates/mint-core/src/media.rs +3 -0
  116. package/crates/mint-core/src/mods/manager.rs +1124 -0
  117. package/crates/mint-core/src/mods/mod.rs +569 -0
  118. package/crates/mint-core/src/mods/operations.rs +204 -0
  119. package/crates/mint-core/src/mods/session.rs +146 -0
  120. package/crates/mint-core/src/mods/slash.rs +59 -0
  121. package/crates/mint-core/src/mods/telemetry.rs +306 -0
  122. package/crates/mint-core/src/orchestration/approvals.rs +344 -0
  123. package/crates/mint-core/src/orchestration/context_render.rs +105 -0
  124. package/crates/mint-core/src/orchestration/mcp_review.rs +153 -0
  125. package/crates/mint-core/src/orchestration/memory_skill.rs +110 -33
  126. package/crates/mint-core/src/orchestration/mod.rs +3129 -481
  127. package/crates/mint-core/src/orchestration/mod_authoring_regressions.rs +209 -0
  128. package/crates/mint-core/src/orchestration/progress_clock.rs +80 -0
  129. package/crates/mint-core/src/orchestration/repeated_failures.rs +116 -0
  130. package/crates/mint-core/src/orchestration/review_regressions.rs +449 -0
  131. package/crates/mint-core/src/orchestration/run_control.rs +196 -0
  132. package/crates/mint-core/src/orchestration/tools/browser.rs +16 -139
  133. package/crates/mint-core/src/orchestration/tools/files.rs +161 -14
  134. package/crates/mint-core/src/orchestration/tools/media.rs +37 -41
  135. package/crates/mint-core/src/orchestration/tools/misc.rs +5 -3
  136. package/crates/mint-core/src/orchestration/tools/mod.rs +2 -0
  137. package/crates/mint-core/src/orchestration/tools/mods.rs +161 -0
  138. package/crates/mint-core/src/orchestration/tools/plugins_mcp.rs +303 -71
  139. package/crates/mint-core/src/orchestration/tools/shell.rs +95 -2
  140. package/crates/mint-core/src/orchestration/tools/web.rs +1 -1
  141. package/crates/mint-core/src/orchestration/workspace_helpers.rs +4 -1
  142. package/crates/mint-core/src/prompts/agent.rs +75 -31
  143. package/crates/mint-core/src/prompts/chat.rs +1 -1
  144. package/crates/mint-core/src/prompts/persona.rs +1 -1
  145. package/crates/mint-core/src/prompts/tool_catalog.rs +121 -27
  146. package/crates/mint-core/src/search/knowledge.rs +7 -1
  147. package/crates/mint-core/src/search/linked_folders/index.rs +896 -0
  148. package/crates/mint-core/src/search/linked_folders.rs +473 -131
  149. package/crates/mint-core/src/slash/catalog.rs +1 -1
  150. package/crates/mint-core/src/slash/mod.rs +644 -33
  151. package/crates/mint-core/src/slash/model_fetcher.rs +12 -3
  152. package/crates/mint-core/src/slash/models.rs +66 -10
  153. package/crates/mint-core/src/slash/mods.rs +155 -0
  154. package/crates/mint-core/src/system/bg_shell.rs +1129 -95
  155. package/crates/mint-core/src/system/config.rs +235 -10
  156. package/crates/mint-core/src/system/html_preview.rs +398 -0
  157. package/crates/mint-core/src/system/mint_web.rs +164 -0
  158. package/crates/mint-core/src/system/release_notes.rs +18 -0
  159. package/crates/mint-core/src/system/shell.rs +21 -6
  160. package/crates/mint-core/src/system/workspace.rs +292 -0
  161. package/crates/mint-core/src/system/workspace_history.rs +604 -0
  162. package/crates/mint-core/src/system.rs +5 -0
  163. package/crates/mint-core/tests/background_retention.rs +34 -0
  164. package/crates/mint-core/tests/background_shutdown.rs +24 -0
  165. package/crates/mint-core/tests/library_http.rs +209 -0
  166. package/crates/mint-core/tests/mcp_contract.rs +283 -0
  167. package/crates/mint-core/tests/mcp_stdio.rs +11 -10
  168. package/crates/mint-core/tests/media_model_catalog_http.rs +403 -0
  169. package/crates/mint-core/tests/memory_persistence.rs +39 -0
  170. package/crates/mint-core/tests/mods_http.rs +215 -0
  171. package/crates/mint-core/tests/terminal_shutdown.rs +109 -0
  172. package/crates/mint-mod-runtime/Cargo.toml +18 -0
  173. package/crates/mint-mod-runtime/src/lib.rs +507 -0
  174. package/crates/mint-mod-runtime/src/sdk.js +56 -0
  175. package/crates/mint-mod-runtime/src/test_sdk.js +32 -0
  176. package/crates/mint-mod-runtime/src/testing.rs +94 -0
  177. package/crates/mint-mod-runtime/tests/runtime.rs +156 -0
  178. package/docs/mods/AUTHORING.md +61 -0
  179. package/docs/mods/README.md +139 -0
  180. package/docs/mods/TESTING.md +56 -0
  181. package/docs/mods/mint-mods.d.ts +66 -0
  182. package/examples/device-mcp/README.md +22 -0
  183. package/examples/device-mcp/contract.md +49 -0
  184. package/examples/device-mcp/mock-device.mjs +66 -0
  185. package/examples/device-mcp/server.mjs +48 -0
  186. package/examples/device-mcp/server.test.mjs +78 -0
  187. package/examples/mods/task-checklist/README.md +1 -0
  188. package/examples/mods/task-checklist/main.ts +17 -0
  189. package/examples/mods/task-checklist/manifest.json +1 -0
  190. package/examples/mods/task-checklist/tests/checklist.test.ts +15 -0
  191. package/examples/mods/token-meter/README.md +1 -0
  192. package/examples/mods/token-meter/main.ts +8 -0
  193. package/examples/mods/token-meter/manifest.json +1 -0
  194. package/examples/mods/tool-guard/README.md +1 -0
  195. package/examples/mods/tool-guard/main.ts +9 -0
  196. package/examples/mods/tool-guard/manifest.json +1 -0
  197. package/out/web/assets/CliSessionsView-CtzUJpK_.css +1 -0
  198. package/out/web/assets/CliSessionsView-D7hw0Mgj.js +1 -0
  199. package/out/web/assets/CodeReviewPage-C7bEOJfd.js +4 -0
  200. package/out/web/assets/CodeReviewPage-Dte9uLiL.css +1 -0
  201. package/out/web/assets/CommandPalette-0RrF-AOT.js +1 -0
  202. package/out/web/assets/ImageStudioPanel-CdxkAf_r.js +1 -0
  203. package/out/web/assets/ImageStudioPanel-D0lnhQV5.css +1 -0
  204. package/out/web/assets/LibraryPage--Qfisp6D.js +2 -0
  205. package/out/web/assets/LibraryPage-DAxUTAg7.css +1 -0
  206. package/out/web/assets/LinkedFoldersView-2nfZbDgN.js +1 -0
  207. package/out/web/assets/McpRegistryPicker-h9uS8_wT.js +1 -0
  208. package/out/web/assets/McpServersView-slfQJzNB.js +1 -0
  209. package/out/web/assets/MintDashboard-BSR5XpXh.js +25 -0
  210. package/out/web/assets/MintDashboard-CWUcCGhd.css +1 -0
  211. package/out/web/assets/PluginsView-l7LSm503.js +1 -0
  212. package/out/web/assets/ScheduledTasksView-CttmLbq1.js +1 -0
  213. package/out/web/assets/SettingsWindow-BCMLYvEV.js +4 -0
  214. package/out/web/assets/SettingsWindow-DcMVmU9u.css +1 -0
  215. package/out/web/assets/SkillsView-rNs1qr_U.js +2 -0
  216. package/out/web/assets/ToolSurfacePage-B9DzEPWN.js +37 -0
  217. package/out/web/assets/ToolSurfacePage-CTfgggF2.css +1 -0
  218. package/out/web/assets/VeoStudioPanel-DOj7_WR3.css +1 -0
  219. package/out/web/assets/VeoStudioPanel-DiJs_Cg2.js +4 -0
  220. package/out/web/assets/WorkspacePanel-CbJ1PFoZ.js +1 -0
  221. package/out/web/assets/WorkspaceSelector-B2xIp04D.js +1 -0
  222. package/out/web/assets/WorkspaceSelector-Ca8uJOsS.css +1 -0
  223. package/out/web/assets/{abnfDiagram-VCTEODGH-DAOBA7ZG.js → abnfDiagram-VCTEODGH-CD0CjoGl.js} +1 -1
  224. package/out/web/assets/{architecture-7GRP2DOG-CgSwQOPQ.js → architecture-7GRP2DOG-RKV83OeZ.js} +1 -1
  225. package/out/web/assets/{architectureDiagram-5GKGNRK7-CYp3yupP.js → architectureDiagram-5GKGNRK7-nN_HAFct.js} +1 -1
  226. package/out/web/assets/{blockDiagram-NRAW4CY4-CyWybDWr.js → blockDiagram-NRAW4CY4-BrrqzHW5.js} +1 -1
  227. package/out/web/assets/{c4Diagram-UCG6FXSJ-BvY3U709.js → c4Diagram-UCG6FXSJ-DTdV8_zE.js} +1 -1
  228. package/out/web/assets/{chunk-75Z2AOVW-TLXCoZYv.js → chunk-75Z2AOVW-_M0z5olV.js} +1 -1
  229. package/out/web/assets/chunk-CKBBP62Z-BpQEgeHF.js +2 -0
  230. package/out/web/assets/{chunk-CLGD4ZFX-xjsKtNS7.js → chunk-CLGD4ZFX-D54yGvGS.js} +1 -1
  231. package/out/web/assets/chunk-DSNCTWBM-C-gvT2ru.js +2 -0
  232. package/out/web/assets/chunk-DU6HZSFF-CnFngIhW.js +128 -0
  233. package/out/web/assets/{chunk-F27PBJKO-BFpgF55A.js → chunk-F27PBJKO-BqtWrnEO.js} +1 -1
  234. package/out/web/assets/{chunk-G27WJ6UU-DfF7qW-C.js → chunk-G27WJ6UU-Ckoq5mi2.js} +1 -1
  235. package/out/web/assets/{chunk-GMAD6QVW-C-558kh_.js → chunk-GMAD6QVW-BuhEcgHq.js} +1 -1
  236. package/out/web/assets/{chunk-JQ64N6SF-wJmeGKAw.js → chunk-JQ64N6SF-CF8T7GiZ.js} +1 -1
  237. package/out/web/assets/{chunk-LCL6LL3I-C1wHD8Ie.js → chunk-LCL6LL3I-BhXV9zyH.js} +1 -1
  238. package/out/web/assets/{chunk-OBVCFTLP-BsRTPBqs.js → chunk-OBVCFTLP-CRIkunUB.js} +1 -1
  239. package/out/web/assets/{chunk-P2QGCYS3-HZIiM7Ni.js → chunk-P2QGCYS3-UFEU6BxZ.js} +1 -1
  240. package/out/web/assets/{chunk-POPQ4Y6H-Dk9wvVb0.js → chunk-POPQ4Y6H-CQcrNbjr.js} +1 -1
  241. package/out/web/assets/{chunk-PWAF6VOD-BmjYLNbq.js → chunk-PWAF6VOD-Cc2uVDsG.js} +1 -1
  242. package/out/web/assets/{chunk-R7TYR2AO-CaHIf-lK.js → chunk-R7TYR2AO-DiCxzdAY.js} +1 -1
  243. package/out/web/assets/{chunk-RHFEMEQ7-_T561hOU.js → chunk-RHFEMEQ7-b7hnPmrR.js} +1 -1
  244. package/out/web/assets/{chunk-SVP7TREG-CM-GGYoY.js → chunk-SVP7TREG-Bu_yYb-V.js} +1 -1
  245. package/out/web/assets/{chunk-TEH6E4GO-K2A8Vg2C.js → chunk-TEH6E4GO-G4Cdlyrt.js} +1 -1
  246. package/out/web/assets/classDiagram-DTDB5LWJ-RnC2mYO4.js +1 -0
  247. package/out/web/assets/classDiagram-v2-JRS7N3AN-RnC2mYO4.js +1 -0
  248. package/out/web/assets/config-CnvCChUM.js +2 -0
  249. package/out/web/assets/config-DM6oIVO3.css +1 -0
  250. package/out/web/assets/core-DsKkQ7ig.js +1 -0
  251. package/out/web/assets/{cynefin-OW5HDTMX-ClzaB6cc.js → cynefin-OW5HDTMX-CnzQfOGp.js} +1 -1
  252. package/out/web/assets/{cynefinDiagram-5FMLGOSQ-D_JzZBzf.js → cynefinDiagram-5FMLGOSQ-DVtQnfpm.js} +1 -1
  253. package/out/web/assets/{dagre-3AP2YEHR-BDuUeMHj.js → dagre-3AP2YEHR-DG76_k5_.js} +1 -1
  254. package/out/web/assets/{diagram-S7CK7UJ4-DgtcfE8P.js → diagram-S7CK7UJ4-CaouUoze.js} +1 -1
  255. package/out/web/assets/{diagram-UQ7AKVKN-DTLoEYti.js → diagram-UQ7AKVKN-B4bEc26B.js} +1 -1
  256. package/out/web/assets/{diagram-VSXAHHWV-ChGl9pXz.js → diagram-VSXAHHWV-BJ86Ov2Y.js} +1 -1
  257. package/out/web/assets/{diagram-VX7I27RA-Be8DWz0S.js → diagram-VX7I27RA-7hH5IPYf.js} +1 -1
  258. package/out/web/assets/{diagram-Z3DM3KII-BMXjRTOF.js → diagram-Z3DM3KII-CCM4clo8.js} +1 -1
  259. package/out/web/assets/dist-js-BkYNznHX.js +1 -0
  260. package/out/web/assets/{ebnfDiagram-PWID7BFC-8Q9pl1CJ.js → ebnfDiagram-PWID7BFC-Da7WAM3k.js} +1 -1
  261. package/out/web/assets/{erDiagram-SSCWMZ5O-DjH8Lnkn.js → erDiagram-SSCWMZ5O-CzzmnZWT.js} +1 -1
  262. package/out/web/assets/event-Cdt7Byyj.js +1 -0
  263. package/out/web/assets/{eventmodeling-NTZA5JFV-BKUH_DOp.js → eventmodeling-NTZA5JFV-xcfsA0XD.js} +1 -1
  264. package/out/web/assets/fira-code-latin-600-normal-BmSSHshu.woff +0 -0
  265. package/out/web/assets/fira-code-latin-600-normal-CvYZpPHg.woff2 +0 -0
  266. package/out/web/assets/fira-code-symbols2-600-normal-Dvq6IQE5.woff +0 -0
  267. package/out/web/assets/flowDiagram-A5DVABFB-JdtH1673.js +1 -0
  268. package/out/web/assets/{ganttDiagram-EL5Y4UJY-D7fZKJNM.js → ganttDiagram-EL5Y4UJY-B3ClFGmL.js} +1 -1
  269. package/out/web/assets/{gitGraph-4MIJSDKK-BypWaaBU.js → gitGraph-4MIJSDKK-BZ_y_cDU.js} +1 -1
  270. package/out/web/assets/{gitGraphDiagram-WWUBYQGX-DWiw_3bq.js → gitGraphDiagram-WWUBYQGX-drhRSYm3.js} +1 -1
  271. package/out/web/assets/index-BZ9xgmMP.css +1 -0
  272. package/out/web/assets/index-Ca8cCS4h.js +168 -0
  273. package/out/web/assets/{info-A6RAGUB7-KkAUxT4j.js → info-A6RAGUB7-B7I3K0F1.js} +1 -1
  274. package/out/web/assets/{infoDiagram-RXCK75RN-BNb6E48y.js → infoDiagram-RXCK75RN-deX_1MPh.js} +1 -1
  275. package/out/web/assets/{ishikawaDiagram-5VMMS53U-Caa-JoAK.js → ishikawaDiagram-5VMMS53U-s38yo28y.js} +1 -1
  276. package/out/web/assets/{journeyDiagram-EYS64GPL-DOrmFGz6.js → journeyDiagram-EYS64GPL-CZn2kB8b.js} +1 -1
  277. package/out/web/assets/jsx-runtime-DN4NhQUK.js +1 -0
  278. package/out/web/assets/{kanban-definition-3QL26DDD-BOWCYcK0.js → kanban-definition-3QL26DDD-DodjgySL.js} +1 -1
  279. package/out/web/assets/markdown-CMhYIcWB.js +39 -0
  280. package/out/web/assets/{mermaid-parser.core-BQqjCkS_.js → mermaid-parser.core-Whu2rlJC.js} +3 -3
  281. package/out/web/assets/mermaid.core-CKyPo8VH.js +11 -0
  282. package/out/web/assets/{mindmap-definition-FBJOCRG2-jTceWpr0.js → mindmap-definition-FBJOCRG2-260WBDxZ.js} +1 -1
  283. package/out/web/assets/modelManager-CUaGWMWu.js +2 -0
  284. package/out/web/assets/oauthManager-DlNhXJr6.js +1 -0
  285. package/out/web/assets/{packet-AYTQ26CC-CJ5RXgol.js → packet-AYTQ26CC-CEeIRKRG.js} +1 -1
  286. package/out/web/assets/{pegDiagram-XKGWAZYB-y8wMtd0y.js → pegDiagram-XKGWAZYB-BYadI-KO.js} +1 -1
  287. package/out/web/assets/{pie-WAS4IAKB-72fTBbl4.js → pie-WAS4IAKB-D8uf4dh5.js} +1 -1
  288. package/out/web/assets/{pieDiagram-E7YTZNPT-e0O02qWq.js → pieDiagram-E7YTZNPT-CjSDy4hO.js} +1 -1
  289. package/out/web/assets/{quadrantDiagram-AXDQQJYC-C9phYLom.js → quadrantDiagram-AXDQQJYC-B8D3gU5d.js} +1 -1
  290. package/out/web/assets/{radar-RG4KPBEZ-BAU2hzUT.js → radar-RG4KPBEZ-DZJxnokY.js} +1 -1
  291. package/out/web/assets/{railroad-74A4TZTK-B0bCAVHC.js → railroad-74A4TZTK-DBysUiUi.js} +1 -1
  292. package/out/web/assets/{railroad-abnf-HS5TGJTU-WhxO7ndE.js → railroad-abnf-HS5TGJTU-CHWxoJYg.js} +1 -1
  293. package/out/web/assets/{railroad-ebnf-LZEXJU2U-BkA0m7-F.js → railroad-ebnf-LZEXJU2U-DDv7X6bY.js} +1 -1
  294. package/out/web/assets/{railroad-peg-WCYAUIDC-BsVpHIaj.js → railroad-peg-WCYAUIDC-DpfcYYNJ.js} +1 -1
  295. package/out/web/assets/{railroadDiagram-O6MQD6OU-CyOLN65Q.js → railroadDiagram-O6MQD6OU-CYtyv0t3.js} +1 -1
  296. package/out/web/assets/{requirementDiagram-EFPCY7ZU-DQXJ7Dud.js → requirementDiagram-EFPCY7ZU-D2X-Ewik.js} +1 -1
  297. package/out/web/assets/{sankeyDiagram-P5KCCOFB-B2XLyIIc.js → sankeyDiagram-P5KCCOFB-Bn2x6C4Y.js} +1 -1
  298. package/out/web/assets/{sequenceDiagram-WJ2MYXX4-DGQY4wwm.js → sequenceDiagram-WJ2MYXX4-BvFLKtPd.js} +1 -1
  299. package/out/web/assets/{stateDiagram-HBIQ2CUA-DHZpDwNy.js → stateDiagram-HBIQ2CUA-Bx0GWeaY.js} +1 -1
  300. package/out/web/assets/stateDiagram-v2-4QOOHH4V--hol1yg_.js +1 -0
  301. package/out/web/assets/studio-B1mk40wD.js +1 -0
  302. package/out/web/assets/{swimlanes-XN3QIQJK-Bn_jcZz-.js → swimlanes-XN3QIQJK-CCma0T7L.js} +1 -1
  303. package/out/web/assets/swimlanesDiagram-VK2B7HYN-BkohoVsU.js +8 -0
  304. package/out/web/assets/{timeline-definition-24CTP7MA-CdTERuJZ.js → timeline-definition-24CTP7MA-iCRhhJ7C.js} +1 -1
  305. package/out/web/assets/{treeView-Q6P3EWNA-TKBKQG6R.js → treeView-Q6P3EWNA-DZ-y08qp.js} +1 -1
  306. package/out/web/assets/{treemap-WGGIJYW6-BP7gQoiC.js → treemap-WGGIJYW6-De0CxzMx.js} +1 -1
  307. package/out/web/assets/{vennDiagram-4TSXK5OY-BHUmxDuK.js → vennDiagram-4TSXK5OY-w_7aZ6xe.js} +1 -1
  308. package/out/web/assets/{wardley-WFR3VGLG-9bct5AAN.js → wardley-WFR3VGLG-DT0y0xJ4.js} +1 -1
  309. package/out/web/assets/{wardleyDiagram-VM6X3IG4-BfJwDDso.js → wardleyDiagram-VM6X3IG4-DAdXELbQ.js} +1 -1
  310. package/out/web/assets/window-BJCVtyET.js +1 -0
  311. package/out/web/assets/{xychartDiagram-S5SC5T6Z-Dqd9Mvpo.js → xychartDiagram-S5SC5T6Z-IZIXf3Vo.js} +1 -1
  312. package/out/web/index-web.html +4 -4
  313. package/out/web/index.html +4 -4
  314. package/package.json +80 -20
  315. package/shared-modules/.github/workflows/ci.yml +120 -0
  316. package/shared-modules/.github/workflows/update.yaml +28 -0
  317. package/slash-commands.json +16 -2
  318. package/src/bin/auto-build.js +66 -0
  319. package/src/bin/desktop.js +113 -0
  320. package/src/bin/desktop.test.js +113 -0
  321. package/src/bin/index.js +18 -25
  322. package/src/bin/index.test.js +89 -0
  323. package/src/bin/measure-effects.mjs +53 -0
  324. package/src/bin/package.test.js +28 -0
  325. package/src/bin/release-notes.test.js +84 -0
  326. package/src/bin/styles.test.js +20 -0
  327. package/src/bin/web.js +5 -0
  328. package/src/bin/web.test.js +39 -0
  329. package/src/renderer/index-web.html +1 -0
  330. package/src/renderer/index.html +1 -2
  331. package/src/renderer/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  332. package/src/renderer/shared/agentProgress.ts +201 -3
  333. package/src/renderer/shared/components/AgentActivityDrawer.tsx +9 -8
  334. package/src/renderer/shared/components/AgentActivityTable.tsx +193 -133
  335. package/src/renderer/shared/components/ApprovalCard.test.tsx +24 -0
  336. package/src/renderer/shared/components/ApprovalCard.tsx +10 -3
  337. package/src/renderer/shared/components/ArtifactPreviewPanel.tsx +91 -40
  338. package/src/renderer/shared/components/AuthGate.tsx +79 -48
  339. package/src/renderer/shared/components/BackgroundTerminals.tsx +181 -0
  340. package/src/renderer/shared/components/CalculationCard.tsx +41 -15
  341. package/src/renderer/shared/components/ChatCodeBlock.tsx +8 -4
  342. package/src/renderer/shared/components/ChatMessageItem.test.tsx +68 -0
  343. package/src/renderer/shared/components/ChatMessageItem.tsx +52 -33
  344. package/src/renderer/shared/components/ChatPanel.test.tsx +21 -0
  345. package/src/renderer/shared/components/ChatPanel.tsx +676 -415
  346. package/src/renderer/shared/components/ChunkErrorBoundary.tsx +115 -64
  347. package/src/renderer/shared/components/CliSessionsView.test.tsx +125 -0
  348. package/src/renderer/shared/components/CliSessionsView.tsx +543 -0
  349. package/src/renderer/shared/components/CodeReviewPage.tsx +668 -0
  350. package/src/renderer/shared/components/CollapsiblePasteInput.tsx +90 -0
  351. package/src/renderer/shared/components/CommandPalette.tsx +8 -8
  352. package/src/renderer/shared/components/ContextCompactionStatus.tsx +17 -0
  353. package/src/renderer/shared/components/DashboardSidebar.tsx +853 -229
  354. package/src/renderer/shared/components/DesktopTitlebar.tsx +371 -0
  355. package/src/renderer/shared/components/DiffReviewModal.tsx +3 -3
  356. package/src/renderer/shared/components/ErrorNotice.tsx +29 -0
  357. package/src/renderer/shared/components/FormattedMessage.tsx +11 -0
  358. package/src/renderer/shared/components/GeminiLiveOverlay.css +86 -0
  359. package/src/renderer/shared/components/GeminiLiveOverlay.test.tsx +24 -0
  360. package/src/renderer/shared/components/GeminiLiveOverlay.tsx +85 -101
  361. package/src/renderer/shared/components/GitBranchSelector.tsx +442 -0
  362. package/src/renderer/shared/components/GlideSelect.css +138 -0
  363. package/src/renderer/shared/components/GlideSelect.d.ts +31 -0
  364. package/src/renderer/shared/components/GlideSelect.jsx +266 -0
  365. package/src/renderer/shared/components/ImageGenCard.tsx +31 -12
  366. package/src/renderer/shared/components/ImageSearchCard.tsx +76 -65
  367. package/src/renderer/shared/components/ImageStudioPanel.tsx +49 -49
  368. package/src/renderer/shared/components/InlineThoughtNote.tsx +27 -0
  369. package/src/renderer/shared/components/LibraryItemCard.tsx +72 -0
  370. package/src/renderer/shared/components/LibraryPage.tsx +165 -0
  371. package/src/renderer/shared/components/LibraryPreview.tsx +59 -0
  372. package/src/renderer/shared/components/LinkedFoldersView.tsx +365 -46
  373. package/src/renderer/shared/components/McpRegistryPicker.tsx +1 -1
  374. package/src/renderer/shared/components/McpServersView.tsx +51 -30
  375. package/src/renderer/shared/components/McpToolAllowlist.tsx +2 -4
  376. package/src/renderer/shared/components/McpToolResults.tsx +99 -0
  377. package/src/renderer/shared/components/MermaidCard.tsx +14 -8
  378. package/src/renderer/shared/components/MintDashboard.tsx +1002 -831
  379. package/src/renderer/shared/components/ModSessionControls.tsx +8 -0
  380. package/src/renderer/shared/components/ModUi.test.tsx +65 -0
  381. package/src/renderer/shared/components/ModUi.tsx +26 -0
  382. package/src/renderer/shared/components/ModelSelectorPopover.tsx +547 -219
  383. package/src/renderer/shared/components/ModsPanel.tsx +156 -0
  384. package/src/renderer/shared/components/PlanChecklistWidget.tsx +1 -1
  385. package/src/renderer/shared/components/PluginsView.tsx +1 -1
  386. package/src/renderer/shared/components/ProgressActivity.tsx +17 -0
  387. package/src/renderer/shared/components/ReviewHistory.tsx +42 -0
  388. package/src/renderer/shared/components/RewindModal.tsx +11 -18
  389. package/src/renderer/shared/components/RunSummaryDashboard.tsx +8 -14
  390. package/src/renderer/shared/components/ScheduledTasksView.tsx +11 -10
  391. package/src/renderer/shared/components/ScreenCaptureDialog.css +221 -0
  392. package/src/renderer/shared/components/ScreenCaptureDialog.tsx +199 -0
  393. package/src/renderer/shared/components/SelectField.test.tsx +27 -0
  394. package/src/renderer/shared/components/SelectField.tsx +38 -0
  395. package/src/renderer/shared/components/SessionSources.tsx +48 -0
  396. package/src/renderer/shared/components/SessionSourcesPopover.tsx +61 -0
  397. package/src/renderer/shared/components/Settings/AgentsTab.tsx +41 -45
  398. package/src/renderer/shared/components/Settings/AudioTab.tsx +16 -15
  399. package/src/renderer/shared/components/Settings/AutomationTab.tsx +10 -60
  400. package/src/renderer/shared/components/Settings/AvatarCropModal.tsx +368 -0
  401. package/src/renderer/shared/components/Settings/GeneralTab.tsx +368 -113
  402. package/src/renderer/shared/components/Settings/MemoryTab.test.tsx +37 -0
  403. package/src/renderer/shared/components/Settings/MemoryTab.tsx +71 -31
  404. package/src/renderer/shared/components/Settings/NotificationsTab.test.tsx +15 -0
  405. package/src/renderer/shared/components/Settings/NotificationsTab.tsx +84 -0
  406. package/src/renderer/shared/components/Settings/PluginsTab.tsx +20 -12
  407. package/src/renderer/shared/components/Settings/ProfileTab.tsx +29 -5
  408. package/src/renderer/shared/components/Settings/SearchableModelCombobox.tsx +411 -0
  409. package/src/renderer/shared/components/Settings/ThemeTab.tsx +416 -165
  410. package/src/renderer/shared/components/SkillsView.tsx +41 -16
  411. package/src/renderer/shared/components/SlashChoicePanel.tsx +88 -0
  412. package/src/renderer/shared/components/SlashSuggestions.test.tsx +33 -0
  413. package/src/renderer/shared/components/SlashSuggestions.tsx +41 -12
  414. package/src/renderer/shared/components/SourcesBlock.tsx +55 -51
  415. package/src/renderer/shared/components/StockCard.tsx +58 -24
  416. package/src/renderer/shared/components/SubagentDagView.tsx +4 -4
  417. package/src/renderer/shared/components/TerminalDock.tsx +241 -0
  418. package/src/renderer/shared/components/ThinkingBlock.tsx +14 -8
  419. package/src/renderer/shared/components/ToolSurfacePage.test.tsx +32 -0
  420. package/src/renderer/shared/components/ToolSurfacePage.tsx +568 -0
  421. package/src/renderer/shared/components/ToolsPanelToggle.tsx +13 -0
  422. package/src/renderer/shared/components/UiFeatureCard.tsx +267 -0
  423. package/src/renderer/shared/components/UiGridCard.tsx +188 -0
  424. package/src/renderer/shared/components/UiMockupWidget.tsx +149 -0
  425. package/src/renderer/shared/components/VeoStudioPanel.tsx +22 -37
  426. package/src/renderer/shared/components/ViewedImages.tsx +36 -0
  427. package/src/renderer/shared/components/VirtualChatHistory.tsx +82 -0
  428. package/src/renderer/shared/components/VoicePill.css +85 -0
  429. package/src/renderer/shared/components/VoicePill.d.ts +12 -0
  430. package/src/renderer/shared/components/VoicePill.jsx +190 -0
  431. package/src/renderer/shared/components/VoicePill.test.tsx +22 -0
  432. package/src/renderer/shared/components/WeatherCard.tsx +3 -3
  433. package/src/renderer/shared/components/WorkspaceContextPopover.tsx +138 -0
  434. package/src/renderer/shared/components/WorkspacePaneResizer.tsx +92 -0
  435. package/src/renderer/shared/components/WorkspacePreviewActions.test.tsx +44 -0
  436. package/src/renderer/shared/components/WorkspacePreviewActions.tsx +19 -0
  437. package/src/renderer/shared/components/WorkspaceSelector.tsx +154 -0
  438. package/src/renderer/shared/components/background-terminals.css +292 -0
  439. package/src/renderer/shared/components/ui/README.md +52 -0
  440. package/src/renderer/shared/components/ui/button.tsx +37 -0
  441. package/src/renderer/shared/components/ui/demo.tsx +26 -0
  442. package/src/renderer/shared/components/ui/error-recovery.css +35 -0
  443. package/src/renderer/shared/components/ui/error-recovery.tsx +35 -0
  444. package/src/renderer/shared/components/ui/voice-powered-orb.css +6 -0
  445. package/src/renderer/shared/components/ui/voice-powered-orb.tsx +374 -0
  446. package/src/renderer/shared/components/workspace-selector.css +248 -0
  447. package/src/renderer/shared/constants/config.ts +57 -3
  448. package/src/renderer/shared/constants/models.ts +189 -2
  449. package/src/renderer/shared/constants/slashCommands.ts +1 -1
  450. package/src/renderer/shared/conversation/coordinator.test.ts +27 -0
  451. package/src/renderer/shared/conversation/coordinator.ts +82 -0
  452. package/src/renderer/shared/conversation/lifecycle.test.ts +362 -0
  453. package/src/renderer/shared/conversation/lifecycle.ts +418 -0
  454. package/src/renderer/shared/conversation/projectNavigation.test.ts +105 -0
  455. package/src/renderer/shared/conversation/projectNavigation.ts +39 -0
  456. package/src/renderer/shared/conversation/sourcesTransport.ts +27 -0
  457. package/src/renderer/shared/conversation/syncView.test.ts +13 -0
  458. package/src/renderer/shared/conversation/syncView.ts +6 -0
  459. package/src/renderer/shared/conversation/transport.test.ts +163 -0
  460. package/src/renderer/shared/conversation/transport.ts +159 -0
  461. package/src/renderer/shared/conversation/useConversationCoordinator.ts +64 -0
  462. package/src/renderer/shared/css/agent-observability.css +46 -15
  463. package/src/renderer/shared/css/auth-gate.css +114 -15
  464. package/src/renderer/shared/css/chat-ui-cards.css +495 -0
  465. package/src/renderer/shared/css/code-review.css +856 -0
  466. package/src/renderer/shared/css/code-sessions.css +479 -0
  467. package/src/renderer/shared/css/collapsed-paste.css +35 -0
  468. package/src/renderer/shared/css/effects.css +15 -0
  469. package/src/renderer/shared/css/error-notice.css +60 -0
  470. package/src/renderer/shared/css/file-changes.css +132 -0
  471. package/src/renderer/shared/css/image-studio.css +16 -16
  472. package/src/renderer/shared/css/library.css +476 -0
  473. package/src/renderer/shared/css/management-views.css +415 -27
  474. package/src/renderer/shared/css/mcp-results.css +11 -0
  475. package/src/renderer/shared/css/model-selector.css +543 -85
  476. package/src/renderer/shared/css/mods.css +77 -0
  477. package/src/renderer/shared/css/notification-notice.css +28 -0
  478. package/src/renderer/shared/css/session-sources-popover.css +9 -0
  479. package/src/renderer/shared/css/session-sources.css +23 -0
  480. package/src/renderer/shared/css/settings/agents.css +1 -1
  481. package/src/renderer/shared/css/settings/automation.css +13 -13
  482. package/src/renderer/shared/css/settings/general.css +526 -5
  483. package/src/renderer/shared/css/settings/memory.css +154 -0
  484. package/src/renderer/shared/css/settings/notifications.css +11 -0
  485. package/src/renderer/shared/css/settings/plugins.css +5 -5
  486. package/src/renderer/shared/css/settings/profile.css +331 -1
  487. package/src/renderer/shared/css/settings/theme.css +656 -3
  488. package/src/renderer/shared/css/slash-choice.css +95 -0
  489. package/src/renderer/shared/css/slash-suggestions.css +68 -0
  490. package/src/renderer/shared/css/sources-block.css +91 -0
  491. package/src/renderer/shared/css/thinking-status.css +46 -0
  492. package/src/renderer/shared/css/tools-panel.css +7 -0
  493. package/src/renderer/shared/css/ui-tailwind.css +19 -0
  494. package/src/renderer/shared/css/veo-studio.css +16 -15
  495. package/src/renderer/shared/css/viewed-images.css +15 -0
  496. package/src/renderer/shared/fonts.css +46 -0
  497. package/src/renderer/shared/fonts.ts +10 -37
  498. package/src/renderer/shared/lib/utils.ts +6 -0
  499. package/src/renderer/shared/library/controller.test.ts +61 -0
  500. package/src/renderer/shared/library/controller.ts +57 -0
  501. package/src/renderer/shared/library/transport.test.ts +26 -0
  502. package/src/renderer/shared/library/transport.ts +60 -0
  503. package/src/renderer/shared/library/useLibraryFile.ts +38 -0
  504. package/src/renderer/shared/mods.ts +58 -0
  505. package/src/renderer/shared/platform.test.ts +90 -0
  506. package/src/renderer/shared/platform.ts +219 -7
  507. package/src/renderer/shared/theme/themeManager.test.ts +43 -0
  508. package/src/renderer/shared/theme/themeManager.ts +157 -0
  509. package/src/renderer/shared/theme/themes.ts +116 -0
  510. package/src/renderer/shared/theme/tokens.css +318 -0
  511. package/src/renderer/shared/types.ts +137 -2
  512. package/src/renderer/shared/utils/agentActivity.test.ts +75 -0
  513. package/src/renderer/shared/utils/agentActivity.ts +168 -8
  514. package/src/renderer/shared/utils/approval.tsx +7 -0
  515. package/src/renderer/shared/utils/artifactBrowser.test.ts +80 -0
  516. package/src/renderer/shared/utils/artifactBrowser.ts +41 -0
  517. package/src/renderer/shared/utils/cardJson.test.ts +35 -0
  518. package/src/renderer/shared/utils/cardJson.ts +30 -0
  519. package/src/renderer/shared/utils/contextCompaction.test.tsx +48 -0
  520. package/src/renderer/shared/utils/contextCompaction.ts +21 -0
  521. package/src/renderer/shared/utils/effectVisibility.ts +9 -0
  522. package/src/renderer/shared/utils/fileIcons.ts +1 -1
  523. package/src/renderer/shared/utils/markdown.tsx +330 -81
  524. package/src/renderer/shared/utils/markdown.webImages.test.tsx +40 -0
  525. package/src/renderer/shared/utils/mcpResults.test.tsx +91 -0
  526. package/src/renderer/shared/utils/modViewport.ts +7 -0
  527. package/src/renderer/shared/utils/nativeRecordingSession.test.ts +61 -0
  528. package/src/renderer/shared/utils/nativeRecordingSession.ts +46 -0
  529. package/src/renderer/shared/utils/notificationSettings.test.ts +14 -0
  530. package/src/renderer/shared/utils/notificationSettings.ts +23 -0
  531. package/src/renderer/shared/utils/notifications.test.ts +51 -0
  532. package/src/renderer/shared/utils/notifications.ts +54 -0
  533. package/src/renderer/shared/utils/pastedText.test.ts +20 -0
  534. package/src/renderer/shared/utils/pastedText.ts +13 -0
  535. package/src/renderer/shared/utils/progressCache.test.ts +32 -0
  536. package/src/renderer/shared/utils/providers.ts +8 -3
  537. package/src/renderer/shared/utils/screenCapture.ts +43 -0
  538. package/src/renderer/shared/utils/sessionSources.test.ts +31 -0
  539. package/src/renderer/shared/utils/sessionSources.ts +34 -0
  540. package/src/renderer/shared/utils/slashCommandProcessor.ts +5 -2
  541. package/src/renderer/shared/utils/sourceAttachments.ts +28 -0
  542. package/src/renderer/shared/utils/speech.ts +51 -9
  543. package/src/renderer/shared/utils/syntaxHighlight.test.tsx +22 -0
  544. package/src/renderer/shared/utils/syntaxHighlight.tsx +26 -0
  545. package/src/renderer/shared/utils/ui.ts +4 -64
  546. package/src/renderer/shared/utils/useGeminiLiveVoice.ts +49 -5
  547. package/src/renderer/shared/utils/useLatestCallback.ts +9 -0
  548. package/src/renderer/shared/utils/useNativeVoiceInput.ts +80 -72
  549. package/src/renderer/shared/utils/useSessionSources.ts +33 -0
  550. package/src/renderer/shared/utils/viewedImages.ts +26 -0
  551. package/src/renderer/shared/utils/workspacePane.test.ts +33 -0
  552. package/src/renderer/shared/utils/workspacePane.ts +20 -0
  553. package/src/renderer/shared/utils/workspacePreview.test.ts +24 -0
  554. package/src/renderer/shared/utils/workspacePreview.ts +16 -0
  555. package/src/renderer/shared/utils/workspaceSnapshot.test.ts +49 -0
  556. package/src/renderer/shared/utils/workspaceSnapshot.ts +26 -0
  557. package/src/renderer/shared/utils/workspaces.ts +10 -0
  558. package/src/renderer/shared/workspaceUndo.ts +88 -0
  559. package/src/renderer/src/App.tsx +16 -1
  560. package/src/renderer/src/components/BrowserShell.tsx +108 -0
  561. package/src/renderer/src/components/BrowserSuggestionsPopup.tsx +91 -0
  562. package/src/renderer/src/components/LiveTranslateControls.tsx +65 -0
  563. package/src/renderer/src/components/MintDashboard.tsx +4 -2
  564. package/src/renderer/src/components/PicturesLibrary.tsx +2 -287
  565. package/src/renderer/src/components/ScreenPicker.css +227 -0
  566. package/src/renderer/src/components/ScreenPicker.tsx +312 -526
  567. package/src/renderer/src/components/SettingsWindow.tsx +48 -5
  568. package/src/renderer/src/components/SpotlightWindow.tsx +1 -0
  569. package/src/renderer/src/components/WidgetWindow.tsx +27 -3
  570. package/src/renderer/src/components/WorkspacePanel.tsx +269 -61
  571. package/src/renderer/src/components/browser-shell.css +185 -0
  572. package/src/renderer/src/components/browser-suggestions-popup.css +55 -0
  573. package/src/renderer/src/components/liveTranslateFrames.test.ts +44 -0
  574. package/src/renderer/src/components/liveTranslateFrames.ts +45 -0
  575. package/src/renderer/src/components/liveTranslateLanguage.ts +6 -0
  576. package/src/renderer/src/css/app-shell.css +400 -204
  577. package/src/renderer/src/css/chat.css +1666 -594
  578. package/src/renderer/src/css/dashboard.css +11 -0
  579. package/src/renderer/src/css/desktop-tools.css +928 -0
  580. package/src/renderer/src/css/misc.css +5 -60
  581. package/src/renderer/src/css/pictures.css +2 -407
  582. package/src/renderer/src/css/settings/base.css +394 -157
  583. package/src/renderer/src/css/sidebar-workspace.css +1072 -376
  584. package/src/renderer/src/css/spotlight.css +15 -26
  585. package/src/renderer/src/css/tool-surfaces.css +784 -0
  586. package/src/renderer/src/css/widget.css +36 -8
  587. package/src/renderer/src/env.d.ts +4 -9
  588. package/src/renderer/src/hooks/useProviderModels.ts +2 -9
  589. package/src/renderer/src/index.css +53 -46
  590. package/src/renderer/src/main.tsx +12 -0
  591. package/src/renderer/src/proactiveSuggestions.ts +29 -0
  592. package/src/renderer/src/tauri.ts +302 -339
  593. package/src/renderer/src/voiceInput.ts +2 -1
  594. package/src/renderer/src-web/App.tsx +40 -0
  595. package/src/renderer/src-web/components/MintDashboard.tsx +4 -2
  596. package/src/renderer/src-web/components/PicturesLibrary.tsx +2 -284
  597. package/src/renderer/src-web/components/PullToRefresh.tsx +107 -0
  598. package/src/renderer/src-web/components/SettingsWindow.tsx +36 -5
  599. package/src/renderer/src-web/css/app-shell.css +188 -207
  600. package/src/renderer/src-web/css/chat.css +1236 -507
  601. package/src/renderer/src-web/css/dashboard.css +11 -0
  602. package/src/renderer/src-web/css/misc.css +3 -55
  603. package/src/renderer/src-web/css/mobile.css +679 -0
  604. package/src/renderer/src-web/css/pictures.css +2 -415
  605. package/src/renderer/src-web/css/pull-to-refresh.css +54 -0
  606. package/src/renderer/src-web/css/settings/base.css +353 -134
  607. package/src/renderer/src-web/css/settings/mobile.css +320 -0
  608. package/src/renderer/src-web/css/sidebar-workspace.css +546 -187
  609. package/src/renderer/src-web/css/spotlight.css +9 -26
  610. package/src/renderer/src-web/css/widget.css +1 -6
  611. package/src/renderer/src-web/env.d.ts +4 -12
  612. package/src/renderer/src-web/hooks/useProviderModels.ts +2 -9
  613. package/src/renderer/src-web/index.css +53 -96
  614. package/src/renderer/src-web/main.tsx +12 -0
  615. package/src/renderer/src-web/proactiveSuggestions.ts +3 -0
  616. package/src/renderer/src-web/tauri.ts +309 -364
  617. package/src/renderer/tests/backgroundTerminals.browser.html +81 -0
  618. package/src/renderer/tests/chatPanelFixture.ts +23 -0
  619. package/src/renderer/tests/chatPerformance.browser.html +71 -0
  620. package/src/renderer/tests/cliSessions.browser.html +82 -0
  621. package/src/renderer/tests/collapsedPaste.browser.html +70 -0
  622. package/src/renderer/tests/composerMenus.browser.html +116 -0
  623. package/src/renderer/tests/contextCompaction.browser.html +17 -0
  624. package/src/renderer/tests/conversationLifecycle.browser.html +70 -0
  625. package/src/renderer/tests/effectsPerformance.browser.html +13 -0
  626. package/src/renderer/tests/errorRecovery.browser.html +21 -0
  627. package/src/renderer/tests/glideSelect.browser.html +71 -0
  628. package/src/renderer/tests/glideSelect.preview.html +27 -0
  629. package/src/renderer/tests/library.browser.html +1 -0
  630. package/src/renderer/tests/library.browser.tsx +78 -0
  631. package/src/renderer/tests/liveOrb.browser.html +49 -0
  632. package/src/renderer/tests/liveVoice.browser.html +29 -0
  633. package/src/renderer/tests/modInput.browser.html +22 -0
  634. package/src/renderer/tests/sessionSources.browser.html +1 -0
  635. package/src/renderer/tests/sessionSources.browser.tsx +107 -0
  636. package/src/renderer/tests/skills.browser.html +24 -0
  637. package/src/renderer/tests/toolsPanel.browser.html +1 -0
  638. package/src/renderer/tests/toolsPanel.browser.tsx +113 -0
  639. package/src/renderer/tests/viewedImages.browser.html +1 -0
  640. package/src/renderer/tests/viewedImages.browser.tsx +93 -0
  641. package/src/renderer/tests/voicePill.browser.html +80 -0
  642. package/src/renderer/tests/voicePill.preview.html +34 -0
  643. package/src/renderer/tests/webSources.browser.html +1 -0
  644. package/src/renderer/tests/webSources.browser.tsx +98 -0
  645. package/src/renderer/tests/workspaceUndo.browser.html +92 -0
  646. package/src-tauri/Cargo.toml +40 -0
  647. package/src-tauri/build.rs +3 -0
  648. package/src-tauri/src/app/lifecycle.rs +277 -0
  649. package/src-tauri/src/app/mod.rs +4 -0
  650. package/src-tauri/src/app/shortcuts.rs +40 -0
  651. package/src-tauri/src/app/tray.rs +52 -0
  652. package/src-tauri/src/background/events.rs +70 -0
  653. package/src-tauri/src/background/headless.rs +230 -0
  654. package/src-tauri/src/background/mod.rs +4 -0
  655. package/src-tauri/src/background/proactive.rs +262 -0
  656. package/src-tauri/src/commands/approvals.rs +20 -0
  657. package/src-tauri/src/commands/auth.rs +127 -0
  658. package/src-tauri/src/commands/conversation.rs +642 -0
  659. package/src-tauri/src/commands/integrations.rs +157 -0
  660. package/src-tauri/src/commands/media.rs +179 -0
  661. package/src-tauri/src/commands/mod.rs +11 -0
  662. package/src-tauri/src/commands/mods.rs +6 -0
  663. package/src-tauri/src/commands/settings.rs +141 -0
  664. package/src-tauri/src/commands/voice.rs +153 -0
  665. package/src-tauri/src/commands/workspace.rs +501 -0
  666. package/src-tauri/src/lib.rs +10 -0
  667. package/src-tauri/src/main.rs +3 -0
  668. package/src-tauri/src/native/actions.rs +206 -0
  669. package/src-tauri/src/native/browser.rs +252 -0
  670. package/src-tauri/src/native/capture.rs +695 -0
  671. package/src-tauri/src/native/discord_rpc.rs +99 -0
  672. package/src-tauri/src/native/integrations.rs +126 -0
  673. package/src-tauri/src/native/mod.rs +12 -0
  674. package/src-tauri/src/native/notifications.rs +51 -0
  675. package/src-tauri/src/native/plugins.rs +14 -0
  676. package/src-tauri/src/native/system.rs +250 -0
  677. package/src-tauri/src/native/terminal.rs +201 -0
  678. package/src-tauri/src/native/updater.rs +148 -0
  679. package/src-tauri/src/native/windows.rs +143 -0
  680. package/tsconfig.json +2 -0
  681. package/vite.config.ts +4 -1
  682. package/vite.config.web.ts +5 -0
  683. package/.cargo/config.toml +0 -13
  684. package/.claude/settings.local.json +0 -182
  685. package/.codex +0 -0
  686. package/CLAUDE.md +0 -102
  687. package/out/web/Live2DCubismCore.js +0 -9
  688. package/out/web/assets/MintDashboard-DWSqucgb.js +0 -57
  689. package/out/web/assets/SettingsWindow-DFM2aj5B.js +0 -2
  690. package/out/web/assets/chunk-CKBBP62Z-nr1uKPj9.js +0 -2
  691. package/out/web/assets/chunk-DSNCTWBM-Dtj8Rqsd.js +0 -2
  692. package/out/web/assets/chunk-DU6HZSFF-2tv5K1wT.js +0 -128
  693. package/out/web/assets/classDiagram-DTDB5LWJ-BdSFArgw.js +0 -1
  694. package/out/web/assets/classDiagram-v2-JRS7N3AN-BdSFArgw.js +0 -1
  695. package/out/web/assets/core-DImejIUG.js +0 -1
  696. package/out/web/assets/dist-js-BxuKEVWM.js +0 -1
  697. package/out/web/assets/event-BNl8Zahw.js +0 -1
  698. package/out/web/assets/fira-code-cyrillic-400-normal-UC0NFL4U.woff2 +0 -0
  699. package/out/web/assets/fira-code-cyrillic-400-normal-dc1q3VD7.woff +0 -0
  700. package/out/web/assets/fira-code-cyrillic-500-normal-Bu7VCL72.woff +0 -0
  701. package/out/web/assets/fira-code-cyrillic-500-normal-CX35h3Mg.woff2 +0 -0
  702. package/out/web/assets/fira-code-cyrillic-ext-400-normal-Buh61xzB.woff +0 -0
  703. package/out/web/assets/fira-code-cyrillic-ext-400-normal-txZ9Fk_1.woff2 +0 -0
  704. package/out/web/assets/fira-code-cyrillic-ext-500-normal-CYRspHgm.woff2 +0 -0
  705. package/out/web/assets/fira-code-cyrillic-ext-500-normal-CwtLmSFD.woff +0 -0
  706. package/out/web/assets/fira-code-greek-400-normal-B2Gh_Y8s.woff2 +0 -0
  707. package/out/web/assets/fira-code-greek-400-normal-DobhaxvF.woff +0 -0
  708. package/out/web/assets/fira-code-greek-500-normal-CVO0IRNt.woff2 +0 -0
  709. package/out/web/assets/fira-code-greek-500-normal-DEXIWiey.woff +0 -0
  710. package/out/web/assets/fira-code-greek-ext-400-normal-DR7mBgIM.woff2 +0 -0
  711. package/out/web/assets/fira-code-greek-ext-400-normal-DSPUheWg.woff +0 -0
  712. package/out/web/assets/fira-code-greek-ext-500-normal-BO3iq16n.woff2 +0 -0
  713. package/out/web/assets/fira-code-greek-ext-500-normal-CMiNLXER.woff +0 -0
  714. package/out/web/assets/fira-code-latin-ext-400-normal-DG73JjOf.woff +0 -0
  715. package/out/web/assets/fira-code-latin-ext-400-normal-KSMg0QLl.woff2 +0 -0
  716. package/out/web/assets/fira-code-latin-ext-500-normal-CaANqXjI.woff +0 -0
  717. package/out/web/assets/fira-code-latin-ext-500-normal-Crd9e_oI.woff2 +0 -0
  718. package/out/web/assets/flowDiagram-A5DVABFB-BpnHUSU7.js +0 -1
  719. package/out/web/assets/index-CVRg270z.css +0 -1
  720. package/out/web/assets/index-DxZQr9yD.js +0 -10
  721. package/out/web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  722. package/out/web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  723. package/out/web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  724. package/out/web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  725. package/out/web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  726. package/out/web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  727. package/out/web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  728. package/out/web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  729. package/out/web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  730. package/out/web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  731. package/out/web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  732. package/out/web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  733. package/out/web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  734. package/out/web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  735. package/out/web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  736. package/out/web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  737. package/out/web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  738. package/out/web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  739. package/out/web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  740. package/out/web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  741. package/out/web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  742. package/out/web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  743. package/out/web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  744. package/out/web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  745. package/out/web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  746. package/out/web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  747. package/out/web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  748. package/out/web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  749. package/out/web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  750. package/out/web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  751. package/out/web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  752. package/out/web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  753. package/out/web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  754. package/out/web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  755. package/out/web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  756. package/out/web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  757. package/out/web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  758. package/out/web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  759. package/out/web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  760. package/out/web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  761. package/out/web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  762. package/out/web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  763. package/out/web/assets/kanit-latin-400-normal-BUlsQXH3.woff2 +0 -0
  764. package/out/web/assets/kanit-latin-400-normal-HVZWrfev.woff +0 -0
  765. package/out/web/assets/kanit-latin-500-normal-CX_4XcKi.woff2 +0 -0
  766. package/out/web/assets/kanit-latin-500-normal-qBha86O4.woff +0 -0
  767. package/out/web/assets/kanit-latin-600-normal-0lS78yAd.woff2 +0 -0
  768. package/out/web/assets/kanit-latin-600-normal-DlacpeTg.woff +0 -0
  769. package/out/web/assets/kanit-latin-ext-400-normal-B2xPf58u.woff2 +0 -0
  770. package/out/web/assets/kanit-latin-ext-400-normal-CHvNKHHD.woff +0 -0
  771. package/out/web/assets/kanit-latin-ext-500-normal-BFdWrG22.woff +0 -0
  772. package/out/web/assets/kanit-latin-ext-500-normal-EHs3suD6.woff2 +0 -0
  773. package/out/web/assets/kanit-latin-ext-600-normal-BBDzoLaj.woff +0 -0
  774. package/out/web/assets/kanit-latin-ext-600-normal-DAX3wtuq.woff2 +0 -0
  775. package/out/web/assets/kanit-thai-400-normal-D2nliXKa.woff +0 -0
  776. package/out/web/assets/kanit-thai-400-normal-D80Pglro.woff2 +0 -0
  777. package/out/web/assets/kanit-thai-500-normal-BB8gri04.woff2 +0 -0
  778. package/out/web/assets/kanit-thai-500-normal-Iy_w0a80.woff +0 -0
  779. package/out/web/assets/kanit-thai-600-normal-Bj6oiaDc.woff2 +0 -0
  780. package/out/web/assets/kanit-thai-600-normal-wbTlPO7I.woff +0 -0
  781. package/out/web/assets/kanit-vietnamese-400-normal-DRI8YWyb.woff2 +0 -0
  782. package/out/web/assets/kanit-vietnamese-400-normal-dow6Bnto.woff +0 -0
  783. package/out/web/assets/kanit-vietnamese-500-normal-BzTrpCM3.woff2 +0 -0
  784. package/out/web/assets/kanit-vietnamese-500-normal-T1ndT7MF.woff +0 -0
  785. package/out/web/assets/kanit-vietnamese-600-normal-Bi7fw3I4.woff2 +0 -0
  786. package/out/web/assets/kanit-vietnamese-600-normal-DXCRAa_8.woff +0 -0
  787. package/out/web/assets/mali-latin-400-normal-ByPcZ8Gx.woff +0 -0
  788. package/out/web/assets/mali-latin-400-normal-Cuc6Ykl8.woff2 +0 -0
  789. package/out/web/assets/mali-latin-500-normal-B01M-LP6.woff2 +0 -0
  790. package/out/web/assets/mali-latin-500-normal-Thgqw_9f.woff +0 -0
  791. package/out/web/assets/mali-latin-600-normal-B9TiUNpT.woff2 +0 -0
  792. package/out/web/assets/mali-latin-600-normal-CycpQCwU.woff +0 -0
  793. package/out/web/assets/mali-latin-ext-400-normal-DUVdJQO4.woff +0 -0
  794. package/out/web/assets/mali-latin-ext-400-normal-iCG1ALuJ.woff2 +0 -0
  795. package/out/web/assets/mali-latin-ext-500-normal-BfJE4UMZ.woff2 +0 -0
  796. package/out/web/assets/mali-latin-ext-500-normal-FoLEyOKS.woff +0 -0
  797. package/out/web/assets/mali-latin-ext-600-normal-BTvIfiCp.woff2 +0 -0
  798. package/out/web/assets/mali-latin-ext-600-normal-DAQcLVmD.woff +0 -0
  799. package/out/web/assets/mali-thai-400-normal-BO7A3bLZ.woff2 +0 -0
  800. package/out/web/assets/mali-thai-400-normal-CX9VrjTn.woff +0 -0
  801. package/out/web/assets/mali-thai-500-normal-Cph2qha9.woff +0 -0
  802. package/out/web/assets/mali-thai-500-normal-D89rMT77.woff2 +0 -0
  803. package/out/web/assets/mali-thai-600-normal-DPgXT6rZ.woff +0 -0
  804. package/out/web/assets/mali-thai-600-normal-DlTx3OsB.woff2 +0 -0
  805. package/out/web/assets/mali-vietnamese-400-normal-DUKxzXaz.woff2 +0 -0
  806. package/out/web/assets/mali-vietnamese-400-normal-gFDLvsOK.woff +0 -0
  807. package/out/web/assets/mali-vietnamese-500-normal--OY07q2c.woff +0 -0
  808. package/out/web/assets/mali-vietnamese-500-normal-KDrNRwfW.woff2 +0 -0
  809. package/out/web/assets/mali-vietnamese-600-normal-CtTMUU7T.woff +0 -0
  810. package/out/web/assets/mali-vietnamese-600-normal-DFlJr66u.woff2 +0 -0
  811. package/out/web/assets/mermaid.core-DYGGlqOf.js +0 -11
  812. package/out/web/assets/mitr-latin-400-normal-Cb18vPmZ.woff +0 -0
  813. package/out/web/assets/mitr-latin-400-normal-CwbkLQ4a.woff2 +0 -0
  814. package/out/web/assets/mitr-latin-500-normal-B4JOhzAi.woff +0 -0
  815. package/out/web/assets/mitr-latin-500-normal-BmClOow2.woff2 +0 -0
  816. package/out/web/assets/mitr-latin-ext-400-normal-B3CPG7Yo.woff2 +0 -0
  817. package/out/web/assets/mitr-latin-ext-400-normal-DTx65hiC.woff +0 -0
  818. package/out/web/assets/mitr-latin-ext-500-normal-B_Diajz1.woff2 +0 -0
  819. package/out/web/assets/mitr-latin-ext-500-normal-DYOsVaVT.woff +0 -0
  820. package/out/web/assets/mitr-thai-400-normal-CS4Do6aM.woff +0 -0
  821. package/out/web/assets/mitr-thai-400-normal-CXWaAdVj.woff2 +0 -0
  822. package/out/web/assets/mitr-thai-500-normal-CVdBrEVH.woff2 +0 -0
  823. package/out/web/assets/mitr-thai-500-normal-CW_B5D-i.woff +0 -0
  824. package/out/web/assets/mitr-vietnamese-400-normal-CPMGWyoC.woff2 +0 -0
  825. package/out/web/assets/mitr-vietnamese-400-normal-Z6XhIpdY.woff +0 -0
  826. package/out/web/assets/mitr-vietnamese-500-normal-CDrv0585.woff2 +0 -0
  827. package/out/web/assets/mitr-vietnamese-500-normal-CMQWyq-Z.woff +0 -0
  828. package/out/web/assets/noto-sans-thai-latin-400-normal-BaWBrqFP.woff2 +0 -0
  829. package/out/web/assets/noto-sans-thai-latin-400-normal-Dh_FJjOB.woff +0 -0
  830. package/out/web/assets/noto-sans-thai-latin-500-normal-BC04jsmm.woff +0 -0
  831. package/out/web/assets/noto-sans-thai-latin-500-normal-fyBKClF1.woff2 +0 -0
  832. package/out/web/assets/noto-sans-thai-latin-600-normal-CemcjnVR.woff2 +0 -0
  833. package/out/web/assets/noto-sans-thai-latin-600-normal-Dbc-dNO6.woff +0 -0
  834. package/out/web/assets/noto-sans-thai-latin-ext-400-normal-B-1H0YkH.woff +0 -0
  835. package/out/web/assets/noto-sans-thai-latin-ext-400-normal-nBP59pVw.woff2 +0 -0
  836. package/out/web/assets/noto-sans-thai-latin-ext-500-normal-B-ZWb8SZ.woff2 +0 -0
  837. package/out/web/assets/noto-sans-thai-latin-ext-500-normal-CrEHO7C8.woff +0 -0
  838. package/out/web/assets/noto-sans-thai-latin-ext-600-normal-PkMZo0xa.woff +0 -0
  839. package/out/web/assets/noto-sans-thai-latin-ext-600-normal-maHHBsez.woff2 +0 -0
  840. package/out/web/assets/noto-sans-thai-thai-400-normal-BEOmKAI1.woff2 +0 -0
  841. package/out/web/assets/noto-sans-thai-thai-400-normal-Dxb_unuU.woff +0 -0
  842. package/out/web/assets/noto-sans-thai-thai-500-normal-BOGXO2tw.woff +0 -0
  843. package/out/web/assets/noto-sans-thai-thai-500-normal-CU_X86GQ.woff2 +0 -0
  844. package/out/web/assets/noto-sans-thai-thai-600-normal-CmgB8_JK.woff +0 -0
  845. package/out/web/assets/noto-sans-thai-thai-600-normal-Cs73sVjl.woff2 +0 -0
  846. package/out/web/assets/oauthManager-DNiwM8Rn.js +0 -2
  847. package/out/web/assets/oauthManager-DuamS1Av.css +0 -1
  848. package/out/web/assets/outfit-latin-300-normal-AWLfuCom.woff +0 -0
  849. package/out/web/assets/outfit-latin-300-normal-CRYTwY6m.woff2 +0 -0
  850. package/out/web/assets/outfit-latin-400-normal-BGsTXAXT.woff2 +0 -0
  851. package/out/web/assets/outfit-latin-400-normal-DMwTpYkH.woff +0 -0
  852. package/out/web/assets/outfit-latin-500-normal-ClnHRwRh.woff +0 -0
  853. package/out/web/assets/outfit-latin-500-normal-DKnIMDSk.woff2 +0 -0
  854. package/out/web/assets/outfit-latin-600-normal-B7SfZ07L.woff2 +0 -0
  855. package/out/web/assets/outfit-latin-600-normal-BEfTtDA7.woff +0 -0
  856. package/out/web/assets/outfit-latin-ext-300-normal-BYS6Dpvq.woff2 +0 -0
  857. package/out/web/assets/outfit-latin-ext-300-normal-CMIMshEd.woff +0 -0
  858. package/out/web/assets/outfit-latin-ext-400-normal-5tcqmc2S.woff2 +0 -0
  859. package/out/web/assets/outfit-latin-ext-400-normal-DHm7mdGe.woff +0 -0
  860. package/out/web/assets/outfit-latin-ext-500-normal-DrCvqoFD.woff +0 -0
  861. package/out/web/assets/outfit-latin-ext-500-normal-zeox_O30.woff2 +0 -0
  862. package/out/web/assets/outfit-latin-ext-600-normal-B85nYjL1.woff2 +0 -0
  863. package/out/web/assets/outfit-latin-ext-600-normal-CWJcPgd7.woff +0 -0
  864. package/out/web/assets/preload-helper-D0uHx7FM.js +0 -1
  865. package/out/web/assets/prompt-latin-400-normal-BQ9zjSN8.woff2 +0 -0
  866. package/out/web/assets/prompt-latin-400-normal-CxU4ec_r.woff +0 -0
  867. package/out/web/assets/prompt-latin-500-normal-B1edBDQ9.woff +0 -0
  868. package/out/web/assets/prompt-latin-500-normal-CxzxEHZc.woff2 +0 -0
  869. package/out/web/assets/prompt-latin-600-normal-CSDDLorv.woff +0 -0
  870. package/out/web/assets/prompt-latin-600-normal-hKZWXsc1.woff2 +0 -0
  871. package/out/web/assets/prompt-latin-ext-400-normal-1pHbN9uy.woff +0 -0
  872. package/out/web/assets/prompt-latin-ext-400-normal-DdSafGZ9.woff2 +0 -0
  873. package/out/web/assets/prompt-latin-ext-500-normal--EZ1um7s.woff2 +0 -0
  874. package/out/web/assets/prompt-latin-ext-500-normal-D-lI1EVT.woff +0 -0
  875. package/out/web/assets/prompt-latin-ext-600-normal-C8b50x67.woff +0 -0
  876. package/out/web/assets/prompt-latin-ext-600-normal-Cg9L7iJU.woff2 +0 -0
  877. package/out/web/assets/prompt-thai-400-normal-BrkKv8cO.woff2 +0 -0
  878. package/out/web/assets/prompt-thai-400-normal-DJypnfr3.woff +0 -0
  879. package/out/web/assets/prompt-thai-500-normal-C18pDUoL.woff2 +0 -0
  880. package/out/web/assets/prompt-thai-500-normal-D7YNqUJD.woff +0 -0
  881. package/out/web/assets/prompt-thai-600-normal-CYPViXds.woff +0 -0
  882. package/out/web/assets/prompt-thai-600-normal-MrdfU7zR.woff2 +0 -0
  883. package/out/web/assets/prompt-vietnamese-400-normal-BCPzsgPT.woff2 +0 -0
  884. package/out/web/assets/prompt-vietnamese-400-normal-D4pLyeNK.woff +0 -0
  885. package/out/web/assets/prompt-vietnamese-500-normal-DmzxmPwa.woff2 +0 -0
  886. package/out/web/assets/prompt-vietnamese-500-normal-w_sS_j2F.woff +0 -0
  887. package/out/web/assets/prompt-vietnamese-600-normal-1dyohtNh.woff +0 -0
  888. package/out/web/assets/prompt-vietnamese-600-normal-7QWjJBsF.woff2 +0 -0
  889. package/out/web/assets/sarabun-latin-400-normal-DmguIS-7.woff +0 -0
  890. package/out/web/assets/sarabun-latin-400-normal-URPBxl-K.woff2 +0 -0
  891. package/out/web/assets/sarabun-latin-500-normal-BjUTcdxu.woff2 +0 -0
  892. package/out/web/assets/sarabun-latin-500-normal-CJexTAnl.woff +0 -0
  893. package/out/web/assets/sarabun-latin-600-normal-9FonlkKx.woff +0 -0
  894. package/out/web/assets/sarabun-latin-600-normal-DMD3TROr.woff2 +0 -0
  895. package/out/web/assets/sarabun-latin-ext-400-normal-BbP7GpLL.woff2 +0 -0
  896. package/out/web/assets/sarabun-latin-ext-400-normal-BsmTn0SS.woff +0 -0
  897. package/out/web/assets/sarabun-latin-ext-500-normal-Cot16Bd2.woff +0 -0
  898. package/out/web/assets/sarabun-latin-ext-500-normal-DE1LysqI.woff2 +0 -0
  899. package/out/web/assets/sarabun-latin-ext-600-normal-0tXu9x_i.woff +0 -0
  900. package/out/web/assets/sarabun-latin-ext-600-normal-BRxjKUQ6.woff2 +0 -0
  901. package/out/web/assets/sarabun-thai-400-normal-C2DaJlKK.woff2 +0 -0
  902. package/out/web/assets/sarabun-thai-400-normal-CH-UbWgg.woff +0 -0
  903. package/out/web/assets/sarabun-thai-500-normal-BVssjame.woff2 +0 -0
  904. package/out/web/assets/sarabun-thai-500-normal-C3iDLEzi.woff +0 -0
  905. package/out/web/assets/sarabun-thai-600-normal-3dIAQ-_s.woff2 +0 -0
  906. package/out/web/assets/sarabun-thai-600-normal-DWKeTe6a.woff +0 -0
  907. package/out/web/assets/sarabun-vietnamese-400-normal-DPfkSCMy.woff +0 -0
  908. package/out/web/assets/sarabun-vietnamese-400-normal-igdrzAGY.woff2 +0 -0
  909. package/out/web/assets/sarabun-vietnamese-500-normal-CpRVvGRU.woff +0 -0
  910. package/out/web/assets/sarabun-vietnamese-500-normal-Dpe7Br9m.woff2 +0 -0
  911. package/out/web/assets/sarabun-vietnamese-600-normal-BMbOgAW-.woff +0 -0
  912. package/out/web/assets/sarabun-vietnamese-600-normal-DkxrOlLB.woff2 +0 -0
  913. package/out/web/assets/stateDiagram-v2-4QOOHH4V-s6P9g2_b.js +0 -1
  914. package/out/web/assets/swimlanesDiagram-VK2B7HYN-CV9fOLrV.js +0 -8
  915. package/out/web/assets/tauri-CNrHQ2Gb.js +0 -3
  916. package/out/web/assets/window-DkIhgI7B.js +0 -1
  917. package/skills-lock.json +0 -41
  918. package/src/renderer/public/Live2DCubismCore.js +0 -9
  919. package/src/renderer/src/companionWidget.ts +0 -95
  920. package/src/renderer/src/components/Live2DStage.tsx +0 -389
  921. package/src/renderer/src/components/ModelPanel.tsx +0 -154
  922. package/src/renderer/src/css/model.css +0 -627
  923. package/src/renderer/src-web/companionWidget.ts +0 -37
  924. package/src/renderer/src-web/components/ModelPanel.tsx +0 -12
  925. package/src/renderer/src-web/css/model.css +0 -628
@@ -7,11 +7,24 @@ use std::future::Future;
7
7
  use std::path::{Path, PathBuf};
8
8
  use std::pin::Pin;
9
9
  use std::process::Command;
10
+ use std::sync::Arc;
10
11
  use std::time::Instant;
11
12
  use thiserror::Error;
12
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
+
13
25
  use crate::chat::{
14
- ChatMessage, ChatRole, ContentBlock, send_chat_with_fallback, stream_chat_with_fallback,
26
+ ChatMessage, ChatRole, ChatStreamEvent, ContentBlock, send_chat_with_fallback,
27
+ stream_chat_events_with_fallback, stream_chat_with_fallback,
15
28
  };
16
29
  use crate::code_tools::{
17
30
  CodeEdit, CodePatchHunk, apply_code_edits, build_code_patch, list_code_files,
@@ -41,6 +54,176 @@ const CONTEXT_LIMIT: usize = 3;
41
54
  /// continuity, not to re-litigate a whole previous answer.
42
55
  const MAX_CONTEXT_MESSAGE_CHARS: usize = 200;
43
56
 
57
+ struct TurnStartListener {
58
+ chat_id: String,
59
+ callback: Arc<dyn Fn(i64) + Send + Sync>,
60
+ }
61
+
62
+ tokio::task_local! {
63
+ static TURN_START_LISTENER: TurnStartListener;
64
+ }
65
+
66
+ /// Reports the persisted turn ID to the initiating transport without changing
67
+ /// the agent/chat interfaces used by CLI, jobs, and background integrations.
68
+ pub async fn with_turn_start_listener<F, R>(
69
+ chat_id: String,
70
+ callback: impl Fn(i64) + Send + Sync + 'static,
71
+ future: F,
72
+ ) -> R
73
+ where
74
+ F: Future<Output = R>,
75
+ {
76
+ TURN_START_LISTENER
77
+ .scope(
78
+ TurnStartListener {
79
+ chat_id,
80
+ callback: Arc::new(callback),
81
+ },
82
+ future,
83
+ )
84
+ .await
85
+ }
86
+
87
+ /// Owns one persisted turn, including its cross-process queue lease. Dropping a
88
+ /// cancelled task marks its prompt interrupted instead of leaving it running.
89
+ struct TurnLease {
90
+ chat_id: String,
91
+ memory: MemoryStore,
92
+ id: i64,
93
+ heartbeat: tokio::task::JoinHandle<()>,
94
+ finished: bool,
95
+ activity: Arc<std::sync::Mutex<Vec<AgentProgress>>>,
96
+ }
97
+
98
+ impl TurnLease {
99
+ async fn start(memory: &MemoryStore, chat_id: &str, text: &str) -> Result<Self, MemoryError> {
100
+ let id = memory.start_turn(chat_id, text)?;
101
+ crate::companion::queued(chat_id, id, text);
102
+ let _ = TURN_START_LISTENER.try_with(|listener| {
103
+ if listener.chat_id == chat_id {
104
+ (listener.callback)(id);
105
+ }
106
+ });
107
+ let heartbeat_memory = memory.clone();
108
+ let heartbeat = tokio::spawn(async move {
109
+ loop {
110
+ tokio::time::sleep(std::time::Duration::from_secs(10)).await;
111
+ if heartbeat_memory.renew_turn(id).is_err() {
112
+ break;
113
+ }
114
+ }
115
+ });
116
+ let mut lease = Self {
117
+ chat_id: chat_id.to_owned(),
118
+ memory: memory.clone(),
119
+ id,
120
+ heartbeat,
121
+ finished: false,
122
+ activity: Arc::new(std::sync::Mutex::new(Vec::new())),
123
+ };
124
+ loop {
125
+ match memory.claim_turn(chat_id, id) {
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
+ }
135
+ Ok(false) => tokio::time::sleep(std::time::Duration::from_millis(250)).await,
136
+ Err(error) => {
137
+ lease.fail();
138
+ return Err(error);
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ fn persist_activity(&self) {
145
+ if let Ok(lock) = self.activity.lock() {
146
+ let compacted = compact_agent_progress(&lock);
147
+ if !compacted.is_empty() {
148
+ if let Ok(activity_json) = serde_json::to_string(&compacted) {
149
+ let _ = self
150
+ .memory
151
+ .set_interaction_agent_activity_json(self.id, &activity_json);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ fn complete_with_summary(
158
+ &mut self,
159
+ summary: &str,
160
+ provider: &str,
161
+ model: &str,
162
+ fallback_provider: Option<&str>,
163
+ ) -> Result<(), MemoryError> {
164
+ self.persist_activity();
165
+ self.memory
166
+ .finish_turn(self.id, summary, provider, model, fallback_provider)?;
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
+ );
174
+ Ok(())
175
+ }
176
+
177
+ fn complete(&mut self, response: &ChatResponse) -> Result<(), MemoryError> {
178
+ self.complete_with_summary(
179
+ &response.text,
180
+ &response.provider,
181
+ &response.model,
182
+ response.fallback_provider.as_deref(),
183
+ )
184
+ }
185
+
186
+ fn fail(&mut self) {
187
+ self.persist_activity();
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
+ );
207
+ self.finished = true;
208
+ }
209
+ }
210
+
211
+ impl Drop for TurnLease {
212
+ fn drop(&mut self) {
213
+ self.heartbeat.abort();
214
+ if !self.finished {
215
+ self.persist_activity();
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
+ );
223
+ }
224
+ }
225
+ }
226
+
44
227
  #[derive(Debug, Error)]
45
228
  pub enum OrchestrationError {
46
229
  #[error(transparent)]
@@ -105,19 +288,26 @@ pub async fn orchestrate_chat(
105
288
  config: &MintConfig,
106
289
  request: &ChatRequest,
107
290
  ) -> Result<ChatResponse, OrchestrationError> {
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
+ }
299
+ let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
108
300
  let mut resolved_request = request.clone();
109
301
  resolved_request.message = resolve_github_links(&request.message, config).await;
110
- let memory = MemoryStore::open_default()?;
111
302
  let enriched = enrich_request(config, &memory, &resolved_request)?;
112
- let response = send_chat(config, &enriched).await?;
113
- memory.add_interaction_for_chat_with_fallback(
114
- &request_chat_id(request),
115
- &request.message,
116
- &response.text,
117
- &response.provider,
118
- &response.model,
119
- response.fallback_provider.as_deref(),
120
- )?;
303
+ let response = match send_chat(config, &enriched).await {
304
+ Ok(response) => response,
305
+ Err(error) => {
306
+ turn.fail();
307
+ return Err(error.into());
308
+ }
309
+ };
310
+ turn.complete(&response)?;
121
311
  spawn_auto_memory_update(
122
312
  config.clone(),
123
313
  request.message.clone(),
@@ -129,6 +319,7 @@ pub async fn orchestrate_chat(
129
319
  config.clone(),
130
320
  request.message.clone(),
131
321
  response.text.clone(),
322
+ turn.id,
132
323
  );
133
324
  Ok(response)
134
325
  }
@@ -141,19 +332,36 @@ pub async fn orchestrate_chat_stream<F>(
141
332
  where
142
333
  F: FnMut(String),
143
334
  {
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
+ }
343
+ let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
144
344
  let mut resolved_request = request.clone();
145
345
  resolved_request.message = resolve_github_links(&request.message, config).await;
146
- let memory = MemoryStore::open_default()?;
147
346
  let enriched = enrich_request(config, &memory, &resolved_request)?;
148
- let response = stream_chat(config, &enriched, on_chunk).await?;
149
- memory.add_interaction_for_chat_with_fallback(
150
- &request_chat_id(request),
151
- &request.message,
152
- &response.text,
153
- &response.provider,
154
- &response.model,
155
- response.fallback_provider.as_deref(),
156
- )?;
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
+ {
358
+ Ok(response) => response,
359
+ Err(error) => {
360
+ turn.fail();
361
+ return Err(error.into());
362
+ }
363
+ };
364
+ turn.complete(&response)?;
157
365
  spawn_auto_memory_update(
158
366
  config.clone(),
159
367
  request.message.clone(),
@@ -165,6 +373,7 @@ where
165
373
  config.clone(),
166
374
  request.message.clone(),
167
375
  response.text.clone(),
376
+ turn.id,
168
377
  );
169
378
  Ok(response)
170
379
  }
@@ -173,19 +382,26 @@ pub async fn orchestrate_chat_with_fallback(
173
382
  config: &MintConfig,
174
383
  request: &ChatRequest,
175
384
  ) -> Result<(ChatResponse, Option<String>), OrchestrationError> {
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
+ }
393
+ let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
176
394
  let mut resolved_request = request.clone();
177
395
  resolved_request.message = resolve_github_links(&request.message, config).await;
178
- let memory = MemoryStore::open_default()?;
179
396
  let enriched = enrich_request(config, &memory, &resolved_request)?;
180
- let (response, fallback) = send_chat_with_fallback(config, &enriched).await?;
181
- memory.add_interaction_for_chat_with_fallback(
182
- &request_chat_id(request),
183
- &request.message,
184
- &response.text,
185
- &response.provider,
186
- &response.model,
187
- response.fallback_provider.as_deref(),
188
- )?;
397
+ let (response, fallback) = match send_chat_with_fallback(config, &enriched).await {
398
+ Ok(result) => result,
399
+ Err(error) => {
400
+ turn.fail();
401
+ return Err(error.into());
402
+ }
403
+ };
404
+ turn.complete(&response)?;
189
405
  spawn_auto_memory_update(
190
406
  config.clone(),
191
407
  request.message.clone(),
@@ -197,6 +413,7 @@ pub async fn orchestrate_chat_with_fallback(
197
413
  config.clone(),
198
414
  request.message.clone(),
199
415
  response.text.clone(),
416
+ turn.id,
200
417
  );
201
418
  Ok((response, fallback))
202
419
  }
@@ -209,19 +426,36 @@ pub async fn orchestrate_chat_stream_with_fallback<F>(
209
426
  where
210
427
  F: FnMut(String),
211
428
  {
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
+ }
437
+ let mut turn = TurnLease::start(&memory, &request_chat_id(request), &request.message).await?;
212
438
  let mut resolved_request = request.clone();
213
439
  resolved_request.message = resolve_github_links(&request.message, config).await;
214
- let memory = MemoryStore::open_default()?;
215
440
  let enriched = enrich_request(config, &memory, &resolved_request)?;
216
- let (response, fallback) = stream_chat_with_fallback(config, &enriched, on_chunk).await?;
217
- memory.add_interaction_for_chat_with_fallback(
218
- &request_chat_id(request),
219
- &request.message,
220
- &response.text,
221
- &response.provider,
222
- &response.model,
223
- response.fallback_provider.as_deref(),
224
- )?;
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
+ {
452
+ Ok(result) => result,
453
+ Err(error) => {
454
+ turn.fail();
455
+ return Err(error.into());
456
+ }
457
+ };
458
+ turn.complete(&response)?;
225
459
  spawn_auto_memory_update(
226
460
  config.clone(),
227
461
  request.message.clone(),
@@ -233,10 +467,33 @@ where
233
467
  config.clone(),
234
468
  request.message.clone(),
235
469
  response.text.clone(),
470
+ turn.id,
236
471
  );
237
472
  Ok((response, fallback))
238
473
  }
239
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
+
240
497
  /// Truncates `text` to at most `max_chars` characters (not bytes, so this
241
498
  /// never splits a multi-byte UTF-8 character) for injection into a "recent
242
499
  /// context" summary. Cheap no-op for the common case of a short message.
@@ -255,7 +512,7 @@ fn enrich_request(
255
512
  request: &ChatRequest,
256
513
  ) -> Result<ChatRequest, MemoryError> {
257
514
  let mut interactions =
258
- memory.recent_interactions_for_chat(&request_chat_id(request), CONTEXT_LIMIT)?;
515
+ memory.recent_completed_interactions_for_chat(&request_chat_id(request), CONTEXT_LIMIT)?;
259
516
  interactions.reverse();
260
517
  let transcript = interactions
261
518
  .into_iter()
@@ -372,6 +629,8 @@ use workspace_helpers::*;
372
629
  /// tasks earlier, but 24 turned out too tight for legitimate multi-file
373
630
  /// work, cutting it off mid-task — raised to 40 as a middle ground.
374
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;
375
634
  const MAX_OBSERVATION_BYTES: usize = 16_000;
376
635
  /// Compact `native_messages` once a step's reported token usage crosses this
377
636
  /// fraction of `MintConfig::context_window_tokens`. Every step resends the whole
@@ -404,6 +663,12 @@ pub struct AskUserOption {
404
663
 
405
664
  #[derive(Debug, Clone, Serialize, Deserialize)]
406
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
+ },
407
672
  WriteFile {
408
673
  path: String,
409
674
  content: String,
@@ -471,6 +736,31 @@ pub const MCP_ALLOW_ALL_SENTINEL: &str = "__mcp_allow_all__";
471
736
  #[derive(Debug, Clone, Serialize, Deserialize)]
472
737
  #[serde(tag = "type", content = "data")]
473
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
+ },
474
764
  Thinking {
475
765
  elapsed_secs: u64,
476
766
  #[serde(skip_serializing_if = "Option::is_none")]
@@ -514,6 +804,34 @@ pub enum AgentProgress {
514
804
  Thought {
515
805
  thought: String,
516
806
  },
807
+ /// Incremental model reasoning for the currently active agent step. These
808
+ /// events are live-only; consumers replace them with the matching final
809
+ /// `ExtendedThinking` record instead of persisting every delta.
810
+ ThinkingDelta {
811
+ id: String,
812
+ delta: String,
813
+ elapsed_ms: u64,
814
+ },
815
+ /// Extended reasoning / chain-of-thought from the model's API thinking
816
+ /// tokens (e.g. Claude `<thinking>`, Gemini Thinking, DeepSeek Reasoner,
817
+ /// or `<think>` tags). Semantically distinct from `Thought`, which
818
+ /// carries short, one-line agent notes (fallback warnings, decision
819
+ /// summaries). The CLI renders this as a collapsible block; the web/
820
+ /// desktop UI shows it in a dedicated "Extended Thinking" panel.
821
+ ExtendedThinking {
822
+ #[serde(default, skip_serializing_if = "Option::is_none")]
823
+ id: Option<String>,
824
+ thought: String,
825
+ #[serde(default, skip_serializing_if = "Option::is_none")]
826
+ elapsed_ms: Option<u64>,
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
+ },
517
835
  /// Emitted while retrying a step after every configured provider was
518
836
  /// unreachable (`ChatError::NetworkUnavailable`) — distinct from
519
837
  /// `Thinking` because there's nothing to wait *on* here except the
@@ -523,6 +841,8 @@ pub enum AgentProgress {
523
841
  max_attempts: usize,
524
842
  },
525
843
  ToolStart {
844
+ #[serde(default, rename = "callId", skip_serializing_if = "Option::is_none")]
845
+ call_id: Option<String>,
526
846
  action: String,
527
847
  input: Value,
528
848
  /// Name of the subagent this tool call happened inside, if any —
@@ -535,6 +855,12 @@ pub enum AgentProgress {
535
855
  subagent: Option<String>,
536
856
  },
537
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>,
538
864
  action: String,
539
865
  input: Value,
540
866
  result: String,
@@ -549,6 +875,112 @@ pub enum AgentProgress {
549
875
  },
550
876
  }
551
877
 
878
+ /// Determines whether a thought string represents long-form / internal chain-of-thought
879
+ /// (reasoning tokens, <think> tags, or multi-line reasoning) versus a brief agent note.
880
+ pub fn is_internal_cot(text: &str) -> bool {
881
+ let trimmed = text.trim();
882
+ if trimmed.starts_with("<think>") || trimmed.contains("</think>") {
883
+ return true;
884
+ }
885
+ let line_count = trimmed.lines().count();
886
+ let char_count = trimmed.chars().count();
887
+ if trimmed.contains("\n\n") || line_count > 2 || char_count > 180 {
888
+ return true;
889
+ }
890
+ let lower = trimmed.to_lowercase();
891
+ if lower.starts_with("the user ")
892
+ || lower.starts_with("let's think")
893
+ || lower.starts_with("let me analyze")
894
+ || lower.starts_with("per rule")
895
+ || lower.starts_with("note the ")
896
+ || lower.contains("thinking process:")
897
+ {
898
+ return true;
899
+ }
900
+ false
901
+ }
902
+
903
+ /// Compacts an agent progress event stream for storage, removing transport-only
904
+ /// streaming deltas (`ThinkingDelta`) while preserving complete `ExtendedThinking`
905
+ /// blocks, or synthesizing an `ExtendedThinking` block for any interrupted delta sequence.
906
+ pub fn compact_agent_progress(progress: &[AgentProgress]) -> Vec<AgentProgress> {
907
+ let completed_ids: std::collections::HashSet<String> = progress
908
+ .iter()
909
+ .filter_map(|event| match event {
910
+ AgentProgress::ExtendedThinking { id: Some(id), .. } => Some(id.clone()),
911
+ _ => None,
912
+ })
913
+ .collect();
914
+
915
+ let mut result = Vec::new();
916
+ let mut interrupted_order: Vec<String> = Vec::new();
917
+ let mut interrupted: std::collections::HashMap<String, (String, Option<u64>)> =
918
+ std::collections::HashMap::new();
919
+
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
+ }
946
+ match event {
947
+ AgentProgress::ThinkingDelta {
948
+ id,
949
+ delta,
950
+ elapsed_ms,
951
+ } => {
952
+ if !completed_ids.contains(id) {
953
+ if !interrupted.contains_key(id) {
954
+ interrupted_order.push(id.clone());
955
+ }
956
+ interrupted
957
+ .entry(id.clone())
958
+ .and_modify(|(text, elapsed)| {
959
+ text.push_str(delta);
960
+ *elapsed = Some(*elapsed_ms);
961
+ })
962
+ .or_insert_with(|| (delta.clone(), Some(*elapsed_ms)));
963
+ }
964
+ }
965
+ _ => {
966
+ result.push(event.clone());
967
+ }
968
+ }
969
+ }
970
+
971
+ for id in interrupted_order {
972
+ if let Some((thought, elapsed_ms)) = interrupted.remove(&id) {
973
+ result.push(AgentProgress::ExtendedThinking {
974
+ id: Some(id),
975
+ thought,
976
+ elapsed_ms,
977
+ });
978
+ }
979
+ }
980
+
981
+ result
982
+ }
983
+
552
984
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
553
985
  #[serde(rename_all = "camelCase")]
554
986
  pub struct ToolExecutionRecord {
@@ -598,6 +1030,8 @@ pub struct AgentResult {
598
1030
  pub summary: String,
599
1031
  pub verification: String,
600
1032
  pub fallback: Option<String>,
1033
+ #[serde(default)]
1034
+ pub fallback_reason: Option<String>,
601
1035
  /// Sum of `total_tokens` across every step's API response this turn —
602
1036
  /// see the doc comment on `turn_total_tokens` where it's accumulated.
603
1037
  pub total_tokens: u64,
@@ -722,6 +1156,24 @@ struct AgentInput {
722
1156
  button: String,
723
1157
  #[serde(default)]
724
1158
  key: String,
1159
+ #[serde(default)]
1160
+ element_ref: String,
1161
+ #[serde(default)]
1162
+ tab_id: String,
1163
+ #[serde(default)]
1164
+ operation: String,
1165
+ #[serde(default)]
1166
+ condition: String,
1167
+ #[serde(default)]
1168
+ value: String,
1169
+ #[serde(default)]
1170
+ timeout_ms: Option<u64>,
1171
+ #[serde(default)]
1172
+ text_offset: Option<u64>,
1173
+ #[serde(default)]
1174
+ element_offset: Option<u64>,
1175
+ #[serde(default)]
1176
+ takeover_complete: bool,
725
1177
  // Video tools input fields
726
1178
  #[serde(default)]
727
1179
  input: String,
@@ -820,7 +1272,59 @@ fn deserialize_agent_input<'de, D>(deserializer: D) -> Result<AgentInput, D::Err
820
1272
  where
821
1273
  D: serde::Deserializer<'de>,
822
1274
  {
823
- 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
+ })
824
1328
  }
825
1329
 
826
1330
  fn resolve_agent_config(
@@ -915,22 +1419,56 @@ fn resolve_agent_config(
915
1419
  )
916
1420
  }
917
1421
 
918
- /// Calls `send_chat_with_fallback`, retrying up to `NETWORK_RETRY_ATTEMPTS`
919
- /// times (waiting `NETWORK_RETRY_DELAY` between each) when every configured
920
- /// provider comes back `ChatError::NetworkUnavailable` — swapping providers
921
- /// is pointless when the network itself is down, so this waits for it to
922
- /// come back instead of burning through the provider list, keeping the user
923
- /// informed via `AgentProgress::WaitingForNetwork` in the meantime. Any other
924
- /// error (including `NetworkUnavailable` after the last attempt) is returned
925
- /// immediately, unchanged.
926
- async fn send_chat_with_network_retry(
1422
+ enum AgentStreamChunk {
1423
+ DirectText(String),
1424
+ FinalSummary(String),
1425
+ }
1426
+
1427
+ async fn stream_chat_with_network_retry(
927
1428
  config: &MintConfig,
928
1429
  request: &ChatRequest,
1430
+ thought_id: &str,
1431
+ thought_started: Instant,
1432
+ fast_mode: bool,
929
1433
  progress: &mut (dyn FnMut(AgentProgress) + Send),
1434
+ stream_json_prompt: bool,
1435
+ allow_final_stream: bool,
1436
+ on_stream_chunk: &mut (dyn FnMut(AgentStreamChunk) + Send),
930
1437
  ) -> Result<(ChatResponse, Option<String>), ChatError> {
931
1438
  let mut attempt = 0;
1439
+ let mut summary_stream = FinishSummaryStream::default();
932
1440
  loop {
933
- match send_chat_with_fallback(config, request).await {
1441
+ let result = stream_chat_events_with_fallback(config, request, |event| match event {
1442
+ ChatStreamEvent::ReasoningDelta { delta } if !fast_mode && !delta.is_empty() => {
1443
+ progress(AgentProgress::ThinkingDelta {
1444
+ id: thought_id.to_owned(),
1445
+ delta,
1446
+ elapsed_ms: thought_started.elapsed().as_millis() as u64,
1447
+ });
1448
+ }
1449
+ ChatStreamEvent::TextDelta { delta } if allow_final_stream => {
1450
+ if stream_json_prompt {
1451
+ summary_stream.push_json_prompt(&delta, &mut |chunk| {
1452
+ on_stream_chunk(AgentStreamChunk::FinalSummary(chunk));
1453
+ });
1454
+ }
1455
+ }
1456
+ ChatStreamEvent::ToolCallDelta {
1457
+ index,
1458
+ name,
1459
+ arguments,
1460
+ input,
1461
+ } if allow_final_stream => summary_stream.push_tool_call(
1462
+ index,
1463
+ name.as_deref(),
1464
+ arguments.as_deref(),
1465
+ input.as_ref(),
1466
+ &mut |chunk| on_stream_chunk(AgentStreamChunk::FinalSummary(chunk)),
1467
+ ),
1468
+ _ => {}
1469
+ })
1470
+ .await;
1471
+ match result {
934
1472
  Err(ChatError::NetworkUnavailable) if attempt < NETWORK_RETRY_ATTEMPTS => {
935
1473
  attempt += 1;
936
1474
  progress(AgentProgress::WaitingForNetwork {
@@ -939,11 +1477,184 @@ async fn send_chat_with_network_retry(
939
1477
  });
940
1478
  tokio::time::sleep(NETWORK_RETRY_DELAY).await;
941
1479
  }
1480
+ Ok((response, fallback)) => {
1481
+ if !stream_json_prompt
1482
+ && allow_final_stream
1483
+ && !provider_truncated_response(response.stop_reason.as_deref())
1484
+ && response
1485
+ .tool_calls
1486
+ .as_ref()
1487
+ .is_none_or(|calls| calls.is_empty())
1488
+ && !response.text.is_empty()
1489
+ {
1490
+ // Plain text from a native tool-calling response is only a final
1491
+ // answer if the completed model turn contains no tool calls. Buffer
1492
+ // it until then so an assistant preamble doesn't appear under
1493
+ // "Mint:" while the requested tools are still about to run.
1494
+ on_stream_chunk(AgentStreamChunk::DirectText(response.text.clone()));
1495
+ }
1496
+ return Ok((response, fallback));
1497
+ }
942
1498
  other => return other,
943
1499
  }
944
1500
  }
945
1501
  }
946
1502
 
1503
+ #[derive(Default)]
1504
+ struct FinishSummaryStream {
1505
+ tool_names: std::collections::HashMap<usize, String>,
1506
+ tool_arguments: std::collections::HashMap<usize, String>,
1507
+ json_prompt: String,
1508
+ emitted: String,
1509
+ }
1510
+
1511
+ impl FinishSummaryStream {
1512
+ fn push_tool_call(
1513
+ &mut self,
1514
+ index: usize,
1515
+ name_delta: Option<&str>,
1516
+ arguments_delta: Option<&str>,
1517
+ input: Option<&serde_json::Value>,
1518
+ on_chunk: &mut (dyn FnMut(String) + Send),
1519
+ ) {
1520
+ if let Some(name) = name_delta {
1521
+ self.tool_names.insert(index, name.to_owned());
1522
+ }
1523
+ if let Some(arguments) = arguments_delta {
1524
+ self.tool_arguments
1525
+ .entry(index)
1526
+ .or_default()
1527
+ .push_str(arguments);
1528
+ }
1529
+ if let Some(input) = input {
1530
+ self.tool_arguments.insert(index, input.to_string());
1531
+ }
1532
+ if self
1533
+ .tool_names
1534
+ .get(&index)
1535
+ .is_some_and(|name| name == "finish")
1536
+ && let Some(arguments) = self.tool_arguments.get(&index)
1537
+ && let Some(summary) = json_string_field_prefix(arguments, "summary")
1538
+ {
1539
+ self.emit_new_text(&summary, on_chunk);
1540
+ }
1541
+ }
1542
+
1543
+ fn push_json_prompt(&mut self, delta: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
1544
+ self.json_prompt.push_str(delta);
1545
+ if json_string_field_prefix(&self.json_prompt, "action").as_deref() == Some("finish")
1546
+ && let Some(summary) = json_string_field_prefix(&self.json_prompt, "summary")
1547
+ {
1548
+ self.emit_new_text(&summary, on_chunk);
1549
+ }
1550
+ }
1551
+
1552
+ fn emit_new_text(&mut self, text: &str, on_chunk: &mut (dyn FnMut(String) + Send)) {
1553
+ if !text.starts_with(&self.emitted) || text.len() == self.emitted.len() {
1554
+ return;
1555
+ }
1556
+ let delta = text[self.emitted.len()..].to_owned();
1557
+ self.emitted.push_str(&delta);
1558
+ on_chunk(delta);
1559
+ }
1560
+ }
1561
+
1562
+ fn unstreamed_summary_remainder<'a>(
1563
+ summary: &'a str,
1564
+ streamed_finish: &str,
1565
+ streamed_direct: &str,
1566
+ ) -> &'a str {
1567
+ let streamed_finish = streamed_finish.trim();
1568
+ if !streamed_finish.is_empty()
1569
+ && let Some(remainder) = summary.strip_prefix(streamed_finish)
1570
+ {
1571
+ return remainder;
1572
+ }
1573
+
1574
+ let streamed_direct = streamed_direct.trim();
1575
+ if !streamed_direct.is_empty()
1576
+ && let Some(remainder) = summary.strip_prefix(streamed_direct)
1577
+ {
1578
+ return remainder;
1579
+ }
1580
+
1581
+ summary
1582
+ }
1583
+
1584
+ /// Reads a JSON string-valued property even while its value is still arriving.
1585
+ /// It skips quoted values and only recognizes actual object keys, so a mention
1586
+ /// of `"summary"` inside a thought or another string cannot leak to the chat.
1587
+ fn json_string_field_prefix(input: &str, field: &str) -> Option<String> {
1588
+ let bytes = input.as_bytes();
1589
+ let mut cursor = 0;
1590
+ while cursor < bytes.len() {
1591
+ if bytes[cursor] != b'"' {
1592
+ cursor += 1;
1593
+ continue;
1594
+ }
1595
+ let start = cursor;
1596
+ cursor += 1;
1597
+ let mut escaped = false;
1598
+ let mut end = None;
1599
+ while cursor < bytes.len() {
1600
+ match (bytes[cursor], escaped) {
1601
+ (b'"', false) => {
1602
+ end = Some(cursor);
1603
+ cursor += 1;
1604
+ break;
1605
+ }
1606
+ (b'\\', false) => escaped = true,
1607
+ (_, true) => escaped = false,
1608
+ _ => {}
1609
+ }
1610
+ cursor += 1;
1611
+ }
1612
+ if end.is_none() {
1613
+ break;
1614
+ }
1615
+ let Ok(key) = serde_json::from_str::<String>(&input[start..cursor]) else {
1616
+ continue;
1617
+ };
1618
+ if key != field {
1619
+ continue;
1620
+ }
1621
+ let mut value_start = cursor;
1622
+ while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() {
1623
+ value_start += 1;
1624
+ }
1625
+ if value_start >= bytes.len() || bytes[value_start] != b':' {
1626
+ continue;
1627
+ }
1628
+ value_start += 1;
1629
+ while value_start < bytes.len() && bytes[value_start].is_ascii_whitespace() {
1630
+ value_start += 1;
1631
+ }
1632
+ if value_start >= bytes.len() || bytes[value_start] != b'"' {
1633
+ continue;
1634
+ }
1635
+ let value_content_start = value_start + 1;
1636
+ let mut value_cursor = value_content_start;
1637
+ let mut value_escaped = false;
1638
+ while value_cursor < bytes.len() {
1639
+ match (bytes[value_cursor], value_escaped) {
1640
+ (b'"', false) => {
1641
+ return serde_json::from_str::<String>(&input[value_start..value_cursor + 1])
1642
+ .ok();
1643
+ }
1644
+ (b'\\', false) => value_escaped = true,
1645
+ (_, true) => value_escaped = false,
1646
+ _ => {}
1647
+ }
1648
+ value_cursor += 1;
1649
+ }
1650
+ // Close a temporary copy of an incomplete string. Invalid partial
1651
+ // escapes (e.g. half a `\\uXXXX`) are held until the next provider delta.
1652
+ let partial = format!("\"{}\"", &input[value_content_start..]);
1653
+ return serde_json::from_str::<String>(&partial).ok();
1654
+ }
1655
+ None
1656
+ }
1657
+
947
1658
  /// Returns a boxed `dyn Future` trait object rather than being a plain
948
1659
  /// `async fn` (whose return type would otherwise be an opaque, compiler-
949
1660
  /// inferred type). This is required, not just a style choice: `execute_tool`
@@ -978,28 +1689,83 @@ where
978
1689
  Progress: FnMut(AgentProgress) + Send + 'a,
979
1690
  Chunk: FnMut(String) + Send + 'a,
980
1691
  {
981
- Box::pin(async move {
982
- let started_at = Instant::now();
983
- let root = root.canonicalize().map_err(|e| {
984
- OrchestrationError::Agent(format!(
985
- "unable to resolve workspace root {}: {}",
986
- root.display(),
987
- e
988
- ))
989
- })?;
990
- let resolved_task = resolve_github_links(task, config).await;
991
- let chat_id = chat_id
1692
+ Box::pin(run_control::scope(
1693
+ chat_id
992
1694
  .map(str::trim)
993
- .filter(|chat_id| !chat_id.is_empty())
994
- .unwrap_or(DEFAULT_CONVERSATION_ID);
995
- // `root` is always resolved to a real, canonicalized directory above
996
- // (never optional here, unlike the plain-chat path's `ChatRequest.
997
- // workspace_path`), so an agent-mode conversation is always scoped to
998
- // *some* workspace — it just never falls back to the plain global
999
- // "cli" bucket the way plain chat can. Idempotent on chat ids that
1000
- // are already scoped or aren't "cli" at all (see `scoped_chat_id`).
1001
- let chat_id = crate::agent::memory::scoped_chat_id(chat_id, Some(&root.to_string_lossy()));
1002
- let chat_id = chat_id.as_str();
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 {
1003
1769
  // Subagent runs use a synthetic `{parent_chat_id}::subagent::{name}` chat id
1004
1770
  // (see the `dispatch_subagent` arm in `execute_tool`) so their own memory
1005
1771
  // interaction doesn't leak into the parent conversation's history. That
@@ -1009,7 +1775,7 @@ where
1009
1775
  let allow_subagent_dispatch = !chat_id.contains("::subagent::");
1010
1776
  let skills =
1011
1777
  crate::skills::learned_skills_context(Some(&root), Some(chat_id)).unwrap_or_default();
1012
- 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.");
1013
1779
  let mut pending_image = image_data_uri;
1014
1780
  let mut pending_audio = audio_data_uri;
1015
1781
  let mut pending_video = video_data_uri;
@@ -1065,7 +1831,12 @@ where
1065
1831
  #[allow(unused_assignments)]
1066
1832
  let mut final_model = "".to_string();
1067
1833
  let mut final_fallback = None;
1834
+ let mut final_fallback_reason = None;
1068
1835
  let mut action_counts = BTreeMap::<String, usize>::new();
1836
+ let mut invalid_tool_calls = ToolArgumentRetries::default();
1837
+ let mut repeated_failures = RepeatedFailures::default();
1838
+ let mut streamed_finish_summary = String::new();
1839
+ let mut streamed_direct_text = String::new();
1069
1840
  // Track the most recent step (if any) that successfully modified a file
1070
1841
  // (`apply_patch`/`write_file`) and the most recent step that ran `verify`,
1071
1842
  // so `finish` can be rejected when code was changed but never checked —
@@ -1113,12 +1884,23 @@ where
1113
1884
  let mut last_input_tokens: u64 = 0;
1114
1885
  let mut turn_generated_tokens: u64 = 0;
1115
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();
1116
1889
  let mut files_modified: std::collections::BTreeSet<String> =
1117
1890
  std::collections::BTreeSet::new();
1118
1891
  let mut files_created: std::collections::BTreeSet<String> =
1119
1892
  std::collections::BTreeSet::new();
1120
1893
 
1121
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())); }
1898
+ let thought_id = if let Some(name) = chat_id.split("::subagent::").nth(1) {
1899
+ format!("subagent-{name}-step-{step}")
1900
+ } else {
1901
+ format!("root-step-{step}")
1902
+ };
1903
+ let thought_started = Instant::now();
1122
1904
  let (active_config, agent_instruction, active_agent_name, active_model_name) =
1123
1905
  resolve_agent_config(config, agent_id, &trajectory);
1124
1906
 
@@ -1134,6 +1916,16 @@ where
1134
1916
  });
1135
1917
 
1136
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
+ }
1137
1929
  if let Some(ref model_name) = active_model_name {
1138
1930
  active_system_prompt.push_str(&format!(
1139
1931
  "\n\n[Active Environment Context]\n\
@@ -1150,6 +1942,12 @@ where
1150
1942
  }
1151
1943
 
1152
1944
  let tool_mode = active_config.tool_calling_mode();
1945
+ // A finish can still be rejected after the provider finishes
1946
+ // streaming when edited files have not been verified. Don't show
1947
+ // that provisional summary to the user as if the run were done.
1948
+ let allow_final_stream =
1949
+ !unverified_modification(last_modify_step, last_verify_step, "")
1950
+ && last_verify_failed != Some(true);
1153
1951
 
1154
1952
  if tool_mode == ToolCallingMode::JsonPrompt
1155
1953
  && active_config.ai_provider == "ollama"
@@ -1204,72 +2002,108 @@ where
1204
2002
  content,
1205
2003
  });
1206
2004
  }
1207
- send_chat_with_network_retry(
1208
- &active_config,
1209
- &ChatRequest {
1210
- message: String::new(),
1211
- system_instruction: active_system_prompt.clone(),
1212
- chat_id: Some(chat_id.to_owned()),
1213
- image_data_uri: None,
1214
- audio_data_uri: None,
1215
- video_data_uri: None,
1216
- document_attachment: None,
1217
- workspace_path: None,
1218
- agent_id: None,
1219
- plan_mode: false,
1220
- pinned_mcp_server: None,
1221
- messages: Some(native_messages.clone()),
1222
- tools: Some(tool_catalog(
1223
- &active_config,
1224
- plan_mode,
1225
- &root,
1226
- allow_subagent_dispatch,
1227
- )),
1228
- temperature: active_config.temperature,
1229
- },
1230
- &mut progress,
1231
- )
1232
- .await?
2005
+ {
2006
+ let mut emit_stream_chunk = |chunk| match chunk {
2007
+ AgentStreamChunk::DirectText(delta) => {
2008
+ streamed_direct_text.push_str(&delta);
2009
+ on_chunk(delta);
2010
+ }
2011
+ AgentStreamChunk::FinalSummary(delta) => {
2012
+ streamed_finish_summary.push_str(&delta);
2013
+ on_chunk(delta);
2014
+ }
2015
+ };
2016
+ run_control::run(stream_chat_with_network_retry(
2017
+ &active_config,
2018
+ &ChatRequest {
2019
+ message: String::new(),
2020
+ system_instruction: active_system_prompt.clone(),
2021
+ chat_id: Some(chat_id.to_owned()),
2022
+ image_data_uri: None,
2023
+ audio_data_uri: None,
2024
+ video_data_uri: None,
2025
+ document_attachment: None,
2026
+ workspace_path: None,
2027
+ agent_id: None,
2028
+ plan_mode: false,
2029
+ pinned_mcp_server: None,
2030
+ messages: Some(native_messages.clone()),
2031
+ tools: Some(tool_catalog(
2032
+ &active_config,
2033
+ plan_mode,
2034
+ &root,
2035
+ allow_subagent_dispatch,
2036
+ )),
2037
+ temperature: active_config.temperature,
2038
+ ..Default::default()
2039
+ },
2040
+ &thought_id,
2041
+ thought_started,
2042
+ fast_mode,
2043
+ &mut progress,
2044
+ false,
2045
+ allow_final_stream,
2046
+ &mut emit_stream_chunk,
2047
+ ))
2048
+ .await?
2049
+ }
1233
2050
  } else {
1234
- send_chat_with_network_retry(
1235
- &active_config,
1236
- &ChatRequest {
1237
- message: observation.clone(),
1238
- system_instruction: active_system_prompt.clone(),
1239
- chat_id: Some(chat_id.to_owned()),
1240
- image_data_uri: pending_image.take(),
1241
- audio_data_uri: pending_audio.take(),
1242
- video_data_uri: pending_video.take(),
1243
- document_attachment: None,
1244
- workspace_path: None,
1245
- agent_id: None,
1246
- plan_mode: false,
1247
- pinned_mcp_server: None,
1248
- messages: None,
1249
- tools: None,
1250
- temperature: active_config.temperature,
1251
- },
1252
- &mut progress,
1253
- )
1254
- .await?
2051
+ {
2052
+ let mut emit_stream_chunk = |chunk| match chunk {
2053
+ AgentStreamChunk::DirectText(delta) => {
2054
+ streamed_direct_text.push_str(&delta);
2055
+ on_chunk(delta);
2056
+ }
2057
+ AgentStreamChunk::FinalSummary(delta) => {
2058
+ streamed_finish_summary.push_str(&delta);
2059
+ on_chunk(delta);
2060
+ }
2061
+ };
2062
+ run_control::run(stream_chat_with_network_retry(
2063
+ &active_config,
2064
+ &ChatRequest {
2065
+ message: observation.clone(),
2066
+ system_instruction: active_system_prompt.clone(),
2067
+ chat_id: Some(chat_id.to_owned()),
2068
+ image_data_uri: pending_image.take(),
2069
+ audio_data_uri: pending_audio.take(),
2070
+ video_data_uri: pending_video.take(),
2071
+ document_attachment: None,
2072
+ workspace_path: None,
2073
+ agent_id: None,
2074
+ plan_mode: false,
2075
+ pinned_mcp_server: None,
2076
+ messages: None,
2077
+ tools: None,
2078
+ temperature: active_config.temperature,
2079
+ ..Default::default()
2080
+ },
2081
+ &thought_id,
2082
+ thought_started,
2083
+ fast_mode,
2084
+ &mut progress,
2085
+ true,
2086
+ allow_final_stream,
2087
+ &mut emit_stream_chunk,
2088
+ ))
2089
+ .await?
2090
+ }
1255
2091
  };
1256
2092
 
1257
2093
  final_provider = response.provider.clone();
1258
2094
  final_model = response.model.clone();
1259
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);
1260
2098
  turn_generated_tokens += response.output_tokens.unwrap_or(0) as u64;
1261
2099
  if let Some(input) = response.input_tokens {
1262
2100
  last_input_tokens = input as u64;
1263
2101
  }
1264
2102
  if fallback.is_some() {
1265
- // `fallback` (this function's own return value) is the provider
1266
- // that actually served this response; `response.fallback_provider`
1267
- // is a same-shaped but differently-populated field that
1268
- // `send_chat_with_fallback` sets to the *original* provider that
1269
- // failed over — using it here showed e.g. "gemini → fallback:
1270
- // gemini • Qwen..." in the CLI badge instead of "gemini →
1271
- // fallback: huggingface • Qwen...".
1272
- final_fallback = fallback.clone();
2103
+ // Track the original provider that failed over (held in `response.fallback_provider`),
2104
+ // so UI and logs correctly show `<original> unavailable, fell back to <current>`.
2105
+ final_fallback = response.fallback_provider.clone();
2106
+ final_fallback_reason = response.fallback_reason.clone();
1273
2107
  if let Some(reason) = &response.fallback_reason {
1274
2108
  progress(AgentProgress::Thought {
1275
2109
  thought: format!(
@@ -1287,38 +2121,53 @@ where
1287
2121
  }
1288
2122
  }
1289
2123
 
1290
- // `decisions` is normally a single `(call_id, AgentDecision)`, matching the
1291
- // original one-action-per-step design. Native tool-calling can return
1292
- // several tool calls in one model turn, in which case they're executed
1293
- // sequentially and all their results are fed back before the next call —
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
+
2150
+ // `decisions` is normally a single `(call_id, AgentDecision)`, matching the
2151
+ // original one-action-per-step design. Native tool-calling can return
2152
+ // several tool calls in one model turn, in which case they're executed
2153
+ // sequentially and all their results are fed back before the next call —
1294
2154
  // `finish` never appears alongside real tool calls (see below), so this
1295
2155
  // never conflicts with the early-return finish handling.
2156
+ let mut step_tool_results: Vec<ToolResultEntry> = Vec::new();
1296
2157
  let decisions: Vec<(String, AgentDecision)> = if tool_mode == ToolCallingMode::Native {
1297
2158
  match response.tool_calls.clone() {
1298
- Some(calls) if !calls.is_empty() => calls
1299
- .into_iter()
1300
- .enumerate()
1301
- .map(|(index, call)| {
1302
- let thought = if index == 0 {
1303
- match &response.thought {
1304
- Some(t) if !t.trim().is_empty() => t.trim().to_string(),
1305
- _ => response.text.trim().to_string(),
1306
- }
1307
- } else {
1308
- String::new()
1309
- };
1310
- let input: AgentInput =
1311
- serde_json::from_value(call.input).unwrap_or_default();
1312
- (
1313
- call.id,
1314
- AgentDecision {
1315
- thought,
1316
- action: call.name,
1317
- input,
1318
- },
1319
- )
1320
- })
1321
- .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
+ }
1322
2171
  // No tool calls means the model answered directly — treat exactly
1323
2172
  // like the JSON-prompt path's fallback for plain, non-JSON text
1324
2173
  // (see `parse_decision_or_finish`): finish with that text as the
@@ -1355,7 +2204,7 @@ where
1355
2204
  let mut decision = match parse_decision_or_finish(&response.text) {
1356
2205
  Ok(decision) => decision,
1357
2206
  Err(_) => {
1358
- let (repaired, _) = send_chat_with_fallback(
2207
+ let (repaired, _) = run_control::run(send_chat_with_fallback(
1359
2208
  &active_config,
1360
2209
  &ChatRequest {
1361
2210
  message: format!(
@@ -1377,8 +2226,9 @@ where
1377
2226
  messages: None,
1378
2227
  tools: None,
1379
2228
  temperature: active_config.temperature,
2229
+ ..Default::default()
1380
2230
  },
1381
- )
2231
+ ))
1382
2232
  .await?;
1383
2233
  parse_decision_or_finish(&repaired.text).map_err(|e| {
1384
2234
  OrchestrationError::Agent(format!(
@@ -1388,6 +2238,13 @@ where
1388
2238
  })?
1389
2239
  }
1390
2240
  };
2241
+ // If the model's inline JSON `thought` is empty but the API
2242
+ // sent separate thinking tokens (reasoning models), emit them
2243
+ // as `ExtendedThinking` so the UI can display them in a
2244
+ // dedicated collapsible block instead of the short-thought
2245
+ // timeline. Still copy into `decision.thought` so the text
2246
+ // is available downstream, but the *progress event* uses the
2247
+ // correct variant.
1391
2248
  if decision.thought.trim().is_empty() {
1392
2249
  if let Some(t) = &response.thought {
1393
2250
  decision.thought = t.trim().to_string();
@@ -1396,7 +2253,19 @@ where
1396
2253
  vec![(format!("call_{step}"), decision)]
1397
2254
  };
1398
2255
 
1399
- let mut step_tool_results: Vec<(String, String, Value, String)> = Vec::new();
2256
+ let mut extended_emitted = false;
2257
+ if !fast_mode
2258
+ && let Some(t) = &response.thought
2259
+ && !t.trim().is_empty()
2260
+ {
2261
+ progress(AgentProgress::ExtendedThinking {
2262
+ id: Some(thought_id.clone()),
2263
+ thought: t.trim().to_owned(),
2264
+ elapsed_ms: Some(thought_started.elapsed().as_millis() as u64),
2265
+ });
2266
+ extended_emitted = true;
2267
+ }
2268
+
1400
2269
  // Screenshots (and any future binary/image tool result) are kept out of
1401
2270
  // `step_tool_results`'s plain-text `final_result` — that string is what
1402
2271
  // both the text `trajectory` and the JSON-prompt `observation` are built
@@ -1405,11 +2274,11 @@ where
1405
2274
  // data URI is stashed here by `call_id` instead, and re-attached as a
1406
2275
  // real `ContentBlock::Image` when building `native_messages` below, so
1407
2276
  // the model actually sees the pixels instead of a truncated text blob.
1408
- let mut step_images: std::collections::HashMap<String, String> =
2277
+ let mut step_images: std::collections::HashMap<String, Vec<String>> =
1409
2278
  std::collections::HashMap::new();
1410
2279
 
1411
2280
  if decisions_are_parallel_subagent_batch(&decisions) {
1412
- step_tool_results = run_parallel_subagent_batch(
2281
+ let completed = run_parallel_subagent_batch(
1413
2282
  &decisions,
1414
2283
  step,
1415
2284
  &root,
@@ -1421,8 +2290,10 @@ where
1421
2290
  &mut trajectory,
1422
2291
  )
1423
2292
  .await;
2293
+ invalid_tool_calls.observe_completed(&completed);
2294
+ step_tool_results.extend(completed);
1424
2295
  } else if decisions_are_parallel_read_only_batch(&decisions) {
1425
- step_tool_results = run_parallel_read_only_batch(
2296
+ let completed = run_parallel_read_only_batch(
1426
2297
  &decisions,
1427
2298
  step,
1428
2299
  &root,
@@ -1438,116 +2309,89 @@ where
1438
2309
  fast_mode,
1439
2310
  )
1440
2311
  .await;
2312
+ invalid_tool_calls.observe_completed(&completed);
2313
+ step_tool_results.extend(completed);
1441
2314
  } else {
1442
2315
  for (call_id, decision) in decisions {
1443
- if !fast_mode && !decision.thought.trim().is_empty() {
1444
- progress(AgentProgress::Thought {
1445
- thought: decision.thought.trim().to_owned(),
1446
- });
2316
+ if run_control::cancelled() { turn.interrupt(); return Err(OrchestrationError::Agent("Agent turn canceled by user".into())); }
2317
+ let d_thought = decision.thought.trim();
2318
+ if !fast_mode && !d_thought.is_empty() {
2319
+ let already_extended = extended_emitted
2320
+ && response.thought.as_deref().map(str::trim) == Some(d_thought);
2321
+ if !already_extended {
2322
+ if is_internal_cot(d_thought) {
2323
+ progress(AgentProgress::ExtendedThinking {
2324
+ id: Some(thought_id.clone()),
2325
+ thought: d_thought.to_owned(),
2326
+ elapsed_ms: Some(thought_started.elapsed().as_millis() as u64),
2327
+ });
2328
+ } else {
2329
+ progress(AgentProgress::Thought {
2330
+ thought: d_thought.to_owned(),
2331
+ });
2332
+ }
2333
+ }
1447
2334
  }
1448
2335
 
1449
2336
  if decision.action == "finish" {
1450
2337
  let mut summary = decision.input.summary.trim().to_owned();
1451
- let is_thai_task =
1452
- task.chars().any(|c| ('\u{0e00}'..='\u{0e7f}').contains(&c));
1453
- if let Some(err_line) = observation
1454
- .lines()
1455
- .find(|l| l.contains("Web search error:"))
1456
- {
1457
- let clean_err = err_line
1458
- .replace("Web search error: ", "")
1459
- .replace("Web search is currently unavailable.", "")
1460
- .trim()
1461
- .to_string();
1462
- if summary.is_empty() {
1463
- if is_thai_task {
1464
- summary = format!(
1465
- "การค้นหาข้อมูลจากเว็บล้มเหลวเนื่องจากข้อผิดพลาด: {}\nมิ้นท์ขออภัยด้วยนะคะที่ไม่สามารถค้นหาข้อมูลเรียลไทม์ให้ได้ในขณะนี้ค่ะ",
1466
- clean_err
1467
- );
1468
- } else {
1469
- summary = format!(
1470
- "Web search failed due to error: {}\nI apologize, but I cannot retrieve real-time information at the moment.",
1471
- clean_err
1472
- );
1473
- }
1474
- } else {
1475
- let err_lower = clean_err.to_lowercase();
1476
- let summary_lower = summary.to_lowercase();
1477
- let already_mentions_error = if is_thai_task {
1478
- summary_lower.contains("ล้มเหลว")
1479
- || summary_lower.contains("ข้อผิดพลาด")
1480
- || summary_lower.contains(&err_lower)
1481
- } else {
1482
- summary_lower.contains("fail")
1483
- || summary_lower.contains("error")
1484
- || summary_lower.contains(&err_lower)
1485
- };
1486
- if !already_mentions_error {
1487
- if is_thai_task {
1488
- summary.push_str(&format!(
1489
- "\n\n(การค้นหาเว็บล้มเหลวเนื่องจากข้อผิดพลาด: {})",
1490
- clean_err
1491
- ));
1492
- } else {
1493
- summary.push_str(&format!(
1494
- "\n\n(Web search failed due to error: {})",
1495
- clean_err
1496
- ));
1497
- }
1498
- }
1499
- }
1500
- } else {
1501
- if summary.is_empty() {
1502
- let err_msg = "Error: Your finish action summary was empty. \
2338
+ if summary.is_empty() {
2339
+ let err_msg = "Error: Your finish action summary was empty. \
1503
2340
  You MUST provide a final answer, explanation, or response to the user's query \
1504
2341
  in the 'summary' field of the 'finish' action input. Do not leave it empty.";
1505
- trajectory.push(format_trajectory_step(
1506
- step,
1507
- &decision.thought,
1508
- &decision.action,
1509
- err_msg,
1510
- ));
1511
- rebuild_observation(task, &root, &trajectory, &mut observation);
1512
- reject_native_finish(
1513
- tool_mode,
1514
- &mut native_messages,
1515
- &response.text,
1516
- err_msg,
1517
- );
1518
- continue 'steps;
1519
- }
1520
- if unverified_modification(
1521
- last_modify_step,
1522
- last_verify_step,
1523
- &decision.input.verification,
1524
- ) {
1525
- let err_msg = "Error: You modified a file (apply_patch/write_file) in this \
2342
+ trajectory.push(format_trajectory_step(
2343
+ step,
2344
+ &decision.thought,
2345
+ &decision.action,
2346
+ err_msg,
2347
+ ));
2348
+ rebuild_observation(task, &root, &trajectory, &mut observation);
2349
+ reject_native_finish(
2350
+ tool_mode,
2351
+ &mut native_messages,
2352
+ &response.text,
2353
+ err_msg,
2354
+ );
2355
+ continue 'steps;
2356
+ }
2357
+ if crate::browser::session_finish_evidence().await && meaningful_verification(&decision.input.verification).is_empty() {
2358
+ let error = "Browser tasks require observed evidence in finish.verification. Describe the observed outcome, or explicitly state that completion is unverified or blocked.";
2359
+ trajectory.push(format_trajectory_step(step, &decision.thought, &decision.action, error));
2360
+ rebuild_observation(task, &root, &trajectory, &mut observation);
2361
+ reject_native_finish(tool_mode, &mut native_messages, &response.text, error);
2362
+ continue 'steps;
2363
+ }
2364
+ if unverified_modification(
2365
+ last_modify_step,
2366
+ last_verify_step,
2367
+ &decision.input.verification,
2368
+ ) {
2369
+ let err_msg = "Error: You modified a file (apply_patch/write_file) in this \
1526
2370
  run but finished without verifying it. Call the verify tool \
1527
2371
  with build/test/lint commands appropriate for this project \
1528
2372
  before finishing. If no check genuinely applies (e.g. no test \
1529
2373
  suite, documentation-only change), say so explicitly in the \
1530
2374
  finish action's 'verification' field and finish again.";
1531
- trajectory.push(format_trajectory_step(
1532
- step,
1533
- &decision.thought,
1534
- &decision.action,
1535
- err_msg,
1536
- ));
1537
- rebuild_observation(task, &root, &trajectory, &mut observation);
1538
- reject_native_finish(
1539
- tool_mode,
1540
- &mut native_messages,
1541
- &response.text,
1542
- err_msg,
1543
- );
1544
- continue 'steps;
1545
- }
1546
- if unacknowledged_verify_failure(
1547
- last_verify_failed,
1548
- &decision.input.verification,
1549
- ) {
1550
- let err_msg = "Error: Your last verify call reported a failure \
2375
+ trajectory.push(format_trajectory_step(
2376
+ step,
2377
+ &decision.thought,
2378
+ &decision.action,
2379
+ err_msg,
2380
+ ));
2381
+ rebuild_observation(task, &root, &trajectory, &mut observation);
2382
+ reject_native_finish(
2383
+ tool_mode,
2384
+ &mut native_messages,
2385
+ &response.text,
2386
+ err_msg,
2387
+ );
2388
+ continue 'steps;
2389
+ }
2390
+ if unacknowledged_verify_failure(
2391
+ last_verify_failed,
2392
+ &decision.input.verification,
2393
+ ) {
2394
+ let err_msg = "Error: Your last verify call reported a failure \
1551
2395
  (non-zero exit code), but you're finishing without \
1552
2396
  addressing it. Read the stdout/stderr from that verify \
1553
2397
  call, fix the actual problem, and run verify again until \
@@ -1556,49 +2400,21 @@ where
1556
2400
  unrelated to your change (e.g. pre-existing), say so \
1557
2401
  explicitly in the finish action's 'verification' field and \
1558
2402
  finish again.";
1559
- trajectory.push(format_trajectory_step(
1560
- step,
1561
- &decision.thought,
1562
- &decision.action,
1563
- err_msg,
1564
- ));
1565
- rebuild_observation(task, &root, &trajectory, &mut observation);
1566
- reject_native_finish(
1567
- tool_mode,
1568
- &mut native_messages,
1569
- &response.text,
1570
- err_msg,
1571
- );
1572
- continue 'steps;
1573
- }
1574
- let mut provider_used = None;
1575
- for line in observation.lines() {
1576
- if line.contains("Web search succeeded using Google Search") {
1577
- provider_used = Some("Google");
1578
- } else if line.contains("Web search succeeded using Brave Search") {
1579
- provider_used = Some("Brave");
1580
- }
1581
- }
1582
- if let Some(prov) = provider_used {
1583
- let summary_lower = summary.to_lowercase();
1584
- if !summary_lower.contains("google")
1585
- && !summary_lower.contains("brave")
1586
- {
1587
- if is_thai_task {
1588
- summary.push_str(&format!(
1589
- "\n\n(มิ้นท์หาข้อมูลนี้มาจาก {} Search นะคะ 💖)",
1590
- prov
1591
- ));
1592
- } else {
1593
- summary.push_str(&format!(
1594
- "\n\n(Information retrieved via {} Search 💖)",
1595
- prov
1596
- ));
1597
- }
1598
- }
1599
- }
2403
+ trajectory.push(format_trajectory_step(
2404
+ step,
2405
+ &decision.thought,
2406
+ &decision.action,
2407
+ err_msg,
2408
+ ));
2409
+ rebuild_observation(task, &root, &trajectory, &mut observation);
2410
+ reject_native_finish(
2411
+ tool_mode,
2412
+ &mut native_messages,
2413
+ &response.text,
2414
+ err_msg,
2415
+ );
2416
+ continue 'steps;
1600
2417
  }
1601
-
1602
2418
  // Auto-append generated media (image/video) and model feedback to summary if LLM omitted it
1603
2419
  let mut media_blocks = Vec::new();
1604
2420
  for step in trajectory.iter() {
@@ -1621,17 +2437,29 @@ where
1621
2437
  let verification =
1622
2438
  meaningful_verification(&decision.input.verification).to_owned();
1623
2439
 
1624
- on_chunk(summary.clone());
1625
-
1626
- let memory = MemoryStore::open_default()?;
1627
- memory.add_interaction_for_chat_with_fallback(
1628
- chat_id,
1629
- task,
2440
+ let remainder = unstreamed_summary_remainder(
1630
2441
  &summary,
1631
- &final_provider,
1632
- &final_model,
1633
- final_fallback.as_deref(),
1634
- )?;
2442
+ &streamed_finish_summary,
2443
+ &streamed_direct_text,
2444
+ );
2445
+ if !remainder.is_empty() {
2446
+ on_chunk(remainder.to_owned());
2447
+ }
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
+ }
1635
2463
  memory.save_workspace_session(
1636
2464
  &root.to_string_lossy(),
1637
2465
  &summary,
@@ -1648,16 +2476,8 @@ where
1648
2476
  config.clone(),
1649
2477
  task.to_string(),
1650
2478
  summary.clone(),
2479
+ turn.id,
1651
2480
  );
1652
- if config.auto_skill_writing && looks_skill_worthy(step, &action_counts) {
1653
- spawn_auto_skill_write(
1654
- config.clone(),
1655
- task.to_string(),
1656
- summary.clone(),
1657
- root.clone(),
1658
- skills.clone(),
1659
- );
1660
- }
1661
2481
  let run_summary = RunTelemetrySummary {
1662
2482
  run_id: format!("run-{}", chrono::Local::now().format("%Y%m%d%H%M%S")),
1663
2483
  task: task.to_string(),
@@ -1674,22 +2494,52 @@ where
1674
2494
  tool_timeline: executed_tools.clone(),
1675
2495
  retries_count: executed_tools.iter().filter(|t| t.retried).count(),
1676
2496
  };
2497
+ let _ = execute_mod_requests(&root, chat_id, config, plan_mode, &mut approve, &mut progress, None).await;
1677
2498
  progress(AgentProgress::RunCompleted {
1678
2499
  summary: run_summary,
1679
2500
  });
1680
2501
 
2502
+ turn.complete_with_summary(
2503
+ &summary,
2504
+ &final_provider,
2505
+ &final_model,
2506
+ final_fallback.as_deref(),
2507
+ )?;
2508
+
1681
2509
  return Ok(AgentResult {
1682
2510
  provider: final_provider,
1683
2511
  model: final_model,
1684
2512
  summary,
1685
2513
  verification,
1686
2514
  fallback: final_fallback,
2515
+ fallback_reason: final_fallback_reason,
1687
2516
  total_tokens: turn_total_tokens,
1688
2517
  input_tokens: last_input_tokens,
1689
2518
  generated_tokens: turn_generated_tokens,
1690
2519
  });
1691
2520
  }
1692
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
+
1693
2543
  let action_key = action_fingerprint(&decision);
1694
2544
  let action_count = {
1695
2545
  let count = action_counts.entry(action_key).or_insert(0);
@@ -1701,6 +2551,10 @@ where
1701
2551
  // stays false for plan-mode/hook blocks and the duplicate-shell skip, since
1702
2552
  // those don't actually run anything and shouldn't count toward verification.
1703
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();
1704
2558
  let target_file_existed = match decision.action.as_str() {
1705
2559
  "write_file" => {
1706
2560
  !decision.input.path.is_empty()
@@ -1799,8 +2653,8 @@ where
1799
2653
  )
1800
2654
  } else {
1801
2655
  let input_val =
1802
- serde_json::to_value(&decision.input).unwrap_or(Value::Null);
1803
- progress(AgentProgress::ToolStart {
2656
+ tool_progress_input(&root, &decision);
2657
+ progress(AgentProgress::ToolStart { call_id: Some(activity_call_id.clone()),
1804
2658
  action: decision.action.clone(),
1805
2659
  input: input_val.clone(),
1806
2660
  subagent: None,
@@ -1838,20 +2692,31 @@ where
1838
2692
  );
1839
2693
  }
1840
2694
 
1841
- let (tool_result, success) = match execute_tool(
2695
+ let (tool_result, success) = match execute_tool_outcome_reviewed(
1842
2696
  &root,
1843
2697
  config,
1844
2698
  &decision,
1845
2699
  chat_id,
1846
2700
  &mut approve,
1847
2701
  &mut progress,
2702
+ &activity_call_id,
2703
+ Some(&mcp_review),
1848
2704
  )
1849
2705
  .await
1850
2706
  {
1851
- 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
+ },
1852
2714
  Err(error) => (format!("Error: {}", error), false),
1853
2715
  };
1854
2716
  action_succeeded = success;
2717
+ if success {
2718
+ invalid_tool_calls.succeeded(&decision.action);
2719
+ }
1855
2720
  let hook_messages = crate::hooks::run_post_tool_hooks(
1856
2721
  &hooks,
1857
2722
  &decision.action,
@@ -1877,9 +2742,17 @@ where
1877
2742
  }
1878
2743
  };
1879
2744
 
1880
- 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,
1881
2754
  action: decision.action.clone(),
1882
- input: serde_json::to_value(&decision.input).unwrap_or(Value::Null),
2755
+ input: tool_progress_input(&root, &decision),
1883
2756
  result: result.clone(),
1884
2757
  subagent: None,
1885
2758
  });
@@ -1905,6 +2778,11 @@ where
1905
2778
  retried,
1906
2779
  });
1907
2780
 
2781
+ if outcome_status == Some(crate::integrations::mcp_result::ToolStatus::Cancelled) {
2782
+ turn.interrupt();
2783
+ return Err(OrchestrationError::Agent(result));
2784
+ }
2785
+
1908
2786
  if action_succeeded {
1909
2787
  match decision.action.as_str() {
1910
2788
  "apply_patch" | "write_file" | "note_write" => {
@@ -1948,7 +2826,7 @@ where
1948
2826
  | "video_waveform"
1949
2827
  | "view_image"
1950
2828
  ) {
1951
- step_images.insert(call_id.clone(), result.clone());
2829
+ step_images.insert(call_id.clone(), vec![result.clone()]);
1952
2830
  match decision.action.as_str() {
1953
2831
  "video_filmstrip" => "[Filmstrip generated — see attached image: \
1954
2832
  sampled frames across the video timeline]"
@@ -1960,7 +2838,7 @@ where
1960
2838
  _ => "[Screenshot captured — see attached image]".to_string(),
1961
2839
  }
1962
2840
  } else {
1963
- truncate(&result)
2841
+ model_tool_result(&decision.action, &result)
1964
2842
  };
1965
2843
  if matches!(
1966
2844
  decision.action.as_str(),
@@ -1986,7 +2864,7 @@ where
1986
2864
  concise verification read.]",
1987
2865
  );
1988
2866
  }
1989
- if action_count >= 3 {
2867
+ if action_count >= 3 && !decision.action.starts_with("browser_") {
1990
2868
  final_result.push_str(
1991
2869
  "\n\n[System Tip: You repeated the same tool action three or more times. \
1992
2870
  Stop repeating it. If you already have enough information or the requested edit is done, \
@@ -2001,54 +2879,42 @@ where
2001
2879
  &final_result,
2002
2880
  ));
2003
2881
 
2004
- step_tool_results.push((
2005
- call_id,
2006
- decision.action.clone(),
2007
- serde_json::to_value(&decision.input).unwrap_or(Value::Null),
2008
- final_result,
2009
- ));
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
+ });
2010
2886
  } // end `for (call_id, decision) in decisions`
2011
2887
  } // end `else` (sequential path)
2012
2888
 
2013
- if tool_mode == ToolCallingMode::Native {
2014
- let mut assistant_content: Vec<ContentBlock> = Vec::new();
2015
- let response_text = response.text.trim();
2016
- if !response_text.is_empty() {
2017
- assistant_content.push(ContentBlock::Text {
2018
- text: response_text.to_string(),
2019
- });
2020
- }
2021
- let mut tool_result_content: Vec<ContentBlock> = Vec::new();
2022
- for (call_id, action, input_value, final_result) in &step_tool_results {
2023
- assistant_content.push(ContentBlock::ToolUse {
2024
- id: call_id.clone(),
2025
- name: action.clone(),
2026
- input: input_value.clone(),
2027
- thought_signature: step_thought_signatures.get(call_id).cloned(),
2028
- });
2029
- tool_result_content.push(ContentBlock::ToolResult {
2030
- tool_use_id: call_id.clone(),
2031
- content: final_result.clone(),
2032
- is_error: false,
2033
- });
2034
- if let Some(data_uri) = step_images.get(call_id) {
2035
- tool_result_content.push(ContentBlock::Image {
2036
- data_uri: data_uri.clone(),
2037
- });
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}"));
2038
2899
  }
2039
2900
  }
2040
- if !assistant_content.is_empty() {
2041
- native_messages.push(ChatMessage {
2042
- role: ChatRole::Assistant,
2043
- content: assistant_content,
2044
- });
2045
- }
2046
- if !tool_result_content.is_empty() {
2047
- native_messages.push(ChatMessage {
2048
- role: ChatRole::Tool,
2049
- content: tool_result_content,
2050
- });
2901
+ Err(error) => {
2902
+ progress(AgentProgress::Thought { thought: error.to_string() });
2903
+ return Err(error);
2051
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
+ );
2052
2918
 
2053
2919
  if let Some(total_tokens) = response.total_tokens {
2054
2920
  let window = active_config.context_window_tokens();
@@ -2056,38 +2922,20 @@ where
2056
2922
  ((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 255.0) as u8,
2057
2923
  );
2058
2924
  if (total_tokens as f64) >= (window as f64) * COMPACTION_TRIGGER_RATIO {
2059
- match compact_native_messages(&active_config, &native_messages).await {
2060
- Ok(Some((compacted, fallback_provider))) => {
2061
- native_messages = compacted;
2062
- let fallback_note = fallback_provider
2063
- .as_deref()
2064
- .map(|p| {
2065
- format!(
2066
- " (summary generated via fallback provider \
2067
- \"{p}\" — the primary provider failed for this \
2068
- call)"
2069
- )
2070
- })
2071
- .unwrap_or_default();
2072
- progress(AgentProgress::Thought {
2073
- thought: format!(
2074
- "[Context] Compacted earlier steps to stay under the \
2075
- context window ({total_tokens}/{window} tokens before \
2076
- compaction){fallback_note}."
2077
- ),
2078
- });
2079
- }
2080
- // Nothing worth compacting yet (too little history) — routine, no warning.
2081
- Ok(None) => {}
2082
- Err(error) => {
2083
- progress(AgentProgress::Thought {
2084
- thought: format!(
2085
- "[Context] Context is approaching the model's window \
2086
- ({total_tokens}/{window} tokens) but compaction failed: \
2087
- {error}. Continuing without compacting this step."
2088
- ),
2089
- });
2090
- }
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;
2091
2939
  }
2092
2940
  }
2093
2941
  }
@@ -2116,11 +2964,29 @@ where
2116
2964
  summary: run_summary,
2117
2965
  });
2118
2966
 
2967
+ turn.fail();
2119
2968
  Err(OrchestrationError::Agent(format!(
2120
- "code agent reached the limit of {} steps",
2121
- MAX_STEPS
2969
+ "code agent reached the limit of {} steps; task incomplete. Last browser state: {}",
2970
+ MAX_STEPS, crate::browser::session_last_state().await
2122
2971
  )))
2123
- })
2972
+ }).await;
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
+ ))
2124
2990
  }
2125
2991
 
2126
2992
  /// Whether `action` may run while plan mode is active. `run_shell` gets a
@@ -2260,22 +3126,41 @@ async fn dispatch_one_subagent(
2260
3126
  let subagent_name = definition.name.clone();
2261
3127
  let mut nested_progress = |event: AgentProgress| {
2262
3128
  let tagged = match event {
2263
- AgentProgress::ToolStart { action, input, .. } => AgentProgress::ToolStart {
3129
+ AgentProgress::ToolStart {
3130
+ action,
3131
+ input,
3132
+ call_id,
3133
+ ..
3134
+ } => AgentProgress::ToolStart {
3135
+ call_id,
2264
3136
  action,
2265
3137
  input,
2266
3138
  subagent: Some(subagent_name.clone()),
2267
3139
  },
2268
3140
  AgentProgress::ToolEnd {
3141
+ elapsed_ms,
2269
3142
  action,
2270
3143
  input,
2271
3144
  result,
3145
+ call_id,
3146
+ status,
2272
3147
  ..
2273
3148
  } => AgentProgress::ToolEnd {
3149
+ elapsed_ms,
3150
+ call_id,
3151
+ status,
2274
3152
  action,
2275
3153
  input,
2276
3154
  result,
2277
3155
  subagent: Some(subagent_name.clone()),
2278
3156
  },
3157
+ AgentProgress::ContextCompaction {
3158
+ status, message, ..
3159
+ } => AgentProgress::ContextCompaction {
3160
+ status,
3161
+ message,
3162
+ subagent: Some(subagent_name.clone()),
3163
+ },
2279
3164
  other => other,
2280
3165
  };
2281
3166
  progress(tagged);
@@ -2318,17 +3203,9 @@ async fn dispatch_one_subagent(
2318
3203
  outcome
2319
3204
  }
2320
3205
 
2321
- /// Runs a step's `dispatch_subagent` decisions concurrently (see
2322
- /// `decisions_are_parallel_subagent_batch` for when this is used instead of
2323
- /// the normal one-at-a-time loop), capped at `PARALLEL_SUBAGENT_LIMIT` in
2324
- /// flight at once. `progress`/`action_counts`/`trajectory` are only touched
2325
- /// after every future has completed — back in single-threaded code, in the
2326
- /// decisions' original order — so this never needs to share those across
2327
- /// concurrent tasks. `approve` is the one resource genuinely needed *during*
2328
- /// concurrent execution (a subagent's own risky actions still need real user
2329
- /// approval), so it's wrapped in a `std::sync::Mutex` for the batch: each
2330
- /// concurrent subagent gets a small adapter closure over a shared `&Mutex`,
2331
- /// 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.
2332
3209
  #[allow(clippy::too_many_arguments)]
2333
3210
  async fn run_parallel_subagent_batch(
2334
3211
  decisions: &[(String, AgentDecision)],
@@ -2340,7 +3217,7 @@ async fn run_parallel_subagent_batch(
2340
3217
  progress: &mut (dyn FnMut(AgentProgress) + Send),
2341
3218
  action_counts: &mut BTreeMap<String, usize>,
2342
3219
  trajectory: &mut Vec<String>,
2343
- ) -> Vec<(String, String, Value, String)> {
3220
+ ) -> Vec<ToolResultEntry> {
2344
3221
  let approve_mutex = std::sync::Mutex::new(approve);
2345
3222
  // Concurrently-running subagents share one real `progress` sink the same
2346
3223
  // way they share one real `approve` gate above — each gets a small
@@ -2355,13 +3232,23 @@ async fn run_parallel_subagent_batch(
2355
3232
  let call_id = call_id.clone();
2356
3233
  let thought = decision.thought.clone();
2357
3234
  let action = decision.action.clone();
2358
- let input_val = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3235
+ let input_val = tool_progress_input(&root, &decision);
2359
3236
  let action_key = action_fingerprint(decision);
2360
3237
  let name = decision.input.name.clone();
2361
3238
  let task_text = decision.input.instruction.clone();
2362
3239
  let approve_mutex = &approve_mutex;
2363
3240
  let progress_mutex = &progress_mutex;
2364
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
+ });
2365
3252
  let result: Result<String, OrchestrationError> = if name.trim().is_empty() {
2366
3253
  Err(OrchestrationError::Agent("name is required".into()))
2367
3254
  } else if task_text.trim().is_empty() {
@@ -2372,10 +3259,6 @@ async fn run_parallel_subagent_batch(
2372
3259
  let mut guard = approve_mutex.lock().unwrap();
2373
3260
  (*guard)(approval)
2374
3261
  };
2375
- let mut progress_adapter = |event: AgentProgress| {
2376
- let mut guard = progress_mutex.lock().unwrap();
2377
- (*guard)(event);
2378
- };
2379
3262
  dispatch_one_subagent(
2380
3263
  root,
2381
3264
  config,
@@ -2387,8 +3270,27 @@ async fn run_parallel_subagent_batch(
2387
3270
  )
2388
3271
  .await
2389
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
+ });
2390
3286
  (
2391
- 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,
2392
3294
  )
2393
3295
  });
2394
3296
  }
@@ -2398,36 +3300,15 @@ async fn run_parallel_subagent_batch(
2398
3300
  .collect::<Vec<_>>()
2399
3301
  .await;
2400
3302
  results.sort_by_key(|(index, ..)| *index);
2401
- // All `dispatches` futures (the only borrowers of `progress_mutex`) have
2402
- // finished by now, so this is the sole remaining handle — safe to unwrap
2403
- // back into a plain `&mut dyn FnMut` for the rest of this function.
2404
- let progress = progress_mutex.into_inner().unwrap();
2405
-
2406
3303
  let mut step_tool_results = Vec::with_capacity(results.len());
2407
- for (_, call_id, thought, action, input_val, action_key, result) in results {
2408
- progress(AgentProgress::ToolStart {
2409
- action: action.clone(),
2410
- input: input_val.clone(),
2411
- subagent: None,
2412
- });
2413
- let tool_result = match result {
2414
- Ok(text) => text,
2415
- Err(error) => format!("Error: {}", error),
2416
- };
2417
- progress(AgentProgress::ToolEnd {
2418
- action: action.clone(),
2419
- input: input_val.clone(),
2420
- result: tool_result.clone(),
2421
- subagent: None,
2422
- });
2423
-
3304
+ for (_, call_id, thought, action, input_val, action_key, tool_result) in results {
2424
3305
  let action_count = {
2425
3306
  let count = action_counts.entry(action_key).or_insert(0);
2426
3307
  *count += 1;
2427
3308
  *count
2428
3309
  };
2429
- let mut final_result = truncate(&tool_result);
2430
- if action_count >= 3 {
3310
+ let mut final_result = model_tool_result(&action, &tool_result);
3311
+ if action_count >= 3 && !action.starts_with("browser_") {
2431
3312
  final_result.push_str(
2432
3313
  "\n\n[System Tip: You repeated the same tool action three or more times. \
2433
3314
  Stop repeating it. If you already have enough information or the requested edit is done, \
@@ -2441,7 +3322,7 @@ async fn run_parallel_subagent_batch(
2441
3322
  &action,
2442
3323
  &final_result,
2443
3324
  ));
2444
- step_tool_results.push((call_id, action, input_val, final_result));
3325
+ step_tool_results.push((call_id, action, input_val, final_result).into());
2445
3326
  }
2446
3327
  step_tool_results
2447
3328
  }
@@ -2457,11 +3338,11 @@ async fn run_parallel_read_only_batch(
2457
3338
  progress: &mut (dyn FnMut(AgentProgress) + Send),
2458
3339
  action_counts: &mut BTreeMap<String, usize>,
2459
3340
  trajectory: &mut Vec<String>,
2460
- step_images: &mut std::collections::HashMap<String, String>,
3341
+ step_images: &mut std::collections::HashMap<String, Vec<String>>,
2461
3342
  hooks: &[crate::hooks::HookEntry],
2462
3343
  pinned_mcp_server: Option<&str>,
2463
3344
  fast_mode: bool,
2464
- ) -> Vec<(String, String, Value, String)> {
3345
+ ) -> Vec<ToolResultEntry> {
2465
3346
  let approve_mutex = std::sync::Mutex::new(approve);
2466
3347
  let progress_mutex = std::sync::Mutex::new(progress);
2467
3348
 
@@ -2470,7 +3351,7 @@ async fn run_parallel_read_only_batch(
2470
3351
  let call_id = call_id.clone();
2471
3352
  let thought = decision.thought.clone();
2472
3353
  let action = decision.action.clone();
2473
- let input_val = serde_json::to_value(&decision.input).unwrap_or(Value::Null);
3354
+ let input_val = tool_progress_input(&root, &decision);
2474
3355
  let action_key = action_fingerprint(decision);
2475
3356
  let approve_mutex = &approve_mutex;
2476
3357
  let progress_mutex = &progress_mutex;
@@ -2478,15 +3359,24 @@ async fn run_parallel_read_only_batch(
2478
3359
 
2479
3360
  tasks.push(async move {
2480
3361
  if !fast_mode && !thought.trim().is_empty() {
3362
+ let t = thought.trim();
2481
3363
  let mut guard = progress_mutex.lock().unwrap();
2482
- (*guard)(AgentProgress::Thought {
2483
- thought: thought.trim().to_owned(),
2484
- });
3364
+ if is_internal_cot(t) {
3365
+ (*guard)(AgentProgress::ExtendedThinking {
3366
+ id: None,
3367
+ thought: t.to_owned(),
3368
+ elapsed_ms: None,
3369
+ });
3370
+ } else {
3371
+ (*guard)(AgentProgress::Thought {
3372
+ thought: t.to_owned(),
3373
+ });
3374
+ }
2485
3375
  }
2486
3376
 
2487
3377
  {
2488
3378
  let mut guard = progress_mutex.lock().unwrap();
2489
- (*guard)(AgentProgress::ToolStart {
3379
+ (*guard)(AgentProgress::ToolStart { call_id: Some(call_id.clone()),
2490
3380
  action: action.clone(),
2491
3381
  input: input_val.clone(),
2492
3382
  subagent: None,
@@ -2561,7 +3451,7 @@ async fn run_parallel_read_only_batch(
2561
3451
 
2562
3452
  {
2563
3453
  let mut guard = progress_mutex.lock().unwrap();
2564
- (*guard)(AgentProgress::ToolEnd {
3454
+ (*guard)(AgentProgress::ToolEnd { elapsed_ms: None, call_id: Some(call_id.clone()), status: None,
2565
3455
  action: action.clone(),
2566
3456
  input: input_val.clone(),
2567
3457
  result: result.clone(),
@@ -2595,7 +3485,7 @@ async fn run_parallel_read_only_batch(
2595
3485
  action.as_str(),
2596
3486
  "browser_screenshot" | "video_filmstrip" | "video_waveform" | "view_image"
2597
3487
  ) {
2598
- step_images.insert(call_id.clone(), result.clone());
3488
+ step_images.insert(call_id.clone(), vec![result.clone()]);
2599
3489
  match action.as_str() {
2600
3490
  "video_filmstrip" => {
2601
3491
  "[Filmstrip generated — see attached image: sampled frames across the video timeline]".to_string()
@@ -2607,10 +3497,10 @@ async fn run_parallel_read_only_batch(
2607
3497
  _ => "[Screenshot captured — see attached image]".to_string(),
2608
3498
  }
2609
3499
  } else {
2610
- truncate(&result)
3500
+ model_tool_result(&action, &result)
2611
3501
  };
2612
3502
 
2613
- if action_count >= 3 {
3503
+ if action_count >= 3 && !action.starts_with("browser_") {
2614
3504
  final_result.push_str(
2615
3505
  "\n\n[System Tip: You repeated the same tool action three or more times. \
2616
3506
  Stop repeating it. If you already have enough information or the requested edit is done, \
@@ -2625,7 +3515,7 @@ async fn run_parallel_read_only_batch(
2625
3515
  &final_result,
2626
3516
  ));
2627
3517
 
2628
- step_tool_results.push((call_id, action, input_val, final_result));
3518
+ step_tool_results.push((call_id, action, input_val, final_result).into());
2629
3519
  }
2630
3520
 
2631
3521
  step_tool_results
@@ -2643,6 +3533,203 @@ async fn run_parallel_read_only_batch(
2643
3533
  // doesn't hit that.
2644
3534
 
2645
3535
  async fn execute_tool(
3536
+ root: &Path,
3537
+ config: &MintConfig,
3538
+ decision: &AgentDecision,
3539
+ chat_id: &str,
3540
+ approve: &mut (dyn FnMut(&AgentApproval) -> Result<ApprovalOutcome, String> + Send),
3541
+ progress: &mut (dyn FnMut(AgentProgress) + Send),
3542
+ ) -> Result<String, OrchestrationError> {
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(),
3652
+ input,
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(
2646
3733
  root: &Path,
2647
3734
  config: &MintConfig,
2648
3735
  decision: &AgentDecision,
@@ -2652,6 +3739,9 @@ async fn execute_tool(
2652
3739
  ) -> Result<String, OrchestrationError> {
2653
3740
  let input = &decision.input;
2654
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
+ }
2655
3745
  "list_files" | "read_file" | "note_write" | "apply_patch" | "write_file" => {
2656
3746
  tools::files::execute(
2657
3747
  decision.action.as_str(),
@@ -2709,7 +3799,13 @@ async fn execute_tool(
2709
3799
  | "browser_mouse_move"
2710
3800
  | "browser_mouse_click"
2711
3801
  | "browser_key_press"
2712
- | "browser_screenshot" => {
3802
+ | "browser_screenshot"
3803
+ | "browser_tabs"
3804
+ | "browser_observe"
3805
+ | "browser_fill"
3806
+ | "browser_select"
3807
+ | "browser_scroll"
3808
+ | "browser_wait" => {
2713
3809
  tools::browser::execute(
2714
3810
  decision.action.as_str(),
2715
3811
  input,
@@ -2849,30 +3945,793 @@ where
2849
3945
  let decision = AgentDecision {
2850
3946
  thought: String::new(),
2851
3947
  action: action.to_string(),
2852
- input: serde_json::from_value(input).unwrap_or_default(),
3948
+ input: parse_tool_input(action, input)?,
2853
3949
  };
2854
3950
  // No progress channel from the realtime voice session — tool-call
2855
3951
  // activity isn't surfaced there the way it is in the CLI/GUI.
2856
3952
  execute_tool(root, config, &decision, chat_id, approve_cb, &mut |_| {}).await
2857
3953
  }
2858
3954
 
2859
- #[cfg(test)]
2860
- mod tests {
2861
- use super::*;
2862
- use crate::config::AgentConfig;
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
+ }
2863
3960
 
2864
- #[test]
2865
- fn truncate_for_context_leaves_short_text_untouched() {
2866
- assert_eq!(truncate_for_context("hello", 400), "hello");
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
+ });
2867
3973
  }
2868
-
2869
- #[test]
2870
- fn truncate_for_context_truncates_long_text_with_a_marker() {
2871
- let long = "a".repeat(500);
2872
- let result = truncate_for_context(&long, 400);
2873
- assert_eq!(
2874
- result.chars().count(),
2875
- 400 + "... [truncated]".chars().count()
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
+
4122
+ #[cfg(test)]
4123
+ mod tests {
4124
+ use super::*;
4125
+ use crate::config::AgentConfig;
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
+
4487
+ #[tokio::test]
4488
+ async fn turn_start_listener_reports_only_the_matching_chat() {
4489
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
4490
+ "mint-turn-start-listener-{}.sqlite",
4491
+ uuid::Uuid::new_v4()
4492
+ )));
4493
+ let reported = Arc::new(std::sync::Mutex::new(Vec::new()));
4494
+ let callback_reported = reported.clone();
4495
+ with_turn_start_listener(
4496
+ "cli::one".to_string(),
4497
+ move |id| callback_reported.lock().unwrap().push(id),
4498
+ async {
4499
+ let first = TurnLease::start(&memory, "cli::one", "hello")
4500
+ .await
4501
+ .unwrap();
4502
+ let other = TurnLease::start(&memory, "cli::two", "other")
4503
+ .await
4504
+ .unwrap();
4505
+ assert_eq!(*reported.lock().unwrap(), vec![first.id]);
4506
+ drop(first);
4507
+ drop(other);
4508
+ },
4509
+ )
4510
+ .await;
4511
+ }
4512
+
4513
+ #[test]
4514
+ fn test_compact_agent_progress_cleans_deltas_and_preserves_tools() {
4515
+ let events = vec![
4516
+ AgentProgress::ToolStart {
4517
+ call_id: None,
4518
+ action: "read_file".into(),
4519
+ input: serde_json::json!({ "path": "src/main.rs" }),
4520
+ subagent: None,
4521
+ },
4522
+ AgentProgress::ThinkingDelta {
4523
+ id: "cot-1".into(),
4524
+ delta: "Reading ".into(),
4525
+ elapsed_ms: 100,
4526
+ },
4527
+ AgentProgress::ThinkingDelta {
4528
+ id: "cot-1".into(),
4529
+ delta: "the file...".into(),
4530
+ elapsed_ms: 200,
4531
+ },
4532
+ AgentProgress::ExtendedThinking {
4533
+ id: Some("cot-1".into()),
4534
+ thought: "Reading the file...".into(),
4535
+ elapsed_ms: Some(200),
4536
+ },
4537
+ AgentProgress::ToolEnd {
4538
+ elapsed_ms: None,
4539
+ call_id: None,
4540
+ status: None,
4541
+ action: "read_file".into(),
4542
+ input: serde_json::json!({ "path": "src/main.rs" }),
4543
+ result: "fn main() {}".into(),
4544
+ subagent: None,
4545
+ },
4546
+ AgentProgress::ThinkingDelta {
4547
+ id: "cot-interrupted".into(),
4548
+ delta: "Let's check ".into(),
4549
+ elapsed_ms: 300,
4550
+ },
4551
+ AgentProgress::ThinkingDelta {
4552
+ id: "cot-interrupted".into(),
4553
+ delta: "more lines".into(),
4554
+ elapsed_ms: 350,
4555
+ },
4556
+ ];
4557
+
4558
+ let compacted = compact_agent_progress(&events);
4559
+ assert!(
4560
+ !compacted
4561
+ .iter()
4562
+ .any(|e| matches!(e, AgentProgress::ThinkingDelta { id, .. } if id == "cot-1"))
4563
+ );
4564
+ assert!(compacted.iter().any(
4565
+ |e| matches!(e, AgentProgress::ToolStart { action, .. } if action == "read_file")
4566
+ ));
4567
+ assert!(
4568
+ compacted.iter().any(
4569
+ |e| matches!(e, AgentProgress::ToolEnd { action, .. } if action == "read_file")
4570
+ )
4571
+ );
4572
+ assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-1" && thought == "Reading the file...")));
4573
+ assert!(compacted.iter().any(|e| matches!(e, AgentProgress::ExtendedThinking { id: Some(id), thought, .. } if id == "cot-interrupted" && thought == "Let's check more lines")));
4574
+ }
4575
+
4576
+ #[tokio::test]
4577
+ async fn test_turn_lease_persists_agent_activity_on_complete() {
4578
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
4579
+ "mint-turn-lease-complete-{}.sqlite",
4580
+ uuid::Uuid::new_v4()
4581
+ )));
4582
+ let mut turn = TurnLease::start(&memory, "cli::test_complete", "hello")
4583
+ .await
4584
+ .unwrap();
4585
+ let turn_id = turn.id;
4586
+ turn.activity
4587
+ .lock()
4588
+ .unwrap()
4589
+ .push(AgentProgress::ToolStart {
4590
+ call_id: None,
4591
+ action: "run_shell".into(),
4592
+ input: serde_json::json!({ "command": "ls -la" }),
4593
+ subagent: None,
4594
+ });
4595
+ turn.activity.lock().unwrap().push(AgentProgress::ToolEnd {
4596
+ elapsed_ms: None,
4597
+ call_id: None,
4598
+ status: None,
4599
+ action: "run_shell".into(),
4600
+ input: serde_json::json!({ "command": "ls -la" }),
4601
+ result: "file.txt".into(),
4602
+ subagent: None,
4603
+ });
4604
+
4605
+ turn.complete_with_summary("Done listing files", "test_provider", "test_model", None)
4606
+ .unwrap();
4607
+
4608
+ let rows = memory.interactions_for_chat("cli::test_complete").unwrap();
4609
+ assert_eq!(rows.len(), 1);
4610
+ let row = &rows[0];
4611
+ assert_eq!(row.id, turn_id);
4612
+ assert_eq!(row.status, "completed");
4613
+ assert_eq!(row.ai_text, "Done listing files");
4614
+ assert!(row.agent_activity.is_some());
4615
+ let activity_val = row.agent_activity.as_ref().unwrap();
4616
+ let activity_arr = activity_val.as_array().unwrap();
4617
+ assert_eq!(activity_arr.len(), 2);
4618
+ assert_eq!(activity_arr[0]["type"], "ToolStart");
4619
+ assert_eq!(activity_arr[1]["type"], "ToolEnd");
4620
+ }
4621
+
4622
+ #[tokio::test]
4623
+ async fn test_turn_lease_persists_partial_activity_on_interrupt() {
4624
+ let memory = MemoryStore::open(std::env::temp_dir().join(format!(
4625
+ "mint-turn-lease-interrupt-{}.sqlite",
4626
+ uuid::Uuid::new_v4()
4627
+ )));
4628
+ let turn = TurnLease::start(&memory, "cli::test_interrupt", "run something")
4629
+ .await
4630
+ .unwrap();
4631
+ let turn_id = turn.id;
4632
+ turn.activity
4633
+ .lock()
4634
+ .unwrap()
4635
+ .push(AgentProgress::ToolStart {
4636
+ call_id: None,
4637
+ action: "read_file".into(),
4638
+ input: serde_json::json!({ "path": "test.txt" }),
4639
+ subagent: None,
4640
+ });
4641
+ drop(turn);
4642
+
4643
+ let rows = memory.interactions_for_chat("cli::test_interrupt").unwrap();
4644
+ assert_eq!(rows.len(), 1);
4645
+ let row = &rows[0];
4646
+ assert_eq!(row.id, turn_id);
4647
+ assert_eq!(row.status, "interrupted");
4648
+ assert!(row.agent_activity.is_some());
4649
+ let activity_val = row.agent_activity.as_ref().unwrap();
4650
+ let activity_arr = activity_val.as_array().unwrap();
4651
+ assert_eq!(activity_arr.len(), 1);
4652
+ assert_eq!(activity_arr[0]["type"], "ToolStart");
4653
+ }
4654
+
4655
+ #[test]
4656
+ fn extracts_a_summary_incrementally_without_matching_text_inside_other_strings() {
4657
+ let mut stream = FinishSummaryStream::default();
4658
+ let mut output = String::new();
4659
+ let mut emit = |delta: String| output.push_str(&delta);
4660
+
4661
+ stream.push_tool_call(0, Some("read_file"), None, None, &mut emit);
4662
+ stream.push_tool_call(
4663
+ 0,
4664
+ None,
4665
+ Some(r#"{"thought":"the \"summary\" key is data","path":"x"}"#),
4666
+ None,
4667
+ &mut emit,
4668
+ );
4669
+ assert!(stream.emitted.is_empty());
4670
+
4671
+ stream.push_tool_call(1, Some("fin"), None, None, &mut emit);
4672
+ stream.push_tool_call(1, Some("finish"), None, None, &mut emit);
4673
+ stream.push_tool_call(1, None, Some(r#"{"summary":"สวัส"#), None, &mut emit);
4674
+ stream.push_tool_call(
4675
+ 1,
4676
+ None,
4677
+ Some(r#"ดี\nโลก","verification":""}"#),
4678
+ None,
4679
+ &mut emit,
4680
+ );
4681
+
4682
+ assert_eq!(output, "สวัสดี\nโลก");
4683
+ }
4684
+
4685
+ #[test]
4686
+ fn extracts_json_prompt_summary_as_chunks_arrive() {
4687
+ let mut stream = FinishSummaryStream::default();
4688
+ let mut output = String::new();
4689
+ let mut emit = |delta: String| output.push_str(&delta);
4690
+ stream.push_json_prompt(
4691
+ r#"{"thought":"done","action":"finish","input":{"summary":"Hello"#,
4692
+ &mut emit,
4693
+ );
4694
+ assert_eq!(stream.emitted, "Hello");
4695
+ stream.push_json_prompt(r#" world"}}"#, &mut emit);
4696
+ assert_eq!(output, "Hello world");
4697
+ }
4698
+
4699
+ #[test]
4700
+ fn direct_native_text_is_not_sent_again_as_a_completed_summary() {
4701
+ assert_eq!(
4702
+ unstreamed_summary_remainder("A streamed answer.", "", "A streamed answer."),
4703
+ ""
4704
+ );
4705
+ }
4706
+
4707
+ #[test]
4708
+ fn direct_native_text_appends_only_the_unstreamed_suffix() {
4709
+ assert_eq!(
4710
+ unstreamed_summary_remainder("A streamed answer, continued.", "", "A streamed answer"),
4711
+ ", continued."
4712
+ );
4713
+ }
4714
+
4715
+ #[test]
4716
+ fn streamed_finish_summary_takes_precedence_over_direct_text() {
4717
+ assert_eq!(
4718
+ unstreamed_summary_remainder("Final answer.", "Final", "unrelated preamble"),
4719
+ " answer."
4720
+ );
4721
+ }
4722
+
4723
+ #[test]
4724
+ fn truncate_for_context_leaves_short_text_untouched() {
4725
+ assert_eq!(truncate_for_context("hello", 400), "hello");
4726
+ }
4727
+
4728
+ #[test]
4729
+ fn truncate_for_context_truncates_long_text_with_a_marker() {
4730
+ let long = "a".repeat(500);
4731
+ let result = truncate_for_context(&long, 400);
4732
+ assert_eq!(
4733
+ result.chars().count(),
4734
+ 400 + "... [truncated]".chars().count()
2876
4735
  );
2877
4736
  assert!(result.starts_with(&"a".repeat(400)));
2878
4737
  assert!(result.ends_with("... [truncated]"));
@@ -3068,6 +4927,7 @@ mod tests {
3068
4927
  messages: None,
3069
4928
  tools: None,
3070
4929
  temperature: None,
4930
+ ..Default::default()
3071
4931
  };
3072
4932
  let config = MintConfig::default();
3073
4933
  assert!(
@@ -3441,3 +5301,791 @@ mod tests {
3441
5301
  assert!(buffer.contains("Step 3:\n- Thought: All done"));
3442
5302
  }
3443
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
+ }