@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.
- 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 +14 -6
- package/dist/components/DrillButton.d.ts.map +1 -1
- package/dist/components/DrillButton.js +14 -7
- 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.test.tsx +48 -0
- package/src/components/DrillButton.tsx +26 -11
- 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,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
|
+
})
|