@miragon/mcp-toolkit-ui 2.7.0 → 2.8.1

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 (388) hide show
  1. package/README.md +7 -4
  2. package/dist/app/adapt-data-widget.d.ts +1 -22
  3. package/dist/app/adapt-data-widget.d.ts.map +1 -1
  4. package/dist/app/adapt-data-widget.js +1 -35
  5. package/dist/app/adapt-data-widget.js.map +1 -1
  6. package/dist/app/builder/BuilderBanners.js +1 -1
  7. package/dist/app/builder/BuilderBanners.js.map +1 -1
  8. package/dist/app/builder/CanvasRow.d.ts +4 -2
  9. package/dist/app/builder/CanvasRow.d.ts.map +1 -1
  10. package/dist/app/builder/CanvasRow.js +21 -7
  11. package/dist/app/builder/CanvasRow.js.map +1 -1
  12. package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
  13. package/dist/app/builder/CatalogueSheet.js +11 -4
  14. package/dist/app/builder/CatalogueSheet.js.map +1 -1
  15. package/dist/app/builder/CellPropsSheet.d.ts +7 -3
  16. package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
  17. package/dist/app/builder/CellPropsSheet.js +10 -3
  18. package/dist/app/builder/CellPropsSheet.js.map +1 -1
  19. package/dist/app/builder/DiscardDialog.d.ts +18 -0
  20. package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
  21. package/dist/app/builder/DiscardDialog.js +18 -0
  22. package/dist/app/builder/DiscardDialog.js.map +1 -0
  23. package/dist/app/builder/LayoutSurface.d.ts +33 -9
  24. package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
  25. package/dist/app/builder/LayoutSurface.js +74 -23
  26. package/dist/app/builder/LayoutSurface.js.map +1 -1
  27. package/dist/app/builder/PipelineStrip.d.ts +2 -1
  28. package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
  29. package/dist/app/builder/PipelineStrip.js +26 -13
  30. package/dist/app/builder/PipelineStrip.js.map +1 -1
  31. package/dist/app/builder/SaveDialog.d.ts +9 -1
  32. package/dist/app/builder/SaveDialog.d.ts.map +1 -1
  33. package/dist/app/builder/SaveDialog.js +8 -2
  34. package/dist/app/builder/SaveDialog.js.map +1 -1
  35. package/dist/app/builder/Toolbar.d.ts +12 -4
  36. package/dist/app/builder/Toolbar.d.ts.map +1 -1
  37. package/dist/app/builder/Toolbar.js +18 -7
  38. package/dist/app/builder/Toolbar.js.map +1 -1
  39. package/dist/app/builder/Workspace.d.ts +23 -3
  40. package/dist/app/builder/Workspace.d.ts.map +1 -1
  41. package/dist/app/builder/Workspace.js +58 -8
  42. package/dist/app/builder/Workspace.js.map +1 -1
  43. package/dist/app/builder/builder-model.d.ts +143 -1
  44. package/dist/app/builder/builder-model.d.ts.map +1 -1
  45. package/dist/app/builder/builder-model.js +191 -1
  46. package/dist/app/builder/builder-model.js.map +1 -1
  47. package/dist/app/builder/reveal.d.ts +10 -0
  48. package/dist/app/builder/reveal.d.ts.map +1 -0
  49. package/dist/app/builder/reveal.js +10 -0
  50. package/dist/app/builder/reveal.js.map +1 -0
  51. package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
  52. package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
  53. package/dist/app/builder/use-builder-catalogue.js +44 -17
  54. package/dist/app/builder/use-builder-catalogue.js.map +1 -1
  55. package/dist/app/builder/use-builder-session.d.ts +30 -0
  56. package/dist/app/builder/use-builder-session.d.ts.map +1 -0
  57. package/dist/app/builder/use-builder-session.js +58 -0
  58. package/dist/app/builder/use-builder-session.js.map +1 -0
  59. package/dist/app/builder/use-display-resolver.d.ts +17 -0
  60. package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
  61. package/dist/app/builder/use-display-resolver.js +16 -0
  62. package/dist/app/builder/use-display-resolver.js.map +1 -0
  63. package/dist/app/index.d.ts +8 -3
  64. package/dist/app/index.d.ts.map +1 -1
  65. package/dist/app/index.js +8 -2
  66. package/dist/app/index.js.map +1 -1
  67. package/dist/app/layout-builder.d.ts +13 -25
  68. package/dist/app/layout-builder.d.ts.map +1 -1
  69. package/dist/app/layout-builder.js +75 -81
  70. package/dist/app/layout-builder.js.map +1 -1
  71. package/dist/app/mcp-app-view-chrome.d.ts +12 -3
  72. package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
  73. package/dist/app/mcp-app-view-chrome.js +21 -5
  74. package/dist/app/mcp-app-view-chrome.js.map +1 -1
  75. package/dist/app/mcp-app-view-states.d.ts +11 -3
  76. package/dist/app/mcp-app-view-states.d.ts.map +1 -1
  77. package/dist/app/mcp-app-view-states.js +29 -8
  78. package/dist/app/mcp-app-view-states.js.map +1 -1
  79. package/dist/app/mcp-app-view.d.ts +33 -1
  80. package/dist/app/mcp-app-view.d.ts.map +1 -1
  81. package/dist/app/mcp-app-view.js +181 -127
  82. package/dist/app/mcp-app-view.js.map +1 -1
  83. package/dist/app/mcp-toolkit-app.d.ts +6 -1
  84. package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
  85. package/dist/app/mcp-toolkit-app.js +8 -1
  86. package/dist/app/mcp-toolkit-app.js.map +1 -1
  87. package/dist/app/mcp-use-host-bridge.d.ts +15 -6
  88. package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
  89. package/dist/app/mcp-use-host-bridge.js +51 -22
  90. package/dist/app/mcp-use-host-bridge.js.map +1 -1
  91. package/dist/app/model-context.d.ts +71 -0
  92. package/dist/app/model-context.d.ts.map +1 -0
  93. package/dist/app/model-context.js +101 -0
  94. package/dist/app/model-context.js.map +1 -0
  95. package/dist/app/use-build-mode-focus.d.ts +33 -0
  96. package/dist/app/use-build-mode-focus.d.ts.map +1 -0
  97. package/dist/app/use-build-mode-focus.js +59 -0
  98. package/dist/app/use-build-mode-focus.js.map +1 -0
  99. package/dist/app/use-view-actions.d.ts +31 -0
  100. package/dist/app/use-view-actions.d.ts.map +1 -0
  101. package/dist/app/use-view-actions.js +81 -0
  102. package/dist/app/use-view-actions.js.map +1 -0
  103. package/dist/app/use-view-lifecycle.d.ts +4 -0
  104. package/dist/app/use-view-lifecycle.d.ts.map +1 -1
  105. package/dist/app/use-view-lifecycle.js +2 -0
  106. package/dist/app/use-view-lifecycle.js.map +1 -1
  107. package/dist/app/view-lifecycle.d.ts +80 -4
  108. package/dist/app/view-lifecycle.d.ts.map +1 -1
  109. package/dist/app/view-lifecycle.js +90 -3
  110. package/dist/app/view-lifecycle.js.map +1 -1
  111. package/dist/app/widget-fixture.d.ts +10 -3
  112. package/dist/app/widget-fixture.d.ts.map +1 -1
  113. package/dist/app/widget-fixture.js +21 -8
  114. package/dist/app/widget-fixture.js.map +1 -1
  115. package/dist/app/widget-props-sheet.d.ts +3 -1
  116. package/dist/app/widget-props-sheet.d.ts.map +1 -1
  117. package/dist/app/widget-props-sheet.js +22 -10
  118. package/dist/app/widget-props-sheet.js.map +1 -1
  119. package/dist/app/widget-renderer.js +1 -1
  120. package/dist/app/widget-renderer.js.map +1 -1
  121. package/dist/components/DrillButton.d.ts +14 -6
  122. package/dist/components/DrillButton.d.ts.map +1 -1
  123. package/dist/components/DrillButton.js +14 -7
  124. package/dist/components/DrillButton.js.map +1 -1
  125. package/dist/components/EmptyState.d.ts.map +1 -1
  126. package/dist/components/EmptyState.js +4 -2
  127. package/dist/components/EmptyState.js.map +1 -1
  128. package/dist/components/ErrorState.d.ts +5 -2
  129. package/dist/components/ErrorState.d.ts.map +1 -1
  130. package/dist/components/ErrorState.js +25 -8
  131. package/dist/components/ErrorState.js.map +1 -1
  132. package/dist/components/FilterBar.d.ts +15 -4
  133. package/dist/components/FilterBar.d.ts.map +1 -1
  134. package/dist/components/FilterBar.js +11 -6
  135. package/dist/components/FilterBar.js.map +1 -1
  136. package/dist/components/KpiGrid.d.ts +66 -21
  137. package/dist/components/KpiGrid.d.ts.map +1 -1
  138. package/dist/components/KpiGrid.js +101 -34
  139. package/dist/components/KpiGrid.js.map +1 -1
  140. package/dist/components/ListFooter.d.ts +31 -4
  141. package/dist/components/ListFooter.d.ts.map +1 -1
  142. package/dist/components/ListFooter.js +30 -11
  143. package/dist/components/ListFooter.js.map +1 -1
  144. package/dist/components/PagedListFooter.d.ts +57 -0
  145. package/dist/components/PagedListFooter.d.ts.map +1 -0
  146. package/dist/components/PagedListFooter.js +27 -0
  147. package/dist/components/PagedListFooter.js.map +1 -0
  148. package/dist/components/Pills.d.ts +27 -7
  149. package/dist/components/Pills.d.ts.map +1 -1
  150. package/dist/components/Pills.js +21 -6
  151. package/dist/components/Pills.js.map +1 -1
  152. package/dist/components/SectionHeading.d.ts +3 -1
  153. package/dist/components/SectionHeading.d.ts.map +1 -1
  154. package/dist/components/SectionHeading.js +6 -2
  155. package/dist/components/SectionHeading.js.map +1 -1
  156. package/dist/components/Toast.d.ts +9 -3
  157. package/dist/components/Toast.d.ts.map +1 -1
  158. package/dist/components/Toast.js +31 -16
  159. package/dist/components/Toast.js.map +1 -1
  160. package/dist/components/WidgetHeader.d.ts +34 -10
  161. package/dist/components/WidgetHeader.d.ts.map +1 -1
  162. package/dist/components/WidgetHeader.js +11 -6
  163. package/dist/components/WidgetHeader.js.map +1 -1
  164. package/dist/hooks/index.d.ts +4 -4
  165. package/dist/hooks/index.d.ts.map +1 -1
  166. package/dist/hooks/index.js +1 -1
  167. package/dist/hooks/index.js.map +1 -1
  168. package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
  169. package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
  170. package/dist/hooks/tool-result-recovery-model.js +273 -0
  171. package/dist/hooks/tool-result-recovery-model.js.map +1 -0
  172. package/dist/hooks/use-tool-query.d.ts +56 -14
  173. package/dist/hooks/use-tool-query.d.ts.map +1 -1
  174. package/dist/hooks/use-tool-query.js +102 -25
  175. package/dist/hooks/use-tool-query.js.map +1 -1
  176. package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
  177. package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
  178. package/dist/hooks/use-tool-result-recovery.js +58 -120
  179. package/dist/hooks/use-tool-result-recovery.js.map +1 -1
  180. package/dist/hooks/use-view-data.d.ts +52 -10
  181. package/dist/hooks/use-view-data.d.ts.map +1 -1
  182. package/dist/hooks/use-view-data.js +81 -16
  183. package/dist/hooks/use-view-data.js.map +1 -1
  184. package/dist/hooks/use-view-tool-query.d.ts +12 -7
  185. package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
  186. package/dist/hooks/use-view-tool-query.js +15 -14
  187. package/dist/hooks/use-view-tool-query.js.map +1 -1
  188. package/dist/index.d.ts +17 -10
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +18 -6
  191. package/dist/index.js.map +1 -1
  192. package/dist/lib/focus-return.d.ts +61 -0
  193. package/dist/lib/focus-return.d.ts.map +1 -0
  194. package/dist/lib/focus-return.js +46 -0
  195. package/dist/lib/focus-return.js.map +1 -0
  196. package/dist/lib/parse-tool-result.d.ts +23 -1
  197. package/dist/lib/parse-tool-result.d.ts.map +1 -1
  198. package/dist/lib/parse-tool-result.js +38 -2
  199. package/dist/lib/parse-tool-result.js.map +1 -1
  200. package/dist/lib/toast-model.d.ts +41 -1
  201. package/dist/lib/toast-model.d.ts.map +1 -1
  202. package/dist/lib/toast-model.js +67 -1
  203. package/dist/lib/toast-model.js.map +1 -1
  204. package/dist/lib/tone-utils.d.ts +58 -6
  205. package/dist/lib/tone-utils.d.ts.map +1 -1
  206. package/dist/lib/tone-utils.js +66 -4
  207. package/dist/lib/tone-utils.js.map +1 -1
  208. package/dist/lib/tool-query.d.ts +107 -0
  209. package/dist/lib/tool-query.d.ts.map +1 -0
  210. package/dist/lib/tool-query.js +182 -0
  211. package/dist/lib/tool-query.js.map +1 -0
  212. package/dist/lib/tool-result-observer.d.ts +108 -0
  213. package/dist/lib/tool-result-observer.d.ts.map +1 -0
  214. package/dist/lib/tool-result-observer.js +158 -0
  215. package/dist/lib/tool-result-observer.js.map +1 -0
  216. package/dist/lib/typography.d.ts +37 -0
  217. package/dist/lib/typography.d.ts.map +1 -0
  218. package/dist/lib/typography.js +41 -0
  219. package/dist/lib/typography.js.map +1 -0
  220. package/dist/lib/utils.d.ts.map +1 -1
  221. package/dist/lib/utils.js +11 -1
  222. package/dist/lib/utils.js.map +1 -1
  223. package/dist/locale/index.d.ts +1 -1
  224. package/dist/locale/index.d.ts.map +1 -1
  225. package/dist/locale/message-types.d.ts +117 -4
  226. package/dist/locale/message-types.d.ts.map +1 -1
  227. package/dist/locale/messages-de.d.ts.map +1 -1
  228. package/dist/locale/messages-de.js +44 -10
  229. package/dist/locale/messages-de.js.map +1 -1
  230. package/dist/locale/messages-en.d.ts.map +1 -1
  231. package/dist/locale/messages-en.js +44 -10
  232. package/dist/locale/messages-en.js.map +1 -1
  233. package/dist/locale/toolkit-messages.d.ts +19 -1
  234. package/dist/locale/toolkit-messages.d.ts.map +1 -1
  235. package/dist/locale/toolkit-messages.js +54 -1
  236. package/dist/locale/toolkit-messages.js.map +1 -1
  237. package/dist/primitives/alert.d.ts +1 -1
  238. package/dist/primitives/alert.d.ts.map +1 -1
  239. package/dist/primitives/alert.js +2 -0
  240. package/dist/primitives/alert.js.map +1 -1
  241. package/dist/primitives/badge.d.ts +1 -1
  242. package/dist/primitives/badge.d.ts.map +1 -1
  243. package/dist/primitives/badge.js +9 -4
  244. package/dist/primitives/badge.js.map +1 -1
  245. package/dist/primitives/button.d.ts +1 -1
  246. package/dist/primitives/button.js +1 -1
  247. package/dist/primitives/button.js.map +1 -1
  248. package/dist/primitives/dialog.d.ts +1 -1
  249. package/dist/primitives/dialog.d.ts.map +1 -1
  250. package/dist/primitives/dialog.js +10 -3
  251. package/dist/primitives/dialog.js.map +1 -1
  252. package/dist/primitives/select.js +1 -1
  253. package/dist/primitives/select.js.map +1 -1
  254. package/dist/primitives/sheet.d.ts +1 -1
  255. package/dist/primitives/sheet.d.ts.map +1 -1
  256. package/dist/primitives/sheet.js +6 -2
  257. package/dist/primitives/sheet.js.map +1 -1
  258. package/dist/providers/query-provider.d.ts +32 -1
  259. package/dist/providers/query-provider.d.ts.map +1 -1
  260. package/dist/providers/query-provider.js +53 -4
  261. package/dist/providers/query-provider.js.map +1 -1
  262. package/dist/render-cases-primitives.js +1 -1
  263. package/dist/render-cases-primitives.js.map +1 -1
  264. package/dist/render-cases.d.ts.map +1 -1
  265. package/dist/render-cases.js +30 -6
  266. package/dist/render-cases.js.map +1 -1
  267. package/dist/render-effects.d.ts +42 -0
  268. package/dist/render-effects.d.ts.map +1 -0
  269. package/dist/render-effects.js +81 -0
  270. package/dist/render-effects.js.map +1 -0
  271. package/dist/theme/create-theme.d.ts +19 -1
  272. package/dist/theme/create-theme.d.ts.map +1 -1
  273. package/dist/theme/create-theme.js +15 -0
  274. package/dist/theme/create-theme.js.map +1 -1
  275. package/package.json +5 -4
  276. package/src/app/adapt-data-widget.tsx +2 -42
  277. package/src/app/builder/BuilderBanners.tsx +1 -1
  278. package/src/app/builder/CanvasRow.tsx +63 -17
  279. package/src/app/builder/CatalogueSheet.tsx +50 -29
  280. package/src/app/builder/CellPropsSheet.tsx +17 -2
  281. package/src/app/builder/DiscardDialog.tsx +59 -0
  282. package/src/app/builder/LayoutSurface.tsx +206 -66
  283. package/src/app/builder/PipelineStrip.tsx +253 -153
  284. package/src/app/builder/SaveDialog.tsx +14 -2
  285. package/src/app/builder/Toolbar.tsx +144 -59
  286. package/src/app/builder/Workspace.tsx +226 -58
  287. package/src/app/builder/builder-model.ts +341 -2
  288. package/src/app/builder/builder-model.ux.test.ts +456 -0
  289. package/src/app/builder/builder-ux.test.tsx +844 -0
  290. package/src/app/builder/reveal.ts +10 -0
  291. package/src/app/builder/use-builder-catalogue.ts +56 -19
  292. package/src/app/builder/use-builder-session.ts +68 -0
  293. package/src/app/builder/use-display-resolver.ts +24 -0
  294. package/src/app/index.ts +12 -2
  295. package/src/app/layout-builder.tsx +178 -191
  296. package/src/app/mcp-app-view-chrome.test.tsx +141 -1
  297. package/src/app/mcp-app-view-chrome.tsx +56 -6
  298. package/src/app/mcp-app-view-states.tsx +51 -11
  299. package/src/app/mcp-app-view.effects.test.tsx +292 -0
  300. package/src/app/mcp-app-view.test.tsx +222 -2
  301. package/src/app/mcp-app-view.tsx +267 -117
  302. package/src/app/mcp-toolkit-app.test.tsx +40 -0
  303. package/src/app/mcp-toolkit-app.tsx +8 -1
  304. package/src/app/mcp-use-host-bridge.test.tsx +180 -0
  305. package/src/app/mcp-use-host-bridge.tsx +67 -24
  306. package/src/app/model-context.test.tsx +116 -0
  307. package/src/app/model-context.tsx +125 -0
  308. package/src/app/use-build-mode-focus.ts +62 -0
  309. package/src/app/use-view-actions.ts +89 -0
  310. package/src/app/use-view-lifecycle.ts +6 -0
  311. package/src/app/view-lifecycle.test.ts +216 -0
  312. package/src/app/view-lifecycle.ts +165 -5
  313. package/src/app/widget-fixture.effects.test.tsx +51 -0
  314. package/src/app/widget-fixture.render.test.tsx +81 -0
  315. package/src/app/widget-fixture.tsx +34 -11
  316. package/src/app/widget-props-sheet.tsx +57 -16
  317. package/src/app/widget-renderer.tsx +1 -1
  318. package/src/components/DrillButton.test.tsx +48 -0
  319. package/src/components/DrillButton.tsx +26 -11
  320. package/src/components/EmptyState.tsx +6 -4
  321. package/src/components/ErrorState.tsx +31 -9
  322. package/src/components/FilterBar.test.tsx +82 -0
  323. package/src/components/FilterBar.tsx +52 -26
  324. package/src/components/KpiGrid.test.tsx +279 -0
  325. package/src/components/KpiGrid.tsx +297 -90
  326. package/src/components/ListFooter.test.tsx +116 -0
  327. package/src/components/ListFooter.tsx +102 -15
  328. package/src/components/PagedListFooter.test.tsx +140 -0
  329. package/src/components/PagedListFooter.tsx +107 -0
  330. package/src/components/Pills.test.tsx +77 -0
  331. package/src/components/Pills.tsx +63 -10
  332. package/src/components/SectionHeading.tsx +9 -3
  333. package/src/components/Toast.tsx +37 -19
  334. package/src/components/WidgetHeader.test.tsx +78 -0
  335. package/src/components/WidgetHeader.tsx +63 -16
  336. package/src/components/feedback-states.test.tsx +98 -9
  337. package/src/components/tone-figures.test.tsx +8 -4
  338. package/src/hooks/index.ts +6 -3
  339. package/src/hooks/tool-result-recovery-model.test.ts +744 -0
  340. package/src/hooks/tool-result-recovery-model.ts +418 -0
  341. package/src/hooks/use-tool-query.effects.test.tsx +205 -0
  342. package/src/hooks/use-tool-query.ts +156 -33
  343. package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
  344. package/src/hooks/use-tool-result-recovery.test.ts +3 -4
  345. package/src/hooks/use-tool-result-recovery.ts +84 -142
  346. package/src/hooks/use-view-data.test.tsx +118 -9
  347. package/src/hooks/use-view-data.ts +128 -18
  348. package/src/hooks/use-view-tool-query.ts +25 -19
  349. package/src/i18n-coverage.test.tsx +104 -2
  350. package/src/index.ts +48 -11
  351. package/src/lib/focus-return.test.ts +288 -0
  352. package/src/lib/focus-return.ts +96 -0
  353. package/src/lib/parse-tool-result.test.ts +46 -1
  354. package/src/lib/parse-tool-result.ts +40 -2
  355. package/src/lib/toast-model.test.ts +166 -1
  356. package/src/lib/toast-model.ts +104 -2
  357. package/src/lib/tone-utils.test.ts +86 -10
  358. package/src/lib/tone-utils.ts +89 -8
  359. package/src/lib/tool-query.test.ts +569 -0
  360. package/src/lib/tool-query.ts +253 -0
  361. package/src/lib/tool-result-observer.test.ts +296 -0
  362. package/src/lib/tool-result-observer.ts +211 -0
  363. package/src/lib/typography.test.ts +45 -0
  364. package/src/lib/typography.ts +47 -0
  365. package/src/lib/utils.ts +12 -1
  366. package/src/locale/index.ts +1 -0
  367. package/src/locale/message-types.ts +118 -2
  368. package/src/locale/messages-de.ts +49 -10
  369. package/src/locale/messages-en.ts +49 -10
  370. package/src/locale/toolkit-messages.test.ts +133 -0
  371. package/src/locale/toolkit-messages.ts +63 -2
  372. package/src/primitives/alert.tsx +3 -0
  373. package/src/primitives/badge.tsx +9 -4
  374. package/src/primitives/button.tsx +1 -1
  375. package/src/primitives/dialog.tsx +10 -1
  376. package/src/primitives/select.tsx +1 -1
  377. package/src/primitives/sheet.tsx +7 -0
  378. package/src/providers/query-provider.test.tsx +154 -0
  379. package/src/providers/query-provider.tsx +66 -5
  380. package/src/render-cases-primitives.tsx +1 -1
  381. package/src/render-cases.tsx +38 -6
  382. package/src/render-effects.tsx +96 -0
  383. package/src/theme/cd-tokens.test.ts +3 -1
  384. package/src/theme/create-theme.test.ts +10 -0
  385. package/src/theme/create-theme.ts +28 -0
  386. package/src/theme/theme-css.test.ts +51 -0
  387. package/src/theme.css +31 -1
  388. package/ui-catalog.json +448 -41
