@miragon/mcp-toolkit-ui 2.7.0 → 2.8.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.
- package/README.md +7 -4
- package/dist/app/adapt-data-widget.d.ts +1 -22
- package/dist/app/adapt-data-widget.d.ts.map +1 -1
- package/dist/app/adapt-data-widget.js +1 -35
- package/dist/app/adapt-data-widget.js.map +1 -1
- package/dist/app/builder/BuilderBanners.js +1 -1
- package/dist/app/builder/BuilderBanners.js.map +1 -1
- package/dist/app/builder/CanvasRow.d.ts +4 -2
- package/dist/app/builder/CanvasRow.d.ts.map +1 -1
- package/dist/app/builder/CanvasRow.js +21 -7
- package/dist/app/builder/CanvasRow.js.map +1 -1
- package/dist/app/builder/CatalogueSheet.d.ts.map +1 -1
- package/dist/app/builder/CatalogueSheet.js +11 -4
- package/dist/app/builder/CatalogueSheet.js.map +1 -1
- package/dist/app/builder/CellPropsSheet.d.ts +7 -3
- package/dist/app/builder/CellPropsSheet.d.ts.map +1 -1
- package/dist/app/builder/CellPropsSheet.js +10 -3
- package/dist/app/builder/CellPropsSheet.js.map +1 -1
- package/dist/app/builder/DiscardDialog.d.ts +18 -0
- package/dist/app/builder/DiscardDialog.d.ts.map +1 -0
- package/dist/app/builder/DiscardDialog.js +18 -0
- package/dist/app/builder/DiscardDialog.js.map +1 -0
- package/dist/app/builder/LayoutSurface.d.ts +33 -9
- package/dist/app/builder/LayoutSurface.d.ts.map +1 -1
- package/dist/app/builder/LayoutSurface.js +74 -23
- package/dist/app/builder/LayoutSurface.js.map +1 -1
- package/dist/app/builder/PipelineStrip.d.ts +2 -1
- package/dist/app/builder/PipelineStrip.d.ts.map +1 -1
- package/dist/app/builder/PipelineStrip.js +26 -13
- package/dist/app/builder/PipelineStrip.js.map +1 -1
- package/dist/app/builder/SaveDialog.d.ts +9 -1
- package/dist/app/builder/SaveDialog.d.ts.map +1 -1
- package/dist/app/builder/SaveDialog.js +8 -2
- package/dist/app/builder/SaveDialog.js.map +1 -1
- package/dist/app/builder/Toolbar.d.ts +12 -4
- package/dist/app/builder/Toolbar.d.ts.map +1 -1
- package/dist/app/builder/Toolbar.js +18 -7
- package/dist/app/builder/Toolbar.js.map +1 -1
- package/dist/app/builder/Workspace.d.ts +23 -3
- package/dist/app/builder/Workspace.d.ts.map +1 -1
- package/dist/app/builder/Workspace.js +58 -8
- package/dist/app/builder/Workspace.js.map +1 -1
- package/dist/app/builder/builder-model.d.ts +143 -1
- package/dist/app/builder/builder-model.d.ts.map +1 -1
- package/dist/app/builder/builder-model.js +191 -1
- package/dist/app/builder/builder-model.js.map +1 -1
- package/dist/app/builder/reveal.d.ts +10 -0
- package/dist/app/builder/reveal.d.ts.map +1 -0
- package/dist/app/builder/reveal.js +10 -0
- package/dist/app/builder/reveal.js.map +1 -0
- package/dist/app/builder/use-builder-catalogue.d.ts +4 -1
- package/dist/app/builder/use-builder-catalogue.d.ts.map +1 -1
- package/dist/app/builder/use-builder-catalogue.js +44 -17
- package/dist/app/builder/use-builder-catalogue.js.map +1 -1
- package/dist/app/builder/use-builder-session.d.ts +30 -0
- package/dist/app/builder/use-builder-session.d.ts.map +1 -0
- package/dist/app/builder/use-builder-session.js +58 -0
- package/dist/app/builder/use-builder-session.js.map +1 -0
- package/dist/app/builder/use-display-resolver.d.ts +17 -0
- package/dist/app/builder/use-display-resolver.d.ts.map +1 -0
- package/dist/app/builder/use-display-resolver.js +16 -0
- package/dist/app/builder/use-display-resolver.js.map +1 -0
- package/dist/app/index.d.ts +8 -3
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +8 -2
- package/dist/app/index.js.map +1 -1
- package/dist/app/layout-builder.d.ts +13 -25
- package/dist/app/layout-builder.d.ts.map +1 -1
- package/dist/app/layout-builder.js +75 -81
- package/dist/app/layout-builder.js.map +1 -1
- package/dist/app/mcp-app-view-chrome.d.ts +12 -3
- package/dist/app/mcp-app-view-chrome.d.ts.map +1 -1
- package/dist/app/mcp-app-view-chrome.js +21 -5
- package/dist/app/mcp-app-view-chrome.js.map +1 -1
- package/dist/app/mcp-app-view-states.d.ts +11 -3
- package/dist/app/mcp-app-view-states.d.ts.map +1 -1
- package/dist/app/mcp-app-view-states.js +29 -8
- package/dist/app/mcp-app-view-states.js.map +1 -1
- package/dist/app/mcp-app-view.d.ts +33 -1
- package/dist/app/mcp-app-view.d.ts.map +1 -1
- package/dist/app/mcp-app-view.js +181 -127
- package/dist/app/mcp-app-view.js.map +1 -1
- package/dist/app/mcp-toolkit-app.d.ts +6 -1
- package/dist/app/mcp-toolkit-app.d.ts.map +1 -1
- package/dist/app/mcp-toolkit-app.js +8 -1
- package/dist/app/mcp-toolkit-app.js.map +1 -1
- package/dist/app/mcp-use-host-bridge.d.ts +15 -6
- package/dist/app/mcp-use-host-bridge.d.ts.map +1 -1
- package/dist/app/mcp-use-host-bridge.js +51 -22
- package/dist/app/mcp-use-host-bridge.js.map +1 -1
- package/dist/app/model-context.d.ts +71 -0
- package/dist/app/model-context.d.ts.map +1 -0
- package/dist/app/model-context.js +101 -0
- package/dist/app/model-context.js.map +1 -0
- package/dist/app/use-build-mode-focus.d.ts +33 -0
- package/dist/app/use-build-mode-focus.d.ts.map +1 -0
- package/dist/app/use-build-mode-focus.js +59 -0
- package/dist/app/use-build-mode-focus.js.map +1 -0
- package/dist/app/use-view-actions.d.ts +31 -0
- package/dist/app/use-view-actions.d.ts.map +1 -0
- package/dist/app/use-view-actions.js +81 -0
- package/dist/app/use-view-actions.js.map +1 -0
- package/dist/app/use-view-lifecycle.d.ts +4 -0
- package/dist/app/use-view-lifecycle.d.ts.map +1 -1
- package/dist/app/use-view-lifecycle.js +2 -0
- package/dist/app/use-view-lifecycle.js.map +1 -1
- package/dist/app/view-lifecycle.d.ts +80 -4
- package/dist/app/view-lifecycle.d.ts.map +1 -1
- package/dist/app/view-lifecycle.js +90 -3
- package/dist/app/view-lifecycle.js.map +1 -1
- package/dist/app/widget-fixture.d.ts +10 -3
- package/dist/app/widget-fixture.d.ts.map +1 -1
- package/dist/app/widget-fixture.js +21 -8
- package/dist/app/widget-fixture.js.map +1 -1
- package/dist/app/widget-props-sheet.d.ts +3 -1
- package/dist/app/widget-props-sheet.d.ts.map +1 -1
- package/dist/app/widget-props-sheet.js +22 -10
- package/dist/app/widget-props-sheet.js.map +1 -1
- package/dist/app/widget-renderer.js +1 -1
- package/dist/app/widget-renderer.js.map +1 -1
- package/dist/components/DrillButton.d.ts +9 -6
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +8 -6
- package/dist/components/DrillButton.js.map +1 -1
- package/dist/components/EmptyState.d.ts.map +1 -1
- package/dist/components/EmptyState.js +4 -2
- package/dist/components/EmptyState.js.map +1 -1
- package/dist/components/ErrorState.d.ts +5 -2
- package/dist/components/ErrorState.d.ts.map +1 -1
- package/dist/components/ErrorState.js +25 -8
- package/dist/components/ErrorState.js.map +1 -1
- package/dist/components/FilterBar.d.ts +15 -4
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +11 -6
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/KpiGrid.d.ts +66 -21
- package/dist/components/KpiGrid.d.ts.map +1 -1
- package/dist/components/KpiGrid.js +101 -34
- package/dist/components/KpiGrid.js.map +1 -1
- package/dist/components/ListFooter.d.ts +31 -4
- package/dist/components/ListFooter.d.ts.map +1 -1
- package/dist/components/ListFooter.js +30 -11
- package/dist/components/ListFooter.js.map +1 -1
- package/dist/components/PagedListFooter.d.ts +57 -0
- package/dist/components/PagedListFooter.d.ts.map +1 -0
- package/dist/components/PagedListFooter.js +27 -0
- package/dist/components/PagedListFooter.js.map +1 -0
- package/dist/components/Pills.d.ts +27 -7
- package/dist/components/Pills.d.ts.map +1 -1
- package/dist/components/Pills.js +21 -6
- package/dist/components/Pills.js.map +1 -1
- package/dist/components/SectionHeading.d.ts +3 -1
- package/dist/components/SectionHeading.d.ts.map +1 -1
- package/dist/components/SectionHeading.js +6 -2
- package/dist/components/SectionHeading.js.map +1 -1
- package/dist/components/Toast.d.ts +9 -3
- package/dist/components/Toast.d.ts.map +1 -1
- package/dist/components/Toast.js +31 -16
- package/dist/components/Toast.js.map +1 -1
- package/dist/components/WidgetHeader.d.ts +34 -10
- package/dist/components/WidgetHeader.d.ts.map +1 -1
- package/dist/components/WidgetHeader.js +11 -6
- package/dist/components/WidgetHeader.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -4
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/tool-result-recovery-model.d.ts +155 -0
- package/dist/hooks/tool-result-recovery-model.d.ts.map +1 -0
- package/dist/hooks/tool-result-recovery-model.js +273 -0
- package/dist/hooks/tool-result-recovery-model.js.map +1 -0
- package/dist/hooks/use-tool-query.d.ts +56 -14
- package/dist/hooks/use-tool-query.d.ts.map +1 -1
- package/dist/hooks/use-tool-query.js +102 -25
- package/dist/hooks/use-tool-query.js.map +1 -1
- package/dist/hooks/use-tool-result-recovery.d.ts +47 -52
- package/dist/hooks/use-tool-result-recovery.d.ts.map +1 -1
- package/dist/hooks/use-tool-result-recovery.js +58 -120
- package/dist/hooks/use-tool-result-recovery.js.map +1 -1
- package/dist/hooks/use-view-data.d.ts +52 -10
- package/dist/hooks/use-view-data.d.ts.map +1 -1
- package/dist/hooks/use-view-data.js +81 -16
- package/dist/hooks/use-view-data.js.map +1 -1
- package/dist/hooks/use-view-tool-query.d.ts +12 -7
- package/dist/hooks/use-view-tool-query.d.ts.map +1 -1
- package/dist/hooks/use-view-tool-query.js +15 -14
- package/dist/hooks/use-view-tool-query.js.map +1 -1
- package/dist/index.d.ts +17 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-return.d.ts +61 -0
- package/dist/lib/focus-return.d.ts.map +1 -0
- package/dist/lib/focus-return.js +46 -0
- package/dist/lib/focus-return.js.map +1 -0
- package/dist/lib/parse-tool-result.d.ts +23 -1
- package/dist/lib/parse-tool-result.d.ts.map +1 -1
- package/dist/lib/parse-tool-result.js +38 -2
- package/dist/lib/parse-tool-result.js.map +1 -1
- package/dist/lib/toast-model.d.ts +41 -1
- package/dist/lib/toast-model.d.ts.map +1 -1
- package/dist/lib/toast-model.js +67 -1
- package/dist/lib/toast-model.js.map +1 -1
- package/dist/lib/tone-utils.d.ts +58 -6
- package/dist/lib/tone-utils.d.ts.map +1 -1
- package/dist/lib/tone-utils.js +66 -4
- package/dist/lib/tone-utils.js.map +1 -1
- package/dist/lib/tool-query.d.ts +107 -0
- package/dist/lib/tool-query.d.ts.map +1 -0
- package/dist/lib/tool-query.js +182 -0
- package/dist/lib/tool-query.js.map +1 -0
- package/dist/lib/tool-result-observer.d.ts +108 -0
- package/dist/lib/tool-result-observer.d.ts.map +1 -0
- package/dist/lib/tool-result-observer.js +158 -0
- package/dist/lib/tool-result-observer.js.map +1 -0
- package/dist/lib/typography.d.ts +37 -0
- package/dist/lib/typography.d.ts.map +1 -0
- package/dist/lib/typography.js +41 -0
- package/dist/lib/typography.js.map +1 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +11 -1
- package/dist/lib/utils.js.map +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.d.ts.map +1 -1
- package/dist/locale/message-types.d.ts +117 -4
- package/dist/locale/message-types.d.ts.map +1 -1
- package/dist/locale/messages-de.d.ts.map +1 -1
- package/dist/locale/messages-de.js +44 -10
- package/dist/locale/messages-de.js.map +1 -1
- package/dist/locale/messages-en.d.ts.map +1 -1
- package/dist/locale/messages-en.js +44 -10
- package/dist/locale/messages-en.js.map +1 -1
- package/dist/locale/toolkit-messages.d.ts +19 -1
- package/dist/locale/toolkit-messages.d.ts.map +1 -1
- package/dist/locale/toolkit-messages.js +54 -1
- package/dist/locale/toolkit-messages.js.map +1 -1
- package/dist/primitives/alert.d.ts +1 -1
- package/dist/primitives/alert.d.ts.map +1 -1
- package/dist/primitives/alert.js +2 -0
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/badge.d.ts +1 -1
- package/dist/primitives/badge.d.ts.map +1 -1
- package/dist/primitives/badge.js +9 -4
- package/dist/primitives/badge.js.map +1 -1
- package/dist/primitives/button.d.ts +1 -1
- package/dist/primitives/button.js +1 -1
- package/dist/primitives/button.js.map +1 -1
- package/dist/primitives/dialog.d.ts +1 -1
- package/dist/primitives/dialog.d.ts.map +1 -1
- package/dist/primitives/dialog.js +10 -3
- package/dist/primitives/dialog.js.map +1 -1
- package/dist/primitives/select.js +1 -1
- package/dist/primitives/select.js.map +1 -1
- package/dist/primitives/sheet.d.ts +1 -1
- package/dist/primitives/sheet.d.ts.map +1 -1
- package/dist/primitives/sheet.js +6 -2
- package/dist/primitives/sheet.js.map +1 -1
- package/dist/providers/query-provider.d.ts +32 -1
- package/dist/providers/query-provider.d.ts.map +1 -1
- package/dist/providers/query-provider.js +53 -4
- package/dist/providers/query-provider.js.map +1 -1
- package/dist/render-cases-primitives.js +1 -1
- package/dist/render-cases-primitives.js.map +1 -1
- package/dist/render-cases.d.ts.map +1 -1
- package/dist/render-cases.js +30 -6
- package/dist/render-cases.js.map +1 -1
- package/dist/render-effects.d.ts +42 -0
- package/dist/render-effects.d.ts.map +1 -0
- package/dist/render-effects.js +81 -0
- package/dist/render-effects.js.map +1 -0
- package/dist/theme/create-theme.d.ts +19 -1
- package/dist/theme/create-theme.d.ts.map +1 -1
- package/dist/theme/create-theme.js +15 -0
- package/dist/theme/create-theme.js.map +1 -1
- package/package.json +5 -4
- package/src/app/adapt-data-widget.tsx +2 -42
- package/src/app/builder/BuilderBanners.tsx +1 -1
- package/src/app/builder/CanvasRow.tsx +63 -17
- package/src/app/builder/CatalogueSheet.tsx +50 -29
- package/src/app/builder/CellPropsSheet.tsx +17 -2
- package/src/app/builder/DiscardDialog.tsx +59 -0
- package/src/app/builder/LayoutSurface.tsx +206 -66
- package/src/app/builder/PipelineStrip.tsx +253 -153
- package/src/app/builder/SaveDialog.tsx +14 -2
- package/src/app/builder/Toolbar.tsx +144 -59
- package/src/app/builder/Workspace.tsx +226 -58
- package/src/app/builder/builder-model.ts +341 -2
- package/src/app/builder/builder-model.ux.test.ts +456 -0
- package/src/app/builder/builder-ux.test.tsx +844 -0
- package/src/app/builder/reveal.ts +10 -0
- package/src/app/builder/use-builder-catalogue.ts +56 -19
- package/src/app/builder/use-builder-session.ts +68 -0
- package/src/app/builder/use-display-resolver.ts +24 -0
- package/src/app/index.ts +12 -2
- package/src/app/layout-builder.tsx +178 -191
- package/src/app/mcp-app-view-chrome.test.tsx +141 -1
- package/src/app/mcp-app-view-chrome.tsx +56 -6
- package/src/app/mcp-app-view-states.tsx +51 -11
- package/src/app/mcp-app-view.effects.test.tsx +292 -0
- package/src/app/mcp-app-view.test.tsx +222 -2
- package/src/app/mcp-app-view.tsx +267 -117
- package/src/app/mcp-toolkit-app.test.tsx +40 -0
- package/src/app/mcp-toolkit-app.tsx +8 -1
- package/src/app/mcp-use-host-bridge.test.tsx +180 -0
- package/src/app/mcp-use-host-bridge.tsx +67 -24
- package/src/app/model-context.test.tsx +116 -0
- package/src/app/model-context.tsx +125 -0
- package/src/app/use-build-mode-focus.ts +62 -0
- package/src/app/use-view-actions.ts +89 -0
- package/src/app/use-view-lifecycle.ts +6 -0
- package/src/app/view-lifecycle.test.ts +216 -0
- package/src/app/view-lifecycle.ts +165 -5
- package/src/app/widget-fixture.effects.test.tsx +51 -0
- package/src/app/widget-fixture.render.test.tsx +81 -0
- package/src/app/widget-fixture.tsx +34 -11
- package/src/app/widget-props-sheet.tsx +57 -16
- package/src/app/widget-renderer.tsx +1 -1
- package/src/components/DrillButton.tsx +23 -10
- package/src/components/EmptyState.tsx +6 -4
- package/src/components/ErrorState.tsx +31 -9
- package/src/components/FilterBar.test.tsx +82 -0
- package/src/components/FilterBar.tsx +52 -26
- package/src/components/KpiGrid.test.tsx +279 -0
- package/src/components/KpiGrid.tsx +297 -90
- package/src/components/ListFooter.test.tsx +116 -0
- package/src/components/ListFooter.tsx +102 -15
- package/src/components/PagedListFooter.test.tsx +140 -0
- package/src/components/PagedListFooter.tsx +107 -0
- package/src/components/Pills.test.tsx +77 -0
- package/src/components/Pills.tsx +63 -10
- package/src/components/SectionHeading.tsx +9 -3
- package/src/components/Toast.tsx +37 -19
- package/src/components/WidgetHeader.test.tsx +78 -0
- package/src/components/WidgetHeader.tsx +63 -16
- package/src/components/feedback-states.test.tsx +98 -9
- package/src/components/tone-figures.test.tsx +8 -4
- package/src/hooks/index.ts +6 -3
- package/src/hooks/tool-result-recovery-model.test.ts +744 -0
- package/src/hooks/tool-result-recovery-model.ts +418 -0
- package/src/hooks/use-tool-query.effects.test.tsx +205 -0
- package/src/hooks/use-tool-query.ts +156 -33
- package/src/hooks/use-tool-result-recovery.effects.test.tsx +141 -0
- package/src/hooks/use-tool-result-recovery.test.ts +3 -4
- package/src/hooks/use-tool-result-recovery.ts +84 -142
- package/src/hooks/use-view-data.test.tsx +118 -9
- package/src/hooks/use-view-data.ts +128 -18
- package/src/hooks/use-view-tool-query.ts +25 -19
- package/src/i18n-coverage.test.tsx +104 -2
- package/src/index.ts +48 -11
- package/src/lib/focus-return.test.ts +288 -0
- package/src/lib/focus-return.ts +96 -0
- package/src/lib/parse-tool-result.test.ts +46 -1
- package/src/lib/parse-tool-result.ts +40 -2
- package/src/lib/toast-model.test.ts +166 -1
- package/src/lib/toast-model.ts +104 -2
- package/src/lib/tone-utils.test.ts +86 -10
- package/src/lib/tone-utils.ts +89 -8
- package/src/lib/tool-query.test.ts +569 -0
- package/src/lib/tool-query.ts +253 -0
- package/src/lib/tool-result-observer.test.ts +296 -0
- package/src/lib/tool-result-observer.ts +211 -0
- package/src/lib/typography.test.ts +45 -0
- package/src/lib/typography.ts +47 -0
- package/src/lib/utils.ts +12 -1
- package/src/locale/index.ts +1 -0
- package/src/locale/message-types.ts +118 -2
- package/src/locale/messages-de.ts +49 -10
- package/src/locale/messages-en.ts +49 -10
- package/src/locale/toolkit-messages.test.ts +133 -0
- package/src/locale/toolkit-messages.ts +63 -2
- package/src/primitives/alert.tsx +3 -0
- package/src/primitives/badge.tsx +9 -4
- package/src/primitives/button.tsx +1 -1
- package/src/primitives/dialog.tsx +10 -1
- package/src/primitives/select.tsx +1 -1
- package/src/primitives/sheet.tsx +7 -0
- package/src/providers/query-provider.test.tsx +154 -0
- package/src/providers/query-provider.tsx +66 -5
- package/src/render-cases-primitives.tsx +1 -1
- package/src/render-cases.tsx +38 -6
- package/src/render-effects.tsx +96 -0
- package/src/theme/cd-tokens.test.ts +3 -1
- package/src/theme/create-theme.test.ts +10 -0
- package/src/theme/create-theme.ts +28 -0
- package/src/theme/theme-css.test.ts +51 -0
- package/src/theme.css +31 -1
- package/ui-catalog.json +448 -41
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stripped-result recovery of `McpAppView` as plain logic (toolkit#176):
|
|
3
|
+
* WHEN the originating tool may be re-executed ({@link decideRecovery}) and a
|
|
4
|
+
* framework-free controller that runs that decision against the transport
|
|
5
|
+
* observer, a fallback grace timer and a single-flight call
|
|
6
|
+
* ({@link createRecoveryController}). `useToolResultRecovery` only binds the
|
|
7
|
+
* controller to React, so the scenarios here (keep / strip / slow / late
|
|
8
|
+
* observer) are the hook's behaviour.
|
|
9
|
+
*
|
|
10
|
+
* The rule, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. Never recover a call that was cancelled or failed: the view renders that
|
|
13
|
+
* state (an `isError` is an error, K14).
|
|
14
|
+
* 2. Recover when the host delivered a result the view cannot render: the
|
|
15
|
+
* latch fired with an invalid payload, or the observer saw the stripped,
|
|
16
|
+
* content-only result claude.ai sends. The re-run uses the COMPLETE
|
|
17
|
+
* arguments of the call (N21), never a partial stream.
|
|
18
|
+
* 3. Otherwise wait. A slow tool on a compliant host is simply still running
|
|
19
|
+
* (K13): the observer, listening since the handshake, would have seen its
|
|
20
|
+
* result. Only when it cannot vouch for that (no observer, or installed
|
|
21
|
+
* after the handshake) does the 2.x grace timer still infer a stripped
|
|
22
|
+
* result, and only once the call's arguments arrived.
|
|
23
|
+
* 4. A needed recovery runs only for a tool the policy allows
|
|
24
|
+
* ({@link RecoveryPolicy}: by default never one the host reports as
|
|
25
|
+
* writing) and only when the host names the tool; else it is `blocked`,
|
|
26
|
+
* which the view renders as an error whose retry is the user's own call.
|
|
27
|
+
*/
|
|
28
|
+
import { parseToolResult } from "../lib/parse-tool-result.js"
|
|
29
|
+
import type { ToolObservation, ToolResultObserver } from "../lib/tool-result-observer.js"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the host's tool annotations say about re-running the originating
|
|
33
|
+
* tool: `readOnlyHint: true` is `"read-only"`, `readOnlyHint: false` or
|
|
34
|
+
* `destructiveHint: true` is `"writes"`, anything else (no tool definition,
|
|
35
|
+
* no annotations, no read-only hint) is `"unknown"`.
|
|
36
|
+
*/
|
|
37
|
+
export type ToolSafety = "read-only" | "writes" | "unknown"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which tools the view may re-execute on its own to recover a stripped result.
|
|
41
|
+
*
|
|
42
|
+
* - `"auto"` (default): any tool the host does not report as writing — a
|
|
43
|
+
* view tool is a read by the toolkit's render contract, so a missing hint
|
|
44
|
+
* counts as one.
|
|
45
|
+
* - `"read-only"`: only a tool the host reports with `readOnlyHint: true`.
|
|
46
|
+
* - `"never"`: never; a stripped result shows the error state, whose retry
|
|
47
|
+
* runs the tool on the user's click.
|
|
48
|
+
*/
|
|
49
|
+
export type RecoveryPolicy = "auto" | "read-only" | "never"
|
|
50
|
+
|
|
51
|
+
/** Why a needed recovery did not run: the policy forbids it, or the host names no tool. */
|
|
52
|
+
export type RecoveryBlockReason = "policy" | "no-tool"
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read {@link ToolSafety} from `hostContext.toolInfo.tool.annotations` (SEP-1865). */
|
|
59
|
+
export function resolveToolSafety(hostContext: unknown): ToolSafety {
|
|
60
|
+
if (!isRecord(hostContext) || !isRecord(hostContext.toolInfo)) return "unknown"
|
|
61
|
+
const tool = hostContext.toolInfo.tool
|
|
62
|
+
const annotations = isRecord(tool) ? tool.annotations : undefined
|
|
63
|
+
if (!isRecord(annotations)) return "unknown"
|
|
64
|
+
if (annotations.destructiveHint === true || annotations.readOnlyHint === false) return "writes"
|
|
65
|
+
return annotations.readOnlyHint === true ? "read-only" : "unknown"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether `policy` lets the view re-execute a tool of this `safety` on its own. */
|
|
69
|
+
export function policyAllows(policy: RecoveryPolicy, safety: ToolSafety): boolean {
|
|
70
|
+
if (policy === "never") return false
|
|
71
|
+
if (policy === "read-only") return safety === "read-only"
|
|
72
|
+
return safety !== "writes"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RecoveryDecisionInput {
|
|
76
|
+
/** The latch fired (`useToolContext().status !== "pending"`). */
|
|
77
|
+
resultReady: boolean
|
|
78
|
+
/** The latched payload is renderable (a conforming host delivered it). */
|
|
79
|
+
payloadValid: boolean
|
|
80
|
+
/** The host cancelled the call. */
|
|
81
|
+
cancelled: boolean
|
|
82
|
+
/** The call latched a tool error (an `isError` result or a failed call). */
|
|
83
|
+
errored: boolean
|
|
84
|
+
/** What the transport observer saw; `null` when none listens. */
|
|
85
|
+
observation: ToolObservation | null
|
|
86
|
+
/** The call's arguments arrived (any tool-input, partial or complete). */
|
|
87
|
+
hasInput: boolean
|
|
88
|
+
/** The originating tool (`hostContext.toolInfo`), when the host names it. */
|
|
89
|
+
toolName: string | undefined
|
|
90
|
+
safety: ToolSafety
|
|
91
|
+
policy: RecoveryPolicy
|
|
92
|
+
/** The fallback grace period passed for the current arguments. */
|
|
93
|
+
graceElapsed: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type RecoveryDecision =
|
|
97
|
+
/** Delivered, failed or cancelled: nothing to recover. */
|
|
98
|
+
| { action: "none" }
|
|
99
|
+
/** The result is not in yet; `armGrace` starts the fallback timer (observer cannot vouch). */
|
|
100
|
+
| { action: "wait"; armGrace: boolean }
|
|
101
|
+
/** Re-execute the originating tool now. */
|
|
102
|
+
| { action: "recover" }
|
|
103
|
+
/** A recovery is needed but must not run. */
|
|
104
|
+
| { action: "blocked"; reason: RecoveryBlockReason }
|
|
105
|
+
|
|
106
|
+
const NONE: RecoveryDecision = { action: "none" }
|
|
107
|
+
const WAIT: RecoveryDecision = { action: "wait", armGrace: false }
|
|
108
|
+
const WAIT_ARMED: RecoveryDecision = { action: "wait", armGrace: true }
|
|
109
|
+
const RECOVER: RecoveryDecision = { action: "recover" }
|
|
110
|
+
|
|
111
|
+
/** Rules 1–3: does the result need recovering, or is it delivered / still on its way? */
|
|
112
|
+
function recoveryNeed(input: RecoveryDecisionInput): RecoveryDecision | "needed" {
|
|
113
|
+
if (input.resultReady) return input.payloadValid ? NONE : "needed"
|
|
114
|
+
const observed = input.observation?.result ?? "none"
|
|
115
|
+
if (observed === "stripped") return "needed"
|
|
116
|
+
// A structured result or an error: the latch is about to follow.
|
|
117
|
+
if (observed !== "none") return NONE
|
|
118
|
+
// Listening since the handshake: nothing arrived because nothing was sent.
|
|
119
|
+
if (input.observation?.sawHandshake) return WAIT
|
|
120
|
+
if (input.toolName === undefined || !input.hasInput) return WAIT
|
|
121
|
+
return input.graceElapsed ? "needed" : WAIT_ARMED
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The recovery rule (see the module comment). Pure: every input is explicit. */
|
|
125
|
+
export function decideRecovery(input: RecoveryDecisionInput): RecoveryDecision {
|
|
126
|
+
if (input.cancelled || input.errored || input.observation?.cancelled) return NONE
|
|
127
|
+
const need = recoveryNeed(input)
|
|
128
|
+
if (need !== "needed") return need
|
|
129
|
+
if (input.toolName === undefined) return { action: "blocked", reason: "no-tool" }
|
|
130
|
+
if (!policyAllows(input.policy, input.safety)) return { action: "blocked", reason: "policy" }
|
|
131
|
+
return RECOVER
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The arguments a re-execution runs with: the complete tool-input the
|
|
136
|
+
* observer saw, else the view runtime's latest input, else none.
|
|
137
|
+
*/
|
|
138
|
+
export function recoveryArgs(
|
|
139
|
+
observation: ToolObservation | null,
|
|
140
|
+
toolInput: Record<string, unknown> | null | undefined,
|
|
141
|
+
): Record<string, unknown> {
|
|
142
|
+
return observation?.completeInput ?? toolInput ?? {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Content identity of a JSON value, independent of key order and object
|
|
147
|
+
* identity: a re-delivered equal input is the same input (N21).
|
|
148
|
+
*/
|
|
149
|
+
export function stableKey(value: unknown): string {
|
|
150
|
+
return (
|
|
151
|
+
JSON.stringify(value, (_key, v: unknown) =>
|
|
152
|
+
isRecord(v)
|
|
153
|
+
? Object.fromEntries(
|
|
154
|
+
Object.keys(v)
|
|
155
|
+
.sort()
|
|
156
|
+
.map((k) => [k, v[k]]),
|
|
157
|
+
)
|
|
158
|
+
: v,
|
|
159
|
+
) ?? "undefined"
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Decodes a re-executed tool result and validates it: the payload, or `null`
|
|
165
|
+
* for an error result, an empty one or one the validator refuses.
|
|
166
|
+
*/
|
|
167
|
+
export function decodeRecoveredResult<T>(
|
|
168
|
+
result: unknown,
|
|
169
|
+
isValid: (value: unknown) => boolean,
|
|
170
|
+
): T | null {
|
|
171
|
+
let decoded: unknown
|
|
172
|
+
try {
|
|
173
|
+
decoded = parseToolResult<unknown>(result)
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
return decoded !== null && isValid(decoded) ? (decoded as T) : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ToolResultRecoveryState<T> {
|
|
181
|
+
/** The recovered payload, `null` until the re-execution resolved (or when it failed). */
|
|
182
|
+
data: T | null
|
|
183
|
+
/** The single recovery attempt failed — the view shows its error state, no retry loop. */
|
|
184
|
+
failed: boolean
|
|
185
|
+
/**
|
|
186
|
+
* A recovery was needed but did not run: the policy forbids re-executing
|
|
187
|
+
* this tool, or the host names no tool. `null` otherwise.
|
|
188
|
+
*/
|
|
189
|
+
blocked: RecoveryBlockReason | null
|
|
190
|
+
/** When the recovered payload arrived (epoch ms) — its data is live as of then (N23). */
|
|
191
|
+
recoveredAt: number | null
|
|
192
|
+
/** The arguments the recovered payload was computed for. */
|
|
193
|
+
args: Record<string, unknown> | null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const RECOVERY_IDLE: ToolResultRecoveryState<never> = Object.freeze({
|
|
197
|
+
data: null,
|
|
198
|
+
failed: false,
|
|
199
|
+
blocked: null,
|
|
200
|
+
recoveredAt: null,
|
|
201
|
+
args: null,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
/** What the view knows about the rendering call on each render. */
|
|
205
|
+
export interface RecoveryInputs {
|
|
206
|
+
resultReady: boolean
|
|
207
|
+
cancelled: boolean
|
|
208
|
+
errored: boolean
|
|
209
|
+
/** The payload the host delivered (`useToolContext().toolOutput`). */
|
|
210
|
+
props: unknown
|
|
211
|
+
isValid: (value: unknown) => boolean
|
|
212
|
+
toolInput: Record<string, unknown> | null | undefined
|
|
213
|
+
/** Raw host context; `toolInfo` names the originating tool and its annotations. */
|
|
214
|
+
hostContext: Record<string, unknown> | null | undefined
|
|
215
|
+
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>
|
|
216
|
+
policy: RecoveryPolicy
|
|
217
|
+
/** The fallback grace period (observer cannot vouch). */
|
|
218
|
+
graceMs: number
|
|
219
|
+
/** Resolves the originating tool name from the host context. */
|
|
220
|
+
resolveToolName: (hostContext: unknown) => string | undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface RecoveryController<T> {
|
|
224
|
+
subscribe: (listener: () => void) => () => void
|
|
225
|
+
getState: () => ToolResultRecoveryState<T>
|
|
226
|
+
/** Feed the latest inputs (every commit) and act on the decision. */
|
|
227
|
+
update: (inputs: RecoveryInputs) => void
|
|
228
|
+
/** Listen to the observer (mount). */
|
|
229
|
+
start: () => void
|
|
230
|
+
/** Stop listening and drop the grace timer; an in-flight call still lands (StrictMode re-runs). */
|
|
231
|
+
stop: () => void
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface RecoveryControllerOptions {
|
|
235
|
+
/** The transport observer; `null` when none listens (the grace timer decides alone). */
|
|
236
|
+
observer: ToolResultObserver | null
|
|
237
|
+
/** Clock for {@link ToolResultRecoveryState.recoveredAt}. Default `Date.now`. */
|
|
238
|
+
now?: () => number
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface Flight {
|
|
242
|
+
key: string
|
|
243
|
+
promise: Promise<unknown>
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One evaluation of the inputs: the decision plus the run it concerns. */
|
|
247
|
+
interface Evaluation {
|
|
248
|
+
decision: RecoveryDecision
|
|
249
|
+
toolName: string | undefined
|
|
250
|
+
args: Record<string, unknown>
|
|
251
|
+
/** Content identity of `args` (the grace timer's key). */
|
|
252
|
+
argsKey: string
|
|
253
|
+
/** Identity of the run: the tool and its arguments (the single flight's key). */
|
|
254
|
+
runKey: string
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Run {@link decideRecovery} on the controller's current knowledge. */
|
|
258
|
+
export function evaluateRecovery(
|
|
259
|
+
inputs: RecoveryInputs,
|
|
260
|
+
observation: ToolObservation | null,
|
|
261
|
+
grace: { key: string | null; elapsed: boolean },
|
|
262
|
+
): Evaluation {
|
|
263
|
+
const toolName = inputs.resolveToolName(inputs.hostContext)
|
|
264
|
+
const args = recoveryArgs(observation, inputs.toolInput)
|
|
265
|
+
const argsKey = stableKey(args)
|
|
266
|
+
const decision = decideRecovery({
|
|
267
|
+
resultReady: inputs.resultReady,
|
|
268
|
+
payloadValid: inputs.isValid(inputs.props),
|
|
269
|
+
cancelled: inputs.cancelled,
|
|
270
|
+
errored: inputs.errored,
|
|
271
|
+
observation,
|
|
272
|
+
hasInput: inputs.toolInput != null || observation?.completeInput !== undefined,
|
|
273
|
+
toolName,
|
|
274
|
+
safety: resolveToolSafety(inputs.hostContext),
|
|
275
|
+
policy: inputs.policy,
|
|
276
|
+
graceElapsed: grace.elapsed && grace.key === argsKey,
|
|
277
|
+
})
|
|
278
|
+
return { decision, toolName, args, argsKey, runKey: `${toolName ?? ""}\u0000${argsKey}` }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Re-execute the tool and decode its answer; `null` for anything unusable. */
|
|
282
|
+
async function rerun<T>(
|
|
283
|
+
callTool: RecoveryInputs["callTool"],
|
|
284
|
+
isValid: RecoveryInputs["isValid"],
|
|
285
|
+
toolName: string,
|
|
286
|
+
args: Record<string, unknown>,
|
|
287
|
+
): Promise<T | null> {
|
|
288
|
+
try {
|
|
289
|
+
const data = decodeRecoveredResult<T>(await callTool(toolName, args), isValid)
|
|
290
|
+
if (data === null) {
|
|
291
|
+
console.warn(
|
|
292
|
+
`[mcp-toolkit] tool-result recovery: re-executing "${toolName}" returned no usable payload`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
return data
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn(`[mcp-toolkit] tool-result recovery for "${toolName}" failed:`, err)
|
|
298
|
+
return null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Runs {@link decideRecovery} for the view: re-evaluates on every input
|
|
304
|
+
* update and observer change, arms the fallback grace timer per argument set,
|
|
305
|
+
* and makes at most one call per run (single flight, keyed by the tool and
|
|
306
|
+
* its arguments; a newer run drops an older call's outcome).
|
|
307
|
+
*/
|
|
308
|
+
export function createRecoveryController<T>(
|
|
309
|
+
options: RecoveryControllerOptions,
|
|
310
|
+
): RecoveryController<T> {
|
|
311
|
+
const { observer } = options
|
|
312
|
+
const now = options.now ?? Date.now
|
|
313
|
+
const listeners = new Set<() => void>()
|
|
314
|
+
let state: ToolResultRecoveryState<T> = RECOVERY_IDLE
|
|
315
|
+
let inputs: RecoveryInputs | null = null
|
|
316
|
+
let unsubscribe: (() => void) | null = null
|
|
317
|
+
let flight: Flight | null = null
|
|
318
|
+
// The fallback grace timer: armed per argument set, elapsed once it fired.
|
|
319
|
+
const grace: { key: string | null; elapsed: boolean } = { key: null, elapsed: false }
|
|
320
|
+
let graceTimer: ReturnType<typeof setTimeout> | null = null
|
|
321
|
+
|
|
322
|
+
const setState = (next: ToolResultRecoveryState<T>) => {
|
|
323
|
+
if (next === state) return
|
|
324
|
+
state = next
|
|
325
|
+
for (const listener of [...listeners]) listener()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const clearGraceTimer = () => {
|
|
329
|
+
if (graceTimer !== null) clearTimeout(graceTimer)
|
|
330
|
+
graceTimer = null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const armGrace = (key: string, ms: number) => {
|
|
334
|
+
if (grace.key === key && (grace.elapsed || graceTimer !== null)) return
|
|
335
|
+
clearGraceTimer()
|
|
336
|
+
grace.key = key
|
|
337
|
+
grace.elapsed = false
|
|
338
|
+
graceTimer = setTimeout(() => {
|
|
339
|
+
graceTimer = null
|
|
340
|
+
grace.elapsed = true
|
|
341
|
+
evaluate()
|
|
342
|
+
}, ms)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const launch = (current: RecoveryInputs, run: Evaluation, toolName: string) => {
|
|
346
|
+
const own: Flight = {
|
|
347
|
+
key: run.runKey,
|
|
348
|
+
promise: rerun<T>(current.callTool, current.isValid, toolName, run.args),
|
|
349
|
+
}
|
|
350
|
+
flight = own
|
|
351
|
+
void own.promise.then((data) => {
|
|
352
|
+
// A newer run superseded this call: its outcome is stale.
|
|
353
|
+
if (flight !== own) return
|
|
354
|
+
setState(
|
|
355
|
+
data === null
|
|
356
|
+
? { ...RECOVERY_IDLE, failed: true, args: run.args }
|
|
357
|
+
: { data: data as T, failed: false, blocked: null, recoveredAt: now(), args: run.args },
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Act on one evaluation: drop a superseded attempt, launch, block or settle. */
|
|
363
|
+
const apply = (current: RecoveryInputs, run: Evaluation) => {
|
|
364
|
+
const { decision } = run
|
|
365
|
+
// A new run (the need went away, or new arguments) drops the previous
|
|
366
|
+
// attempt and its outcome.
|
|
367
|
+
if (flight && (decision.action !== "recover" || flight.key !== run.runKey)) {
|
|
368
|
+
flight = null
|
|
369
|
+
setState(RECOVERY_IDLE)
|
|
370
|
+
}
|
|
371
|
+
if (decision.action === "blocked") {
|
|
372
|
+
if (state.blocked !== decision.reason) {
|
|
373
|
+
setState({ ...RECOVERY_IDLE, blocked: decision.reason })
|
|
374
|
+
}
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
// A lifted block (the tool got named, the policy changed) is gone at once,
|
|
378
|
+
// also while the re-run it allows is still in flight.
|
|
379
|
+
if (state.blocked !== null) setState(RECOVERY_IDLE)
|
|
380
|
+
if (decision.action === "recover" && !flight && run.toolName !== undefined) {
|
|
381
|
+
launch(current, run, run.toolName)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function evaluate() {
|
|
386
|
+
if (!inputs) return
|
|
387
|
+
const run = evaluateRecovery(inputs, observer?.getSnapshot() ?? null, grace)
|
|
388
|
+
if (run.decision.action === "wait" && run.decision.armGrace)
|
|
389
|
+
armGrace(run.argsKey, inputs.graceMs)
|
|
390
|
+
else clearGraceTimer()
|
|
391
|
+
apply(inputs, run)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
subscribe: (listener) => {
|
|
396
|
+
listeners.add(listener)
|
|
397
|
+
return () => {
|
|
398
|
+
listeners.delete(listener)
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
getState: () => state,
|
|
402
|
+
update: (next) => {
|
|
403
|
+
inputs = next
|
|
404
|
+
evaluate()
|
|
405
|
+
},
|
|
406
|
+
start: () => {
|
|
407
|
+
if (!unsubscribe && observer) unsubscribe = observer.subscribe(evaluate)
|
|
408
|
+
evaluate()
|
|
409
|
+
},
|
|
410
|
+
stop: () => {
|
|
411
|
+
unsubscribe?.()
|
|
412
|
+
unsubscribe = null
|
|
413
|
+
// A stopped timer restarts in full on the next start (armGrace re-arms
|
|
414
|
+
// a key whose timer is gone); an elapsed grace stays elapsed.
|
|
415
|
+
clearGraceTimer()
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import { QueryClient } from "@tanstack/react-query"
|
|
4
|
+
import { afterEach, describe, expect, it, vi, type Mock } from "vitest"
|
|
5
|
+
import { NoToolTransportError } from "../lib/tool-query.js"
|
|
6
|
+
import { AppQueryProvider } from "../providers/query-provider.js"
|
|
7
|
+
import { flushEffects, hookProbe, renderWithEffects } from "../render-effects.js"
|
|
8
|
+
import { useToolMutation, useToolQuery } from "./use-tool-query.js"
|
|
9
|
+
import { useViewData } from "./use-view-data.js"
|
|
10
|
+
import { useViewToolQuery } from "./use-view-tool-query.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The data hooks' wiring with their effects running (toolkit#176 K18, N25):
|
|
14
|
+
* the key the REAL query uses, that a seeded feed does not fetch on mount,
|
|
15
|
+
* that a late transport fetches, and `useToolMutation`'s per-call callbacks
|
|
16
|
+
* and invalidation. The pure parts are pinned in `lib/tool-query.test.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type CallTool = (name: string, args: object) => Promise<unknown>
|
|
20
|
+
|
|
21
|
+
const ARGS = { id: 1 }
|
|
22
|
+
const answer = (data: unknown) => ({ structuredContent: data })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A client per test that keeps its cache (no garbage collection). The probes
|
|
26
|
+
* read the result after the render, so every change re-renders them (a widget
|
|
27
|
+
* reads its fields during the render, which TanStack tracks).
|
|
28
|
+
*/
|
|
29
|
+
function testClient(): QueryClient {
|
|
30
|
+
return new QueryClient({
|
|
31
|
+
defaultOptions: { queries: { gcTime: Infinity, notifyOnChangeProps: "all" } },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function under(
|
|
36
|
+
client: QueryClient,
|
|
37
|
+
callTool: CallTool | undefined,
|
|
38
|
+
toolNameInQueryKey: boolean | undefined,
|
|
39
|
+
node: ReactNode,
|
|
40
|
+
) {
|
|
41
|
+
return (
|
|
42
|
+
<AppQueryProvider client={client} callTool={callTool} toolNameInQueryKey={toolNameInQueryKey}>
|
|
43
|
+
{node}
|
|
44
|
+
</AppQueryProvider>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The keys of every query in the client's cache. */
|
|
49
|
+
const cachedKeys = (client: QueryClient) =>
|
|
50
|
+
client
|
|
51
|
+
.getQueryCache()
|
|
52
|
+
.findAll()
|
|
53
|
+
.map((query) => query.queryKey)
|
|
54
|
+
|
|
55
|
+
let unmount: (() => Promise<void>) | null = null
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await unmount?.()
|
|
59
|
+
unmount = null
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
async function render(node: ReactNode) {
|
|
63
|
+
const view = await renderWithEffects(node)
|
|
64
|
+
unmount = view.unmount
|
|
65
|
+
return view
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("the query hooks' real cache keys (K18)", () => {
|
|
69
|
+
it("useToolQuery fetches under [...key, tool, args] with toolNameInQueryKey, else the 2.x key", async () => {
|
|
70
|
+
for (const [flag, key] of [
|
|
71
|
+
[true, ["feed", "feed_data", ARGS]],
|
|
72
|
+
[undefined, ["feed", ARGS]],
|
|
73
|
+
] as const) {
|
|
74
|
+
const client = testClient()
|
|
75
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 1 })))
|
|
76
|
+
const probe = hookProbe(() => useToolQuery(["feed"], "feed_data", ARGS))
|
|
77
|
+
await render(under(client, callTool, flag, <probe.Probe />))
|
|
78
|
+
await flushEffects()
|
|
79
|
+
expect(cachedKeys(client)).toEqual([key])
|
|
80
|
+
expect(client.getQueryData(key)).toEqual({ n: 1 })
|
|
81
|
+
expect(probe.current().data).toEqual({ n: 1 })
|
|
82
|
+
await unmount?.()
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("useViewToolQuery fetches under the same keys", async () => {
|
|
87
|
+
for (const [flag, key] of [
|
|
88
|
+
[true, ["view", "show_list", ARGS]],
|
|
89
|
+
[false, ["view", ARGS]],
|
|
90
|
+
] as const) {
|
|
91
|
+
const client = testClient()
|
|
92
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ rows: [] })))
|
|
93
|
+
const probe = hookProbe(() => useViewToolQuery(["view"], "show_list", ARGS))
|
|
94
|
+
await render(under(client, callTool, flag, <probe.Probe />))
|
|
95
|
+
await flushEffects()
|
|
96
|
+
expect(cachedKeys(client)).toEqual([key])
|
|
97
|
+
expect(callTool).toHaveBeenCalledWith("show_list", ARGS)
|
|
98
|
+
await unmount?.()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('a seeded feed ("seed" mode) is read from the key the seed wrote: no call on mount', async () => {
|
|
103
|
+
for (const flag of [true, false]) {
|
|
104
|
+
const client = testClient()
|
|
105
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 2 })))
|
|
106
|
+
const probe = hookProbe(() =>
|
|
107
|
+
useViewData<{ n: number }>({ n: 1 }, ["feed"], "feed_data", ARGS, true, {
|
|
108
|
+
initialDataMode: "seed",
|
|
109
|
+
staleTime: 60_000,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
await render(under(client, callTool, flag, <probe.Probe />))
|
|
113
|
+
await flushEffects()
|
|
114
|
+
expect(callTool, "the seed answers the mount").not.toHaveBeenCalled()
|
|
115
|
+
expect(cachedKeys(client)).toHaveLength(1)
|
|
116
|
+
expect(probe.current()).toMatchObject({ data: { n: 1 }, loading: false })
|
|
117
|
+
await unmount?.()
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("a tool transport that arrives after the first render (N25)", () => {
|
|
123
|
+
it("the query fetches once it arrives instead of keeping NoToolTransportError", async () => {
|
|
124
|
+
for (const useHook of [
|
|
125
|
+
() => useToolQuery<{ n: number }>(["late"], "late_data", ARGS),
|
|
126
|
+
() => useViewToolQuery<{ n: number }>(["late-view"], "late_view", ARGS),
|
|
127
|
+
]) {
|
|
128
|
+
const client = testClient()
|
|
129
|
+
const probe = hookProbe(useHook)
|
|
130
|
+
const view = await render(under(client, undefined, undefined, <probe.Probe />))
|
|
131
|
+
await flushEffects()
|
|
132
|
+
expect(probe.current().error).toBeInstanceOf(NoToolTransportError)
|
|
133
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ n: 3 })))
|
|
134
|
+
await view.rerender(under(client, callTool, undefined, <probe.Probe />))
|
|
135
|
+
await flushEffects()
|
|
136
|
+
expect(callTool).toHaveBeenCalledTimes(1)
|
|
137
|
+
expect(probe.current()).toMatchObject({ data: { n: 3 }, error: null })
|
|
138
|
+
await unmount?.()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("useToolMutation with its effects", () => {
|
|
144
|
+
type Mutation = ReturnType<typeof useToolMutation>
|
|
145
|
+
|
|
146
|
+
async function mountMutation(
|
|
147
|
+
client: QueryClient,
|
|
148
|
+
callTool: CallTool,
|
|
149
|
+
invalidateKeys?: unknown[][],
|
|
150
|
+
) {
|
|
151
|
+
const probe = hookProbe(() => useToolMutation("retry_incident", { invalidateKeys }))
|
|
152
|
+
const view = await render(under(client, callTool, undefined, <probe.Probe />))
|
|
153
|
+
return { view, mutation: (): Mutation => probe.current() }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
it("fires the per-call callbacks of EVERY call, not only the latest one", async () => {
|
|
157
|
+
const client = testClient()
|
|
158
|
+
const pending: ((value: unknown) => void)[] = []
|
|
159
|
+
const callTool = vi.fn<CallTool>(() => new Promise((resolve) => pending.push(resolve)))
|
|
160
|
+
const { mutation } = await mountMutation(client, callTool)
|
|
161
|
+
const first: Mock = vi.fn()
|
|
162
|
+
const second: Mock = vi.fn()
|
|
163
|
+
mutation().mutate({ id: "a" }, { onSuccess: first })
|
|
164
|
+
mutation().mutate({ id: "b" }, { onSuccess: second })
|
|
165
|
+
await flushEffects()
|
|
166
|
+
for (const resolve of pending) resolve(answer({ done: true }))
|
|
167
|
+
await flushEffects()
|
|
168
|
+
expect(first).toHaveBeenCalledTimes(1)
|
|
169
|
+
expect(first.mock.calls[0]?.slice(0, 2)).toEqual([{ done: true }, { id: "a" }])
|
|
170
|
+
expect(second).toHaveBeenCalledTimes(1)
|
|
171
|
+
expect(second.mock.calls[0]?.slice(0, 2)).toEqual([{ done: true }, { id: "b" }])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("invalidates an inactive page under invalidateKeys without fetching it", async () => {
|
|
175
|
+
const client = testClient()
|
|
176
|
+
const pageFetch = vi.fn(() => Promise.resolve(["page 2"]))
|
|
177
|
+
await client.prefetchQuery({ queryKey: ["incidents", { page: 2 }], queryFn: pageFetch })
|
|
178
|
+
expect(pageFetch).toHaveBeenCalledTimes(1)
|
|
179
|
+
const callTool = vi.fn<CallTool>(() => Promise.resolve(answer({ done: true })))
|
|
180
|
+
const { mutation } = await mountMutation(client, callTool, [["incidents"]])
|
|
181
|
+
const onSuccess = vi.fn()
|
|
182
|
+
mutation().mutate({ id: "a" }, { onSuccess })
|
|
183
|
+
await flushEffects()
|
|
184
|
+
expect(onSuccess).toHaveBeenCalledTimes(1)
|
|
185
|
+
expect(client.getQueryState(["incidents", { page: 2 }])?.isInvalidated).toBe(true)
|
|
186
|
+
expect(pageFetch, "an inactive page waits for its next use").toHaveBeenCalledTimes(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("fires no per-call callback once the component unmounted", async () => {
|
|
190
|
+
const client = testClient()
|
|
191
|
+
let resolve: (value: unknown) => void = () => {}
|
|
192
|
+
const callTool = vi.fn<CallTool>(() => new Promise((r) => (resolve = r)))
|
|
193
|
+
const { view, mutation } = await mountMutation(client, callTool)
|
|
194
|
+
const onSuccess = vi.fn()
|
|
195
|
+
const onSettled = vi.fn()
|
|
196
|
+
mutation().mutate({ id: "a" }, { onSuccess, onSettled })
|
|
197
|
+
await flushEffects()
|
|
198
|
+
await view.unmount()
|
|
199
|
+
resolve(answer({ done: true }))
|
|
200
|
+
await flushEffects()
|
|
201
|
+
expect(callTool).toHaveBeenCalledTimes(1)
|
|
202
|
+
expect(onSuccess).not.toHaveBeenCalled()
|
|
203
|
+
expect(onSettled).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
})
|