@@ -0,0 +1,744 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import {
3
+ createToolResultObserver,
4
+ EMPTY_TOOL_OBSERVATION,
5
+ type ToolObservation,
6
+ type ToolResultObserver,
7
+ } from "../lib/tool-result-observer.js"
8
+ import {
9
+ createRecoveryController,
10
+ decideRecovery,
11
+ evaluateRecovery,
12
+ policyAllows,
13
+ RECOVERY_IDLE,
14
+ recoveryArgs,
15
+ resolveToolSafety,
16
+ stableKey,
17
+ type RecoveryDecisionInput,
18
+ type RecoveryInputs,
19
+ type RecoveryPolicy,
20
+ } from "./tool-result-recovery-model.js"
21
+ import { resolveRecoveryToolName } from "./use-tool-result-recovery.js"
22
+
23
+ /**
24
+ * The stripped-result recovery of McpAppView (toolkit#176), driven through
25
+ * the framework-free controller the hook binds to React. The scenarios are
26
+ * the host behaviours the issue names: a host that KEEPS `structuredContent`,
27
+ * one that STRIPS it (claude.ai), and a SLOW tool on a compliant host, which
28
+ * must never be re-run (K13).
29
+ */
30
+
31
+ const GRACE = 2500
32
+
33
+ const toolContext = (annotations?: Record<string, unknown>) => ({
34
+ toolInfo: { id: 1, tool: { name: "render-view", ...(annotations ? { annotations } : {}) } },
35
+ })
36
+
37
+ interface View {
38
+ context: object
39
+ layout: object
40
+ }
41
+ const isView = (v: unknown): boolean =>
42
+ typeof v === "object" && v !== null && "context" in v && "layout" in v
43
+ const VIEW: View = { context: { steps: {} }, layout: { rows: [] } }
44
+ const VIEW_B: View = { context: { steps: { b: 1 } }, layout: { rows: [] } }
45
+ const result = (structuredContent: unknown) => ({ content: [], structuredContent })
46
+
47
+ /** A host frame: posts the SEP-1865 lifecycle messages the observer reads. */
48
+ function hostFrame() {
49
+ const target = new EventTarget()
50
+ const observer = createToolResultObserver(target)
51
+ const post = (data: unknown) => target.dispatchEvent(new MessageEvent("message", { data }))
52
+ const notify = (method: string, params: Record<string, unknown> = {}) =>
53
+ post({ jsonrpc: "2.0", method, params })
54
+ return {
55
+ observer,
56
+ handshake: () =>
57
+ post({ jsonrpc: "2.0", id: 0, result: { protocolVersion: "x", hostCapabilities: {} } }),
58
+ input: (args: Record<string, unknown>) =>
59
+ notify("ui/notifications/tool-input", { arguments: args }),
60
+ partialInput: (args: Record<string, unknown>) =>
61
+ notify("ui/notifications/tool-input-partial", { arguments: args }),
62
+ structuredResult: () => notify("ui/notifications/tool-result", { structuredContent: VIEW }),
63
+ strippedResult: () =>
64
+ notify("ui/notifications/tool-result", { content: [{ type: "text", text: "Done." }] }),
65
+ errorResult: () => notify("ui/notifications/tool-result", { isError: true, content: [] }),
66
+ cancelled: () => notify("ui/notifications/tool-cancelled"),
67
+ }
68
+ }
69
+
70
+ /** A controlled `callTool`: each call waits until the test settles it. */
71
+ function deferredCalls() {
72
+ const calls: {
73
+ name: string
74
+ args: Record<string, unknown>
75
+ resolve: (v: unknown) => void
76
+ reject: (e: unknown) => void
77
+ }[] = []
78
+ const callTool = vi.fn(
79
+ (name: string, args: Record<string, unknown>) =>
80
+ new Promise<unknown>((resolve, reject) => calls.push({ name, args, resolve, reject })),
81
+ )
82
+ return { calls, callTool }
83
+ }
84
+
85
+ function setup(options: { observer?: ToolResultObserver | null; now?: () => number } = {}) {
86
+ const { calls, callTool } = deferredCalls()
87
+ const controller = createRecoveryController<View>({
88
+ observer: options.observer ?? null,
89
+ now: options.now ?? (() => 1_000),
90
+ })
91
+ const listener = vi.fn()
92
+ controller.subscribe(listener)
93
+ let current: RecoveryInputs = {
94
+ resultReady: false,
95
+ cancelled: false,
96
+ errored: false,
97
+ props: undefined,
98
+ isValid: isView,
99
+ toolInput: undefined,
100
+ hostContext: toolContext(),
101
+ callTool,
102
+ policy: "auto",
103
+ graceMs: GRACE,
104
+ resolveToolName: resolveRecoveryToolName,
105
+ }
106
+ const update = (patch: Partial<RecoveryInputs>) => {
107
+ current = { ...current, ...patch }
108
+ controller.update(current)
109
+ }
110
+ return { controller, calls, callTool, listener, update }
111
+ }
112
+
113
+ beforeEach(() => {
114
+ vi.useFakeTimers()
115
+ vi.spyOn(console, "warn").mockImplementation(() => {})
116
+ })
117
+
118
+ afterEach(() => {
119
+ vi.useRealTimers()
120
+ vi.restoreAllMocks()
121
+ })
122
+
123
+ describe("keep: a host that delivers structuredContent", () => {
124
+ it("never re-executes the tool", async () => {
125
+ const host = hostFrame()
126
+ const { controller, update, callTool } = setup({ observer: host.observer })
127
+ host.handshake()
128
+ controller.start()
129
+ update({ toolInput: { id: 1 } })
130
+ host.input({ id: 1 })
131
+ host.structuredResult()
132
+ update({ resultReady: true, props: VIEW })
133
+ await vi.advanceTimersByTimeAsync(60_000)
134
+ expect(callTool).not.toHaveBeenCalled()
135
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
136
+ })
137
+ })
138
+
139
+ describe("slow: a compliant host still running the tool (K13)", () => {
140
+ it("waits for the host's result instead of re-running after the grace period", async () => {
141
+ const host = hostFrame()
142
+ const { controller, update, callTool } = setup({ observer: host.observer })
143
+ host.handshake()
144
+ controller.start()
145
+ host.input({ id: 1 })
146
+ update({ toolInput: { id: 1 } })
147
+ await vi.advanceTimersByTimeAsync(GRACE * 10)
148
+ expect(callTool, "a slow tool is never re-run").not.toHaveBeenCalled()
149
+ host.structuredResult()
150
+ update({ resultReady: true, props: VIEW })
151
+ await vi.advanceTimersByTimeAsync(GRACE)
152
+ expect(callTool).not.toHaveBeenCalled()
153
+ })
154
+
155
+ it("is exactly the case the 2.x grace timer got wrong (no observer: re-run after the grace)", async () => {
156
+ const { controller, update, callTool } = setup({ observer: null })
157
+ controller.start()
158
+ update({ toolInput: { id: 1 } })
159
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
160
+ expect(callTool).not.toHaveBeenCalled()
161
+ await vi.advanceTimersByTimeAsync(1)
162
+ expect(callTool).toHaveBeenCalledExactlyOnceWith("render-view", { id: 1 })
163
+ })
164
+ })
165
+
166
+ describe("strip: a host that delivers the result without structuredContent", () => {
167
+ it("re-runs the tool as the stripped result arrives, with the COMPLETE arguments (N21)", async () => {
168
+ const host = hostFrame()
169
+ const { controller, update, calls, listener } = setup({
170
+ observer: host.observer,
171
+ now: () => 42_000,
172
+ })
173
+ host.handshake()
174
+ controller.start()
175
+ // mcp-use merges partial and complete input into one field.
176
+ host.partialInput({ id: 1 })
177
+ update({ toolInput: { id: 1 } })
178
+ host.input({ id: 1, period: "7d" })
179
+ update({ toolInput: { id: 1, period: "7d" } })
180
+ expect(calls).toHaveLength(0)
181
+ host.strippedResult()
182
+ expect(calls).toHaveLength(1)
183
+ expect(calls[0]).toMatchObject({ name: "render-view", args: { id: 1, period: "7d" } })
184
+ calls[0]?.resolve(result(VIEW))
185
+ await vi.advanceTimersByTimeAsync(0)
186
+ expect(controller.getState()).toEqual({
187
+ data: VIEW,
188
+ failed: false,
189
+ blocked: null,
190
+ recoveredAt: 42_000,
191
+ args: { id: 1, period: "7d" },
192
+ })
193
+ expect(listener).toHaveBeenCalled()
194
+ })
195
+
196
+ it("uses the complete input the observer saw even when the view only holds a partial one", () => {
197
+ const host = hostFrame()
198
+ const { controller, update, calls } = setup({ observer: host.observer })
199
+ host.handshake()
200
+ controller.start()
201
+ host.partialInput({ id: 1 })
202
+ update({ toolInput: { id: 1 } })
203
+ host.input({ id: 1, full: true })
204
+ host.strippedResult()
205
+ expect(calls[0]?.args).toEqual({ id: 1, full: true })
206
+ })
207
+
208
+ it("an observer that joined late still sees the stripped result and recovers at once", () => {
209
+ const host = hostFrame()
210
+ const { controller, update, calls } = setup({ observer: host.observer })
211
+ controller.start()
212
+ update({ toolInput: { id: 1 } })
213
+ host.strippedResult()
214
+ expect(calls).toHaveLength(1)
215
+ expect(calls[0]?.args).toEqual({ id: 1 })
216
+ })
217
+
218
+ it("an observer that joined late and saw nothing falls back to the grace timer", async () => {
219
+ const host = hostFrame()
220
+ const { controller, update, callTool } = setup({ observer: host.observer })
221
+ controller.start()
222
+ update({ toolInput: { id: 1 } })
223
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
224
+ expect(callTool).not.toHaveBeenCalled()
225
+ await vi.advanceTimersByTimeAsync(1)
226
+ expect(callTool).toHaveBeenCalledOnce()
227
+ })
228
+
229
+ it("a latched result that is no view is recovered at once", () => {
230
+ const { controller, update, calls } = setup()
231
+ controller.start()
232
+ update({ resultReady: true, props: { notAView: true }, toolInput: { id: 1 } })
233
+ expect(calls).toHaveLength(1)
234
+ })
235
+
236
+ it("a failed re-run reports `failed` once and never loops", async () => {
237
+ const host = hostFrame()
238
+ const { controller, update, calls, callTool } = setup({ observer: host.observer })
239
+ host.handshake()
240
+ controller.start()
241
+ update({ toolInput: { id: 1 } })
242
+ host.strippedResult()
243
+ calls[0]?.reject(new Error("transport down"))
244
+ await vi.advanceTimersByTimeAsync(0)
245
+ expect(controller.getState()).toMatchObject({ data: null, failed: true, args: { id: 1 } })
246
+ update({ toolInput: { id: 1 } })
247
+ await vi.advanceTimersByTimeAsync(GRACE * 4)
248
+ expect(callTool).toHaveBeenCalledOnce()
249
+ })
250
+
251
+ it("an unusable re-run payload is a failure too", async () => {
252
+ const host = hostFrame()
253
+ const { controller, update, calls } = setup({ observer: host.observer })
254
+ host.handshake()
255
+ controller.start()
256
+ update({ toolInput: {} })
257
+ host.strippedResult()
258
+ calls[0]?.resolve(result({ notAView: true }))
259
+ await vi.advanceTimersByTimeAsync(0)
260
+ expect(controller.getState().failed).toBe(true)
261
+ })
262
+
263
+ it("the host's own payload, once delivered, replaces a recovered one", async () => {
264
+ const { controller, update, calls } = setup()
265
+ controller.start()
266
+ update({ toolInput: { id: 1 } })
267
+ await vi.advanceTimersByTimeAsync(GRACE)
268
+ calls[0]?.resolve(result(VIEW))
269
+ await vi.advanceTimersByTimeAsync(0)
270
+ expect(controller.getState().data).toEqual(VIEW)
271
+ update({ resultReady: true, props: VIEW_B })
272
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
273
+ })
274
+ })
275
+
276
+ describe("never recover a failed or cancelled call (K14)", () => {
277
+ it("an isError result is rendered, not re-run", async () => {
278
+ const host = hostFrame()
279
+ const { controller, update, callTool } = setup({ observer: host.observer })
280
+ host.handshake()
281
+ controller.start()
282
+ update({ toolInput: { id: 1 } })
283
+ host.errorResult()
284
+ update({ resultReady: true, errored: true })
285
+ await vi.advanceTimersByTimeAsync(GRACE * 4)
286
+ expect(callTool).not.toHaveBeenCalled()
287
+ })
288
+
289
+ it("a cancellation drops an attempt in flight and its outcome", async () => {
290
+ const { controller, update, calls } = setup()
291
+ controller.start()
292
+ update({ toolInput: { id: 1 } })
293
+ await vi.advanceTimersByTimeAsync(GRACE)
294
+ expect(calls).toHaveLength(1)
295
+ update({ cancelled: true })
296
+ calls[0]?.resolve(result(VIEW))
297
+ await vi.advanceTimersByTimeAsync(0)
298
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
299
+ })
300
+
301
+ it("a cancellation the observer saw suppresses the recovery before the latch reports it", async () => {
302
+ const host = hostFrame()
303
+ const { controller, update, callTool } = setup({ observer: host.observer })
304
+ controller.start()
305
+ update({ toolInput: { id: 1 } })
306
+ host.cancelled()
307
+ await vi.advanceTimersByTimeAsync(GRACE * 2)
308
+ expect(callTool).not.toHaveBeenCalled()
309
+ })
310
+ })
311
+
312
+ describe("the recovery policy (annotations) and the named tool", () => {
313
+ const blockedFor = (policy: RecoveryPolicy, annotations?: Record<string, unknown>) => {
314
+ const host = hostFrame()
315
+ const { controller, update, callTool } = setup({ observer: host.observer })
316
+ host.handshake()
317
+ controller.start()
318
+ update({ toolInput: { id: 1 }, policy, hostContext: toolContext(annotations) })
319
+ host.strippedResult()
320
+ return { state: controller.getState(), called: callTool.mock.calls.length }
321
+ }
322
+
323
+ it("auto never re-runs a tool the host reports as writing", () => {
324
+ expect(blockedFor("auto", { readOnlyHint: false })).toEqual({
325
+ state: { ...RECOVERY_IDLE, blocked: "policy" },
326
+ called: 0,
327
+ })
328
+ expect(blockedFor("auto", { destructiveHint: true, readOnlyHint: true }).called).toBe(0)
329
+ })
330
+
331
+ it("auto re-runs a read-only tool and one without annotations", () => {
332
+ expect(blockedFor("auto", { readOnlyHint: true }).called).toBe(1)
333
+ expect(blockedFor("auto").called).toBe(1)
334
+ })
335
+
336
+ it("read-only needs the host's readOnlyHint", () => {
337
+ expect(blockedFor("read-only").state.blocked).toBe("policy")
338
+ expect(blockedFor("read-only", { readOnlyHint: true }).called).toBe(1)
339
+ })
340
+
341
+ it("never re-runs nothing", () => {
342
+ expect(blockedFor("never", { readOnlyHint: true })).toEqual({
343
+ state: { ...RECOVERY_IDLE, blocked: "policy" },
344
+ called: 0,
345
+ })
346
+ })
347
+
348
+ it("a stripped result on a host that names no tool is unrecoverable, not an endless wait", () => {
349
+ const host = hostFrame()
350
+ const { controller, update, callTool } = setup({ observer: host.observer })
351
+ host.handshake()
352
+ controller.start()
353
+ update({ toolInput: { id: 1 }, hostContext: {} })
354
+ host.strippedResult()
355
+ expect(controller.getState().blocked).toBe("no-tool")
356
+ expect(callTool).not.toHaveBeenCalled()
357
+ })
358
+
359
+ it("a no-tool block lifts as soon as the host names the tool, while the re-run runs", () => {
360
+ const host = hostFrame()
361
+ const { controller, update, calls } = setup({ observer: host.observer })
362
+ host.handshake()
363
+ controller.start()
364
+ update({ toolInput: { id: 1 }, hostContext: {} })
365
+ host.strippedResult()
366
+ expect(controller.getState().blocked).toBe("no-tool")
367
+ update({ hostContext: toolContext() })
368
+ expect(calls).toHaveLength(1)
369
+ expect(controller.getState(), "no error shown for a running re-run").toBe(RECOVERY_IDLE)
370
+ })
371
+
372
+ it("a block lifts when the need goes away", () => {
373
+ const host = hostFrame()
374
+ const { controller, update } = setup({ observer: host.observer })
375
+ controller.start()
376
+ update({ toolInput: {}, policy: "never" })
377
+ host.strippedResult()
378
+ expect(controller.getState().blocked).toBe("policy")
379
+ update({ resultReady: true, props: VIEW })
380
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
381
+ })
382
+ })
383
+
384
+ describe("arguments: partial streams and re-deliveries (N21)", () => {
385
+ it("newer arguments drop a running re-run, restart the grace and keep the newer payload", async () => {
386
+ const { controller, update, calls } = setup()
387
+ controller.start()
388
+ update({ toolInput: { id: 1 } })
389
+ await vi.advanceTimersByTimeAsync(GRACE)
390
+ expect(calls).toHaveLength(1)
391
+ update({ toolInput: { id: 2 } })
392
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
393
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
394
+ expect(calls).toHaveLength(1)
395
+ await vi.advanceTimersByTimeAsync(1)
396
+ expect(calls).toHaveLength(2)
397
+ expect(calls[1]?.args).toEqual({ id: 2 })
398
+ calls[1]?.resolve(result(VIEW_B))
399
+ calls[0]?.resolve(result(VIEW))
400
+ await vi.advanceTimersByTimeAsync(0)
401
+ expect(controller.getState()).toMatchObject({ data: VIEW_B, args: { id: 2 } })
402
+ })
403
+
404
+ it("an equal re-delivery (new object, other key order) is the same run", async () => {
405
+ const { controller, update, callTool } = setup()
406
+ controller.start()
407
+ update({ toolInput: { a: 1, b: { c: 2, d: 3 } } })
408
+ await vi.advanceTimersByTimeAsync(GRACE - 100)
409
+ update({ toolInput: { b: { d: 3, c: 2 }, a: 1 } })
410
+ await vi.advanceTimersByTimeAsync(100)
411
+ expect(callTool, "the grace was not restarted").toHaveBeenCalledOnce()
412
+ update({ toolInput: { a: 1, b: { c: 2, d: 3 } } })
413
+ await vi.advanceTimersByTimeAsync(GRACE)
414
+ expect(callTool, "and not fired twice").toHaveBeenCalledOnce()
415
+ })
416
+
417
+ it("without any arguments yet the grace timer is not armed", async () => {
418
+ const { controller, update, callTool } = setup()
419
+ controller.start()
420
+ update({ toolInput: undefined })
421
+ await vi.advanceTimersByTimeAsync(GRACE * 4)
422
+ expect(callTool).not.toHaveBeenCalled()
423
+ })
424
+
425
+ it("without a named tool the grace timer is not armed either", async () => {
426
+ const { controller, update, callTool } = setup()
427
+ controller.start()
428
+ update({ toolInput: { id: 1 }, hostContext: undefined })
429
+ await vi.advanceTimersByTimeAsync(GRACE * 4)
430
+ expect(callTool).not.toHaveBeenCalled()
431
+ expect(controller.getState()).toBe(RECOVERY_IDLE)
432
+ })
433
+
434
+ it("newer arguments before the grace ran out restart it: the old timer never fires", async () => {
435
+ const { controller, update, calls } = setup()
436
+ controller.start()
437
+ update({ toolInput: { id: 1 } })
438
+ await vi.advanceTimersByTimeAsync(GRACE - 500)
439
+ update({ toolInput: { id: 2 } })
440
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
441
+ expect(calls, "no call 2.5 s after the FIRST arguments").toHaveLength(0)
442
+ await vi.advanceTimersByTimeAsync(1)
443
+ expect(calls.map((c) => c.args)).toEqual([{ id: 2 }])
444
+ })
445
+
446
+ it("a grace dropped while the tool is unnamed restarts in full once it is named again", async () => {
447
+ const { controller, update, callTool } = setup()
448
+ controller.start()
449
+ update({ toolInput: { id: 1 } })
450
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
451
+ update({ hostContext: undefined })
452
+ await vi.advanceTimersByTimeAsync(GRACE)
453
+ expect(callTool).not.toHaveBeenCalled()
454
+ update({ hostContext: toolContext() })
455
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
456
+ expect(callTool).not.toHaveBeenCalled()
457
+ await vi.advanceTimersByTimeAsync(1)
458
+ expect(callTool).toHaveBeenCalledOnce()
459
+ })
460
+
461
+ it("the observer's complete input counts as arguments even before the view holds any", async () => {
462
+ const host = hostFrame()
463
+ const { controller, update, callTool } = setup({ observer: host.observer })
464
+ controller.start()
465
+ host.input({ id: 7 })
466
+ update({ toolInput: undefined })
467
+ await vi.advanceTimersByTimeAsync(GRACE)
468
+ expect(callTool).toHaveBeenCalledExactlyOnceWith("render-view", { id: 7 })
469
+ })
470
+ })
471
+
472
+ describe("what the controller reports", () => {
473
+ it("warns with the tool's name when a re-run fails or returns nothing usable", async () => {
474
+ const warn = vi.mocked(console.warn)
475
+ const { controller, update, calls } = setup()
476
+ controller.start()
477
+ update({ resultReady: true, props: null, toolInput: { id: 1 } })
478
+ calls[0]?.reject(new Error("transport down"))
479
+ await vi.advanceTimersByTimeAsync(0)
480
+ expect(warn).toHaveBeenLastCalledWith(
481
+ '[mcp-toolkit] tool-result recovery for "render-view" failed:',
482
+ expect.objectContaining({ message: "transport down" }),
483
+ )
484
+ update({ toolInput: { id: 2 } })
485
+ calls[1]?.resolve(result({ notAView: true }))
486
+ await vi.advanceTimersByTimeAsync(0)
487
+ expect(warn).toHaveBeenLastCalledWith(
488
+ '[mcp-toolkit] tool-result recovery: re-executing "render-view" returned no usable payload',
489
+ )
490
+ })
491
+
492
+ it("tells its listeners only about real changes", async () => {
493
+ const { controller, update, listener, calls } = setup()
494
+ controller.start()
495
+ update({ toolInput: { id: 1 }, policy: "never", resultReady: true, props: null })
496
+ expect(controller.getState().blocked).toBe("policy")
497
+ update({ toolInput: { id: 1 }, policy: "never", resultReady: true, props: null })
498
+ update({ toolInput: { id: 1 }, policy: "never", resultReady: true, props: null })
499
+ expect(listener, "one block, one notification").toHaveBeenCalledOnce()
500
+ update({ policy: "auto" })
501
+ expect(listener, "the lifted block").toHaveBeenCalledTimes(2)
502
+ calls[0]?.resolve(result(VIEW))
503
+ await vi.advanceTimersByTimeAsync(0)
504
+ update({ policy: "auto" })
505
+ expect(listener, "the recovered payload, once").toHaveBeenCalledTimes(3)
506
+ })
507
+ })
508
+
509
+ describe("mount lifecycle", () => {
510
+ it("a StrictMode stop/start never fires a second call; the first one still lands", async () => {
511
+ const host = hostFrame()
512
+ const { controller, update, calls } = setup({ observer: host.observer })
513
+ controller.start()
514
+ update({ toolInput: { id: 1 } })
515
+ host.strippedResult()
516
+ controller.stop()
517
+ controller.start()
518
+ expect(calls).toHaveLength(1)
519
+ calls[0]?.resolve(result(VIEW))
520
+ await vi.advanceTimersByTimeAsync(0)
521
+ expect(controller.getState().data).toEqual(VIEW)
522
+ })
523
+
524
+ it("a stopped grace timer restarts in full on the next start", async () => {
525
+ const { controller, update, callTool } = setup()
526
+ controller.start()
527
+ update({ toolInput: { id: 1 } })
528
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
529
+ controller.stop()
530
+ await vi.advanceTimersByTimeAsync(GRACE)
531
+ expect(callTool).not.toHaveBeenCalled()
532
+ controller.start()
533
+ await vi.advanceTimersByTimeAsync(GRACE - 1)
534
+ expect(callTool).not.toHaveBeenCalled()
535
+ await vi.advanceTimersByTimeAsync(1)
536
+ expect(callTool).toHaveBeenCalledOnce()
537
+ })
538
+
539
+ it("an elapsed grace survives a stop/start: the running re-run is kept and lands", async () => {
540
+ const { controller, update, calls } = setup()
541
+ controller.start()
542
+ update({ toolInput: { id: 1 } })
543
+ await vi.advanceTimersByTimeAsync(GRACE)
544
+ controller.stop()
545
+ controller.start()
546
+ await vi.advanceTimersByTimeAsync(GRACE * 2)
547
+ expect(calls).toHaveLength(1)
548
+ calls[0]?.resolve(result(VIEW))
549
+ await vi.advanceTimersByTimeAsync(0)
550
+ expect(controller.getState().data).toEqual(VIEW)
551
+ })
552
+
553
+ it("stop unsubscribes from the observer; nothing runs before the first update", () => {
554
+ const host = hostFrame()
555
+ const { controller, update, callTool } = setup({ observer: host.observer })
556
+ controller.start()
557
+ host.strippedResult()
558
+ expect(callTool, "no inputs yet").not.toHaveBeenCalled()
559
+ controller.stop()
560
+ update({ toolInput: {} })
561
+ expect(callTool, "an update still evaluates the latest observation").toHaveBeenCalledOnce()
562
+ })
563
+
564
+ it("unsubscribed listeners are no longer told about changes", async () => {
565
+ const { controller, update, calls } = setup()
566
+ const late = vi.fn()
567
+ const unsubscribe = controller.subscribe(late)
568
+ unsubscribe()
569
+ controller.start()
570
+ update({ resultReady: true, props: null, toolInput: {} })
571
+ calls[0]?.resolve(result(VIEW))
572
+ await vi.advanceTimersByTimeAsync(0)
573
+ expect(late).not.toHaveBeenCalled()
574
+ })
575
+ })
576
+
577
+ describe("decideRecovery (the rule, as a table)", () => {
578
+ const base: RecoveryDecisionInput = {
579
+ resultReady: false,
580
+ payloadValid: false,
581
+ cancelled: false,
582
+ errored: false,
583
+ observation: null,
584
+ hasInput: true,
585
+ toolName: "render-view",
586
+ safety: "unknown",
587
+ policy: "auto",
588
+ graceElapsed: false,
589
+ }
590
+ const seen = (patch: Partial<ToolObservation>): ToolObservation => ({
591
+ ...EMPTY_TOOL_OBSERVATION,
592
+ ...patch,
593
+ })
594
+
595
+ it("cancelled or failed: nothing", () => {
596
+ expect(decideRecovery({ ...base, resultReady: true, cancelled: true })).toEqual({
597
+ action: "none",
598
+ })
599
+ expect(decideRecovery({ ...base, resultReady: true, errored: true })).toEqual({
600
+ action: "none",
601
+ })
602
+ expect(
603
+ decideRecovery({ ...base, observation: seen({ result: "stripped", cancelled: true }) }),
604
+ ).toEqual({ action: "none" })
605
+ })
606
+
607
+ it("delivered: nothing; latched but no view: recover", () => {
608
+ expect(decideRecovery({ ...base, resultReady: true, payloadValid: true })).toEqual({
609
+ action: "none",
610
+ })
611
+ expect(decideRecovery({ ...base, resultReady: true })).toEqual({ action: "recover" })
612
+ })
613
+
614
+ it("the observer's verdict: stripped recovers, structured or error waits for the latch", () => {
615
+ expect(decideRecovery({ ...base, observation: seen({ result: "stripped" }) })).toEqual({
616
+ action: "recover",
617
+ })
618
+ expect(decideRecovery({ ...base, observation: seen({ result: "structured" }) })).toEqual({
619
+ action: "none",
620
+ })
621
+ expect(decideRecovery({ ...base, observation: seen({ result: "error" }) })).toEqual({
622
+ action: "none",
623
+ })
624
+ })
625
+
626
+ it("nothing seen since the handshake: wait without a timer (K13)", () => {
627
+ expect(
628
+ decideRecovery({ ...base, observation: seen({ sawHandshake: true }), graceElapsed: true }),
629
+ ).toEqual({ action: "wait", armGrace: false })
630
+ })
631
+
632
+ it("no observer to vouch: the grace timer decides, once a tool and arguments are known", () => {
633
+ expect(decideRecovery(base)).toEqual({ action: "wait", armGrace: true })
634
+ expect(decideRecovery({ ...base, graceElapsed: true })).toEqual({ action: "recover" })
635
+ expect(decideRecovery({ ...base, observation: seen({}) })).toEqual({
636
+ action: "wait",
637
+ armGrace: true,
638
+ })
639
+ expect(decideRecovery({ ...base, hasInput: false })).toEqual({
640
+ action: "wait",
641
+ armGrace: false,
642
+ })
643
+ expect(decideRecovery({ ...base, toolName: undefined })).toEqual({
644
+ action: "wait",
645
+ armGrace: false,
646
+ })
647
+ })
648
+
649
+ it("a needed recovery the policy or a missing tool forbids is blocked", () => {
650
+ expect(decideRecovery({ ...base, resultReady: true, safety: "writes" })).toEqual({
651
+ action: "blocked",
652
+ reason: "policy",
653
+ })
654
+ expect(decideRecovery({ ...base, resultReady: true, toolName: undefined })).toEqual({
655
+ action: "blocked",
656
+ reason: "no-tool",
657
+ })
658
+ })
659
+ })
660
+
661
+ describe("evaluateRecovery", () => {
662
+ it("keys the run by the tool and the content of its arguments", () => {
663
+ const inputs: RecoveryInputs = {
664
+ resultReady: true,
665
+ cancelled: false,
666
+ errored: false,
667
+ props: null,
668
+ isValid: isView,
669
+ toolInput: { b: 2, a: 1 },
670
+ hostContext: toolContext(),
671
+ callTool: () => Promise.resolve(null),
672
+ policy: "auto",
673
+ graceMs: GRACE,
674
+ resolveToolName: resolveRecoveryToolName,
675
+ }
676
+ const run = evaluateRecovery(inputs, null, { key: null, elapsed: false })
677
+ expect(run).toEqual({
678
+ decision: { action: "recover" },
679
+ toolName: "render-view",
680
+ args: { b: 2, a: 1 },
681
+ argsKey: '{"a":1,"b":2}',
682
+ runKey: 'render-view\u0000{"a":1,"b":2}',
683
+ })
684
+ const elsewhere = evaluateRecovery({ ...inputs, resultReady: false }, null, {
685
+ key: '{"a":9}',
686
+ elapsed: true,
687
+ })
688
+ expect(elsewhere.decision, "an elapsed grace for other arguments does not count").toEqual({
689
+ action: "wait",
690
+ armGrace: true,
691
+ })
692
+ const unnamed = evaluateRecovery({ ...inputs, hostContext: {} }, null, {
693
+ key: null,
694
+ elapsed: false,
695
+ })
696
+ expect(unnamed.runKey).toBe('\u0000{"a":1,"b":2}')
697
+ })
698
+ })
699
+
700
+ describe("resolveToolSafety / policyAllows", () => {
701
+ it("reads the host's annotations", () => {
702
+ expect(resolveToolSafety(toolContext({ readOnlyHint: true }))).toBe("read-only")
703
+ expect(resolveToolSafety(toolContext({ readOnlyHint: false }))).toBe("writes")
704
+ expect(resolveToolSafety(toolContext({ readOnlyHint: true, destructiveHint: true }))).toBe(
705
+ "writes",
706
+ )
707
+ expect(resolveToolSafety(toolContext({ destructiveHint: false }))).toBe("unknown")
708
+ expect(resolveToolSafety(toolContext({}))).toBe("unknown")
709
+ expect(resolveToolSafety(toolContext())).toBe("unknown")
710
+ expect(resolveToolSafety({ toolInfo: { tool: "render-view" } })).toBe("unknown")
711
+ expect(resolveToolSafety({ toolInfo: null })).toBe("unknown")
712
+ expect(resolveToolSafety({ toolInfo: { tool: { annotations: [] } } })).toBe("unknown")
713
+ expect(resolveToolSafety(undefined)).toBe("unknown")
714
+ })
715
+
716
+ it("maps policy × safety", () => {
717
+ expect(policyAllows("auto", "read-only")).toBe(true)
718
+ expect(policyAllows("auto", "unknown")).toBe(true)
719
+ expect(policyAllows("auto", "writes")).toBe(false)
720
+ expect(policyAllows("read-only", "read-only")).toBe(true)
721
+ expect(policyAllows("read-only", "unknown")).toBe(false)
722
+ expect(policyAllows("read-only", "writes")).toBe(false)
723
+ expect(policyAllows("never", "read-only")).toBe(false)
724
+ })
725
+ })
726
+
727
+ describe("recoveryArgs / stableKey", () => {
728
+ it("prefers the observer's complete input over the view's, then none", () => {
729
+ const observed = { ...EMPTY_TOOL_OBSERVATION, completeInput: { full: true } }
730
+ expect(recoveryArgs(observed, { partial: true })).toEqual({ full: true })
731
+ expect(recoveryArgs(EMPTY_TOOL_OBSERVATION, { partial: true })).toEqual({ partial: true })
732
+ expect(recoveryArgs(null, null)).toEqual({})
733
+ expect(recoveryArgs(null, undefined)).toEqual({})
734
+ })
735
+
736
+ it("is the content identity of a JSON value, independent of key order", () => {
737
+ expect(stableKey({ b: 1, a: { d: [2, { y: 1, x: 0 }], c: null } })).toBe(
738
+ '{"a":{"c":null,"d":[2,{"x":0,"y":1}]},"b":1}',
739
+ )
740
+ expect(stableKey([3, 1])).toBe("[3,1]")
741
+ expect(stableKey("x")).toBe('"x"')
742
+ expect(stableKey(undefined)).toBe("undefined")
743
+ })
744
+ })