@runtypelabs/persona 4.25.0 → 4.26.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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
- package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +14 -0
- package/dist/animations-extra.d.ts +14 -0
- package/dist/approval-ui-QAKWNFNM.js +1 -0
- package/dist/approval-ui.cjs +2 -2
- package/dist/approval-ui.d.cts +180 -8
- package/dist/approval-ui.d.ts +180 -8
- package/dist/approval-ui.js +2 -2
- package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
- package/dist/artifacts-ui.cjs +4 -4
- package/dist/artifacts-ui.d.cts +180 -8
- package/dist/artifacts-ui.d.ts +180 -8
- package/dist/artifacts-ui.js +5 -5
- package/dist/chunk-2ANTRKTV.js +1 -0
- package/dist/chunk-4HPTPUC6.js +1 -0
- package/dist/chunk-6V3MNEKB.js +1 -0
- package/dist/chunk-HGUCGM3K.js +9 -0
- package/dist/chunk-IO5VVUKP.js +3 -0
- package/dist/chunk-J4J3Y2P2.js +1 -0
- package/dist/chunk-K6HKIP5K.js +1 -0
- package/dist/chunk-K7NA7G7J.js +1 -0
- package/dist/chunk-MXUTS3LB.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-QPPJUSZH.js +1 -0
- package/dist/chunk-QRKGQPU7.js +1 -0
- package/dist/chunk-S6PBWE75.js +1 -0
- package/dist/chunk-T7QM7YMF.js +1 -0
- package/dist/chunk-TR2H475E.js +1 -0
- package/dist/chunk-UPO4GUFC.js +1 -0
- package/dist/chunk-ZC66NAIR.js +1 -0
- package/dist/client-history-YPEG45Q4.js +1 -0
- package/dist/client-history.js +1 -0
- package/dist/client-stream-42FOI3EC.js +4 -0
- package/dist/client-stream.js +5 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions-E7HJYWO6.js +161 -0
- package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
- package/dist/context-mentions.cjs +7 -7
- package/dist/context-mentions.d.cts +180 -8
- package/dist/context-mentions.d.ts +180 -8
- package/dist/context-mentions.js +8 -8
- package/dist/event-stream-view-FOIJHFQO.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +183 -31
- package/dist/event-stream-view.d.ts +183 -31
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +221 -667
- package/dist/forms-ui.d.ts +221 -667
- package/dist/history-shell-QDQ44PSE.js +3 -0
- package/dist/history-shell.js +3 -0
- package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
- package/dist/history-view.cjs +2 -2
- package/dist/history-view.d.cts +35 -1
- package/dist/history-view.d.ts +35 -1
- package/dist/history-view.js +2 -2
- package/dist/index.cjs +80 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +227 -738
- package/dist/index.d.ts +227 -738
- package/dist/index.global.js +3 -215
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +81 -75
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -67
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +22 -22
- package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-actions-ISSKSI66.js +1 -0
- package/dist/session-actions.js +1 -0
- package/dist/session-reconnect.d.cts +180 -8
- package/dist/session-reconnect.d.ts +180 -8
- package/dist/smart-dom-reader.cjs +13 -13
- package/dist/smart-dom-reader.d.cts +180 -8
- package/dist/smart-dom-reader.d.ts +180 -8
- package/dist/smart-dom-reader.js +11 -11
- package/dist/theme-editor-preview.cjs +116 -98
- package/dist/theme-editor-preview.d.cts +182 -8
- package/dist/theme-editor-preview.d.ts +182 -8
- package/dist/theme-editor-preview.js +49 -54
- package/dist/theme-editor.cjs +2 -2
- package/dist/theme-editor.d.cts +181 -23
- package/dist/theme-editor.d.ts +181 -23
- package/dist/theme-editor.js +2 -2
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
- package/dist/ui-extras.js +72 -0
- package/dist/voice-runtime-UOQOFAFY.js +17 -0
- package/dist/voice-runtime.cjs +17 -1
- package/dist/voice-runtime.d.cts +11650 -232
- package/dist/voice-runtime.d.ts +11650 -232
- package/dist/voice-runtime.js +17 -1
- package/dist/voice-worklet-player.cjs +19 -4
- package/dist/voice-worklet-player.d.cts +9 -10
- package/dist/voice-worklet-player.d.ts +9 -10
- package/dist/voice-worklet-player.js +19 -4
- package/dist/webmcp-polyfill.js +1 -1
- package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
- package/dist/webmcp-runtime.cjs +2 -2
- package/dist/webmcp-runtime.d.cts +1 -33
- package/dist/webmcp-runtime.d.ts +1 -33
- package/dist/webmcp-runtime.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +11 -5
- package/src/approval-paused-tool-settle.test.ts +221 -0
- package/src/client-approve.test.ts +311 -0
- package/src/client-history-eager.ts +9 -0
- package/src/client-history-loader.ts +23 -0
- package/src/client-history.ts +579 -0
- package/src/client-resume.ts +177 -0
- package/src/client-stream-eager.ts +12 -0
- package/src/client-stream-loader.ts +25 -0
- package/src/client-stream.ts +1880 -0
- package/src/client.test.ts +31 -1
- package/src/client.ts +293 -2607
- package/src/components/approval-actions.test.ts +27 -0
- package/src/components/approval-actions.ts +27 -3
- package/src/components/message-bubble.test.ts +19 -0
- package/src/components/message-bubble.ts +2 -0
- package/src/components/registry.ts +6 -6
- package/src/components/tool-bubble.test.ts +9 -0
- package/src/components/tool-bubble.ts +1 -0
- package/src/history-shell-bundle.test.ts +54 -0
- package/src/history-shell-eager.ts +12 -0
- package/src/history-shell-loader.ts +27 -0
- package/src/history-shell.ts +2362 -0
- package/src/history-view-loader.ts +2 -0
- package/src/history-view.ts +3 -0
- package/src/index-core.ts +6 -0
- package/src/index-global.ts +90 -248
- package/src/index.ts +13 -0
- package/src/install.ts +6 -4
- package/src/internal/runtype-history-provider.test.ts +1 -0
- package/src/internal/runtype-history-provider.ts +28 -13
- package/src/plugins/registry.ts +8 -8
- package/src/session-actions-chunk.test.ts +55 -0
- package/src/session-actions-eager.ts +11 -0
- package/src/session-actions-loader.ts +37 -0
- package/src/session-actions.ts +909 -0
- package/src/session-history.test.ts +3 -1
- package/src/session.ts +852 -1536
- package/src/session.voice.test.ts +1170 -5
- package/src/styles/widget.css +58 -0
- package/src/theme-editor/state.ts +91 -91
- package/src/types.ts +198 -5
- package/src/ui-ask-user-sheet.ts +382 -0
- package/src/ui-extras-eager.ts +10 -0
- package/src/ui-extras-entry.ts +9 -0
- package/src/ui-extras-loader.ts +26 -0
- package/src/ui.artifact-rail-resize.test.ts +2 -1
- package/src/ui.ask-user-chunk.test.ts +93 -0
- package/src/ui.history-chunk.test.ts +2 -1
- package/src/ui.history-config-provider.test.ts +2 -1
- package/src/ui.history-confirm-fallback.test.ts +142 -0
- package/src/ui.history-controller.test.ts +2 -1
- package/src/ui.history-lifecycle.test.ts +2 -1
- package/src/ui.history-render-hooks.test.ts +2 -1
- package/src/ui.history-shell.test.ts +2 -1
- package/src/ui.rail-overlay.test.ts +2 -1
- package/src/ui.rail-resize.test.ts +2 -1
- package/src/ui.rail-trigger-pending.test.ts +2 -1
- package/src/ui.ts +340 -2770
- package/src/utils/attachment-manager.ts +67 -67
- package/src/utils/constants.ts +3 -0
- package/src/utils/context-mention-controller.ts +323 -323
- package/src/utils/context-mention-manager.ts +40 -40
- package/src/utils/context-mention-orchestrator.ts +28 -0
- package/src/utils/event-stream-buffer.ts +50 -50
- package/src/utils/event-stream-store.ts +43 -43
- package/src/utils/throughput-tracker.ts +34 -34
- package/src/utils/virtual-scroller.ts +67 -67
- package/src/utils/visitor-store.test.ts +50 -0
- package/src/utils/visitor-store.ts +23 -5
- package/src/voice/__fixtures__/voice-wire/README.md +1 -0
- package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
- package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
- package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
- package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
- package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
- package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
- package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
- package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
- package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
- package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
- package/src/voice/audio-playback-manager.test.ts +57 -0
- package/src/voice/audio-playback-manager.ts +120 -98
- package/src/voice/browser-voice-provider.ts +37 -37
- package/src/voice/fallback-speech-engine.ts +22 -16
- package/src/voice/keyed-voice-transcript.ts +314 -0
- package/src/voice/mic-state-styles.ts +182 -0
- package/src/voice/read-aloud-controller.ts +46 -44
- package/src/voice/runtype-speech-engine.ts +35 -33
- package/src/voice/runtype-voice-provider.ts +670 -189
- package/src/voice/session-voice-wiring.ts +266 -0
- package/src/voice/voice-delegation.test.ts +71 -0
- package/src/voice/voice-delegation.ts +364 -0
- package/src/voice/voice-turn-tracker.ts +6 -6
- package/src/voice/voice.test.ts +981 -8
- package/src/voice/worklet-playback-engine.test.ts +81 -0
- package/src/voice/worklet-playback-engine.ts +21 -3
- package/src/voice-runtime-loader.ts +4 -1
- package/src/voice-runtime.ts +7 -0
- package/src/webmcp-runtime-entry.ts +41 -39
- package/dist/approval-ui-3NMYNXBD.js +0 -1
- package/dist/chunk-5EIIHQLQ.js +0 -1
- package/dist/chunk-7CO4T5KB.js +0 -1
- package/dist/chunk-EFJBUH5F.js +0 -1
- package/dist/chunk-R2TMIMDI.js +0 -3
- package/dist/chunk-UTVLOTWB.js +0 -9
- package/dist/context-mentions-45MODWOY.js +0 -161
- package/dist/event-stream-view-GL3ADRY2.js +0 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
- package/dist/voice-runtime-7DA7MJH4.js +0 -1
- package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runtypelabs/persona",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.26.0",
|
|
4
4
|
"description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -149,6 +149,7 @@
|
|
|
149
149
|
"fake-indexeddb": "^6.2.5",
|
|
150
150
|
"rimraf": "^5.0.5",
|
|
151
151
|
"size-limit": "^12.1.0",
|
|
152
|
+
"terser": "^5.49.0",
|
|
152
153
|
"tsup": "^8.0.1",
|
|
153
154
|
"typescript": "^5.4.5",
|
|
154
155
|
"vitest": "^4.0.9"
|
|
@@ -180,10 +181,15 @@
|
|
|
180
181
|
"access": "public"
|
|
181
182
|
},
|
|
182
183
|
"scripts": {
|
|
183
|
-
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:context-mentions && pnpm run build:context-mentions-inline && pnpm run build:history-view && pnpm run build:event-stream-view && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations && pnpm run build:animations-extra && pnpm run build:approval-ui && pnpm run build:session-reconnect && pnpm run build:webmcp-runtime && pnpm run build:icons-extra && pnpm run build:artifacts-ui && pnpm run build:voice-runtime && pnpm run build:forms-ui && pnpm run check:dist-vite8 && pnpm run check:size",
|
|
184
|
+
"build": "rimraf dist && pnpm run build:styles && pnpm run build:markdown-parsers && pnpm run build:client-stream && pnpm run build:client-history && pnpm run build:ui-extras && pnpm run build:session-actions && pnpm run build:history-shell && pnpm run build:client && pnpm run build:installer && pnpm run build:launcher && pnpm run build:webmcp-polyfill && pnpm run build:context-mentions && pnpm run build:context-mentions-inline && pnpm run build:history-view && pnpm run build:event-stream-view && pnpm run build:runtype-tts && pnpm run build:theme-ref && pnpm run build:codegen && pnpm run build:theme-editor && pnpm run build:theme-editor-preview && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:voice-worklet-player && pnpm run build:plugin-kit && pnpm run build:animations && pnpm run build:animations-extra && pnpm run build:approval-ui && pnpm run build:session-reconnect && pnpm run build:webmcp-runtime && pnpm run build:icons-extra && pnpm run build:artifacts-ui && pnpm run build:voice-runtime && pnpm run build:forms-ui && pnpm run check:dist-vite8 && pnpm run check:size",
|
|
184
185
|
"check:size": "node scripts/check-size.mjs",
|
|
185
186
|
"check:dist-vite8": "node scripts/check-dist-no-in-for-init.mjs",
|
|
186
187
|
"build:markdown-parsers": "tsup --config tsup.markdown-parsers.config.ts",
|
|
188
|
+
"build:client-stream": "tsup --config tsup.client-stream.config.ts",
|
|
189
|
+
"build:client-history": "tsup --config tsup.client-history.config.ts",
|
|
190
|
+
"build:ui-extras": "tsup --config tsup.ui-extras.config.ts",
|
|
191
|
+
"build:session-actions": "tsup --config tsup.session-actions.config.ts",
|
|
192
|
+
"build:history-shell": "tsup --config tsup.history-shell.config.ts",
|
|
187
193
|
"build:plugin-kit": "tsup src/plugin-kit.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
188
194
|
"build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
|
|
189
195
|
"build:theme-editor-preview": "tsup src/theme-editor-preview.ts --format esm,cjs --minify --dts --out-dir dist",
|
|
@@ -202,14 +208,14 @@
|
|
|
202
208
|
"build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
|
|
203
209
|
"build:codegen": "tsup src/codegen.ts --format esm,cjs --minify --dts",
|
|
204
210
|
"build:styles": "node scripts/build-styles.mjs",
|
|
205
|
-
"build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" --external @runtypelabs/persona/context-mentions --external @runtypelabs/persona/context-mentions-inline --external @runtypelabs/persona/history-view --external @runtypelabs/persona/event-stream-view --external @runtypelabs/persona/animations-extra --external @runtypelabs/persona/approval-ui --external @runtypelabs/persona/session-reconnect --external @runtypelabs/persona/webmcp-runtime --external @runtypelabs/persona/icons-extra --external @runtypelabs/persona/artifacts-ui --external @runtypelabs/persona/voice-runtime --external @runtypelabs/persona/forms-ui && tsup --config tsup.global.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
|
|
211
|
+
"build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" --external @runtypelabs/persona/context-mentions --external @runtypelabs/persona/context-mentions-inline --external @runtypelabs/persona/history-view --external @runtypelabs/persona/event-stream-view --external @runtypelabs/persona/animations-extra --external @runtypelabs/persona/approval-ui --external @runtypelabs/persona/session-reconnect --external @runtypelabs/persona/webmcp-runtime --external @runtypelabs/persona/icons-extra --external @runtypelabs/persona/artifacts-ui --external @runtypelabs/persona/voice-runtime --external @runtypelabs/persona/forms-ui && tsup --config tsup.global.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\" && node scripts/terser-global.mjs dist/index.global.js",
|
|
206
212
|
"build:webmcp-polyfill": "tsup --config tsup.webmcp-polyfill.config.ts",
|
|
207
213
|
"build:context-mentions": "tsup --config tsup.context-mentions.config.ts",
|
|
208
214
|
"build:context-mentions-inline": "tsup --config tsup.context-mentions-inline.config.ts",
|
|
209
215
|
"build:history-view": "tsup --config tsup.history-view.config.ts",
|
|
210
216
|
"build:runtype-tts": "tsup --config tsup.runtype-tts.config.ts",
|
|
211
|
-
"build:installer": "tsup src/install.ts --format iife --global-name SiteAgentInstaller --out-dir dist --minify --sourcemap --no-splitting",
|
|
212
|
-
"build:launcher": "tsup --config tsup.launcher.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/launcher-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/launcher'+ext);}\"",
|
|
217
|
+
"build:installer": "tsup src/install.ts --format iife --global-name SiteAgentInstaller --out-dir dist --minify --sourcemap --no-splitting && node scripts/terser-global.mjs dist/install.global.js",
|
|
218
|
+
"build:launcher": "tsup --config tsup.launcher.config.ts && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/launcher-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/launcher'+ext);}\" && node scripts/terser-global.mjs dist/launcher.global.js",
|
|
213
219
|
"lint": "eslint . --ext .ts",
|
|
214
220
|
"typecheck": "pnpm run check:runtype-types && tsc --noEmit",
|
|
215
221
|
"test": "vitest",
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import { AgentWidgetSession } from "./session";
|
|
4
|
+
import type { AgentWidgetMessage } from "./types";
|
|
5
|
+
|
|
6
|
+
// An approval resume re-runs the agent, which re-issues the gated call under a
|
|
7
|
+
// NEW toolCallId, so no terminal tool frame ever arrives for the paused one.
|
|
8
|
+
// The session settles the paused bubble from the server's approval_complete.
|
|
9
|
+
|
|
10
|
+
const originalFetch = global.fetch;
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
global.fetch = originalFetch;
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const sse = (events: Array<Record<string, unknown>>) =>
|
|
17
|
+
new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), {
|
|
18
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const liveSession = (client: AgentWidgetClient) => {
|
|
22
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
23
|
+
sessionId: "cs_123",
|
|
24
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const pausedTool = (status: "running" | "complete" = "running"): AgentWidgetMessage => ({
|
|
29
|
+
id: "tool-toolu_paused",
|
|
30
|
+
role: "assistant",
|
|
31
|
+
content: "",
|
|
32
|
+
createdAt: new Date(Date.now() - 2_000).toISOString(),
|
|
33
|
+
streaming: status === "running",
|
|
34
|
+
variant: "tool",
|
|
35
|
+
toolCall: {
|
|
36
|
+
id: "toolu_paused",
|
|
37
|
+
name: "place_pickup_order",
|
|
38
|
+
status,
|
|
39
|
+
args: { item: "latte" },
|
|
40
|
+
startedAt: Date.now() - 2_000,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const pendingApproval = (toolCallId?: string): AgentWidgetMessage => ({
|
|
45
|
+
id: "approval-appr_1",
|
|
46
|
+
role: "assistant",
|
|
47
|
+
content: "",
|
|
48
|
+
createdAt: new Date(Date.now() - 1_000).toISOString(),
|
|
49
|
+
variant: "approval",
|
|
50
|
+
approval: {
|
|
51
|
+
id: "appr_1",
|
|
52
|
+
status: "pending",
|
|
53
|
+
agentId: "agent_abc",
|
|
54
|
+
executionId: "exec_abc",
|
|
55
|
+
toolName: "place_pickup_order",
|
|
56
|
+
description: "Place the order",
|
|
57
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const setup = (initialMessages: AgentWidgetMessage[]) => {
|
|
62
|
+
let messages: AgentWidgetMessage[] = [];
|
|
63
|
+
const session = new AgentWidgetSession(
|
|
64
|
+
{ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com", initialMessages },
|
|
65
|
+
{
|
|
66
|
+
onMessagesChanged: (m) => { messages = m; },
|
|
67
|
+
onStatusChanged: () => {},
|
|
68
|
+
onStreamingChanged: () => {},
|
|
69
|
+
onError: () => {},
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
liveSession((session as unknown as { client: AgentWidgetClient }).client);
|
|
73
|
+
const approval = initialMessages.find((m) => m.variant === "approval")!.approval!;
|
|
74
|
+
return {
|
|
75
|
+
resolve: (decision: "approved" | "denied") => session.resolveApproval(approval, decision),
|
|
76
|
+
tool: (id = "toolu_paused") => messages.find((m) => m.toolCall?.id === id),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const resumeStream = (decision: "approved" | "denied" | "timeout", rerun = true) =>
|
|
81
|
+
sse([
|
|
82
|
+
{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision },
|
|
83
|
+
...(rerun
|
|
84
|
+
? [
|
|
85
|
+
{ type: "tool_start", toolCallId: "toolu_rerun", toolName: "place_pickup_order", toolType: "custom" },
|
|
86
|
+
{
|
|
87
|
+
type: "tool_complete",
|
|
88
|
+
toolCallId: "toolu_rerun",
|
|
89
|
+
toolName: "place_pickup_order",
|
|
90
|
+
success: decision === "approved",
|
|
91
|
+
result: decision === "approved" ? { orderId: "ord_1" } : undefined,
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
{ type: "execution_complete", kind: "agent", success: true },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
describe("approval_start records the paused toolCallId", () => {
|
|
99
|
+
it("carries toolCallId onto the approval message", async () => {
|
|
100
|
+
const client = new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" });
|
|
101
|
+
const emitted: AgentWidgetMessage[] = [];
|
|
102
|
+
await client.processStream(
|
|
103
|
+
sse([
|
|
104
|
+
{
|
|
105
|
+
type: "approval_start",
|
|
106
|
+
executionId: "exec_abc",
|
|
107
|
+
approvalId: "appr_1",
|
|
108
|
+
toolCallId: "toolu_paused",
|
|
109
|
+
toolName: "place_pickup_order",
|
|
110
|
+
parameters: { item: "latte" },
|
|
111
|
+
},
|
|
112
|
+
]).body!,
|
|
113
|
+
(event) => {
|
|
114
|
+
if (event.type === "message") emitted.push(event.message);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
const approvalMessage = emitted.find((m) => m.variant === "approval");
|
|
118
|
+
expect(approvalMessage?.approval?.toolCallId).toBe("toolu_paused");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("AgentWidgetSession settles the approval-paused tool bubble", () => {
|
|
123
|
+
it("marks an approved call superseded and leaves the result on the re-run", async () => {
|
|
124
|
+
global.fetch = vi.fn().mockResolvedValue(resumeStream("approved"));
|
|
125
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
126
|
+
await resolve("approved");
|
|
127
|
+
|
|
128
|
+
expect(tool()?.toolCall).toMatchObject({
|
|
129
|
+
status: "complete",
|
|
130
|
+
approvalStatus: "approved",
|
|
131
|
+
superseded: true,
|
|
132
|
+
});
|
|
133
|
+
expect(tool()?.toolCall?.success).toBeUndefined();
|
|
134
|
+
expect(tool()?.streaming).toBe(false);
|
|
135
|
+
expect(tool("toolu_rerun")?.toolCall).toMatchObject({
|
|
136
|
+
status: "complete",
|
|
137
|
+
result: { orderId: "ord_1" },
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("settles a denied call as failed with a Denied error", async () => {
|
|
142
|
+
global.fetch = vi.fn().mockResolvedValue(resumeStream("denied", false));
|
|
143
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
144
|
+
await resolve("denied");
|
|
145
|
+
|
|
146
|
+
expect(tool()?.toolCall).toMatchObject({
|
|
147
|
+
status: "complete",
|
|
148
|
+
approvalStatus: "denied",
|
|
149
|
+
success: false,
|
|
150
|
+
error: "Denied",
|
|
151
|
+
});
|
|
152
|
+
expect(tool()?.toolCall?.superseded).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("settles a timed-out call as failed", async () => {
|
|
156
|
+
global.fetch = vi.fn().mockResolvedValue(resumeStream("timeout", false));
|
|
157
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
158
|
+
await resolve("denied");
|
|
159
|
+
|
|
160
|
+
expect(tool()?.toolCall).toMatchObject({
|
|
161
|
+
status: "complete",
|
|
162
|
+
approvalStatus: "timeout",
|
|
163
|
+
success: false,
|
|
164
|
+
error: "Approval timed out",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("leaves the bubble running when the server refuses the decision", async () => {
|
|
169
|
+
global.fetch = vi.fn().mockResolvedValue(Response.json({ error: "Failed to process approval" }, { status: 500 }));
|
|
170
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
171
|
+
await resolve("approved").catch(() => {});
|
|
172
|
+
|
|
173
|
+
expect(tool()?.toolCall?.status).toBe("running");
|
|
174
|
+
expect(tool()?.toolCall?.approvalStatus).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("settles the call as timed out when the server reports the pause gone", async () => {
|
|
178
|
+
global.fetch = vi.fn().mockResolvedValue(
|
|
179
|
+
Response.json({ code: "APPROVAL_ALREADY_RESOLVED", message: "Already resolved" }, { status: 409 })
|
|
180
|
+
);
|
|
181
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
182
|
+
await resolve("approved").catch(() => {});
|
|
183
|
+
|
|
184
|
+
expect(tool()?.toolCall).toMatchObject({
|
|
185
|
+
status: "complete",
|
|
186
|
+
approvalStatus: "timeout",
|
|
187
|
+
success: false,
|
|
188
|
+
error: "Approval timed out",
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("does not touch a tool call that already completed", async () => {
|
|
193
|
+
global.fetch = vi.fn().mockResolvedValue(resumeStream("denied", false));
|
|
194
|
+
const { resolve, tool } = setup([pausedTool("complete"), pendingApproval("toolu_paused")]);
|
|
195
|
+
await resolve("denied");
|
|
196
|
+
|
|
197
|
+
expect(tool()?.toolCall?.success).toBeUndefined();
|
|
198
|
+
expect(tool()?.toolCall?.approvalStatus).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does nothing for an approval without a toolCallId", async () => {
|
|
202
|
+
global.fetch = vi.fn().mockResolvedValue(resumeStream("denied", false));
|
|
203
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval()]);
|
|
204
|
+
await resolve("denied");
|
|
205
|
+
|
|
206
|
+
expect(tool()?.toolCall?.status).toBe("running");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("settles a denied call when the accepted denial has no stream body", async () => {
|
|
210
|
+
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
211
|
+
const { resolve, tool } = setup([pausedTool(), pendingApproval("toolu_paused")]);
|
|
212
|
+
await resolve("denied");
|
|
213
|
+
|
|
214
|
+
expect(tool()?.toolCall).toMatchObject({
|
|
215
|
+
status: "complete",
|
|
216
|
+
approvalStatus: "denied",
|
|
217
|
+
success: false,
|
|
218
|
+
error: "Denied",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "./client";
|
|
3
|
+
import { AgentWidgetSession } from "./session";
|
|
4
|
+
import type { AgentWidgetMessage } from "./types";
|
|
5
|
+
|
|
6
|
+
// runtypelabs/core#9518: client-token embeds answer `approver: 'end-user'`
|
|
7
|
+
// gates through the session-authenticated `/v1/client/approve`.
|
|
8
|
+
|
|
9
|
+
const originalFetch = global.fetch;
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
global.fetch = originalFetch;
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const approval = { agentId: "agent_abc", executionId: "exec_abc", approvalId: "appr_1" };
|
|
16
|
+
|
|
17
|
+
const liveSession = (client: AgentWidgetClient) => {
|
|
18
|
+
// A live session so initSession() short-circuits instead of fetching /init.
|
|
19
|
+
(client as unknown as { clientSession: { sessionId: string; expiresAt: Date } }).clientSession = {
|
|
20
|
+
sessionId: "cs_123",
|
|
21
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const sse = (events: Array<Record<string, unknown>>) =>
|
|
26
|
+
new Response(events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""), {
|
|
27
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type Call = { url: string; body: Record<string, unknown>; headers: Record<string, string> };
|
|
31
|
+
|
|
32
|
+
const mockFetch = (...responses: Response[]) => {
|
|
33
|
+
const calls: Call[] = [];
|
|
34
|
+
global.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
|
|
35
|
+
calls.push({
|
|
36
|
+
url,
|
|
37
|
+
body: JSON.parse(init.body as string),
|
|
38
|
+
headers: init.headers as Record<string, string>,
|
|
39
|
+
});
|
|
40
|
+
return responses.shift() ?? sse([]);
|
|
41
|
+
});
|
|
42
|
+
return calls;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const tokenClient = () => {
|
|
46
|
+
const client = new AgentWidgetClient({ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com" });
|
|
47
|
+
liveSession(client);
|
|
48
|
+
return client;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("AgentWidgetClient.resolveApproval", () => {
|
|
52
|
+
it("posts a client-token decision to /v1/client/approve with the session and no Bearer key", async () => {
|
|
53
|
+
const calls = mockFetch(sse([]));
|
|
54
|
+
await tokenClient().resolveApproval(approval, "approved");
|
|
55
|
+
|
|
56
|
+
expect(calls).toHaveLength(1);
|
|
57
|
+
expect(calls[0].url).toBe("https://api.runtype.com/v1/client/approve");
|
|
58
|
+
expect(calls[0].body).toEqual({
|
|
59
|
+
sessionId: "cs_123",
|
|
60
|
+
executionId: "exec_abc",
|
|
61
|
+
approvalId: "appr_1",
|
|
62
|
+
decision: "approved",
|
|
63
|
+
streamResponse: true,
|
|
64
|
+
});
|
|
65
|
+
expect(calls[0].headers.Authorization).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sends a denial the same way", async () => {
|
|
69
|
+
const calls = mockFetch(sse([]));
|
|
70
|
+
await tokenClient().resolveApproval(approval, "denied");
|
|
71
|
+
|
|
72
|
+
expect(calls[0].url).toBe("https://api.runtype.com/v1/client/approve");
|
|
73
|
+
expect(calls[0].body.decision).toBe("denied");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("falls back to the owner route when the core predates /v1/client/approve", async () => {
|
|
77
|
+
const calls = mockFetch(
|
|
78
|
+
Response.json({ error: "Not Found", message: "The requested resource was not found" }, { status: 404 }),
|
|
79
|
+
sse([])
|
|
80
|
+
);
|
|
81
|
+
const response = await tokenClient().resolveApproval(approval, "approved");
|
|
82
|
+
|
|
83
|
+
expect(calls.map((c) => c.url)).toEqual([
|
|
84
|
+
"https://api.runtype.com/v1/client/approve",
|
|
85
|
+
"https://api.runtype.com/v1/agents/agent_abc/approve",
|
|
86
|
+
]);
|
|
87
|
+
expect(calls[1].body.sessionId).toBeUndefined();
|
|
88
|
+
expect(response.ok).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns an unknown or expired pause 404 without falling back", async () => {
|
|
92
|
+
const calls = mockFetch(
|
|
93
|
+
Response.json({ error: "No paused execution found for this executionId" }, { status: 404 })
|
|
94
|
+
);
|
|
95
|
+
const response = await tokenClient().resolveApproval(approval, "approved");
|
|
96
|
+
|
|
97
|
+
expect(calls).toHaveLength(1);
|
|
98
|
+
expect(response.status).toBe(404);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps proxy mode on the owner route with the host headers", async () => {
|
|
102
|
+
const calls = mockFetch(sse([]));
|
|
103
|
+
const client = new AgentWidgetClient({
|
|
104
|
+
apiUrl: "https://api.runtype.com/v1/dispatch",
|
|
105
|
+
headers: { Authorization: "Bearer host" },
|
|
106
|
+
});
|
|
107
|
+
await client.resolveApproval(approval, "approved");
|
|
108
|
+
|
|
109
|
+
expect(calls[0].url).toBe("https://api.runtype.com/v1/agents/agent_abc/approve");
|
|
110
|
+
expect(calls[0].body).toEqual({
|
|
111
|
+
executionId: "exec_abc",
|
|
112
|
+
approvalId: "appr_1",
|
|
113
|
+
decision: "approved",
|
|
114
|
+
streamResponse: true,
|
|
115
|
+
});
|
|
116
|
+
expect(calls[0].headers.Authorization).toBe("Bearer host");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("AgentWidgetSession.resolveApproval in client-token mode", () => {
|
|
121
|
+
const setup = () => {
|
|
122
|
+
let messages: AgentWidgetMessage[] = [];
|
|
123
|
+
const errors: Error[] = [];
|
|
124
|
+
const pending: AgentWidgetMessage = {
|
|
125
|
+
id: "approval-appr_1",
|
|
126
|
+
role: "assistant",
|
|
127
|
+
content: "",
|
|
128
|
+
createdAt: new Date().toISOString(),
|
|
129
|
+
variant: "approval",
|
|
130
|
+
approval: {
|
|
131
|
+
id: "appr_1",
|
|
132
|
+
status: "pending",
|
|
133
|
+
agentId: "agent_abc",
|
|
134
|
+
executionId: "exec_abc",
|
|
135
|
+
toolName: "place_pickup_order",
|
|
136
|
+
description: "Place the order",
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const session = new AgentWidgetSession(
|
|
140
|
+
{ clientToken: "ct_live_demo", apiUrl: "https://api.runtype.com", initialMessages: [pending] },
|
|
141
|
+
{
|
|
142
|
+
onMessagesChanged: (m) => { messages = m; },
|
|
143
|
+
onStatusChanged: () => {},
|
|
144
|
+
onStreamingChanged: () => {},
|
|
145
|
+
onError: (e) => errors.push(e),
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
liveSession((session as unknown as { client: AgentWidgetClient }).client);
|
|
149
|
+
return {
|
|
150
|
+
session,
|
|
151
|
+
errors,
|
|
152
|
+
approve: (decision: "approved" | "denied" = "approved") =>
|
|
153
|
+
session.resolveApproval(pending.approval!, decision),
|
|
154
|
+
bubble: () => messages.find((m) => m.id === "approval-appr_1"),
|
|
155
|
+
last: () => messages.at(-1),
|
|
156
|
+
all: () => messages,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
it("streams the approved continuation into the conversation", async () => {
|
|
161
|
+
mockFetch(
|
|
162
|
+
sse([
|
|
163
|
+
{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision: "approved" },
|
|
164
|
+
{ type: "text_start", id: "t1" },
|
|
165
|
+
{ type: "text_delta", id: "t1", delta: "Order placed." },
|
|
166
|
+
{ type: "text_complete", id: "t1" },
|
|
167
|
+
{ type: "execution_complete", kind: "agent", success: true },
|
|
168
|
+
])
|
|
169
|
+
);
|
|
170
|
+
const { session, errors, approve, bubble, last } = setup();
|
|
171
|
+
await approve();
|
|
172
|
+
|
|
173
|
+
expect(bubble()?.approval?.status).toBe("approved");
|
|
174
|
+
expect(last()?.content).toBe("Order placed.");
|
|
175
|
+
expect(session.isStreaming()).toBe(false);
|
|
176
|
+
expect(errors).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it.each([
|
|
180
|
+
[
|
|
181
|
+
403,
|
|
182
|
+
{
|
|
183
|
+
error: "This approval needs the business owner, not the chat visitor",
|
|
184
|
+
code: "APPROVAL_APPROVER_NOT_END_USER",
|
|
185
|
+
},
|
|
186
|
+
"This approval needs the business owner, not the chat visitor",
|
|
187
|
+
"pending",
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
409,
|
|
191
|
+
{
|
|
192
|
+
error: "Approval already resolved",
|
|
193
|
+
message: "This approval was already denied; that decision is the one in force.",
|
|
194
|
+
code: "APPROVAL_ALREADY_RESOLVED",
|
|
195
|
+
},
|
|
196
|
+
"This approval was already denied; that decision is the one in force.",
|
|
197
|
+
"timeout",
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
404,
|
|
201
|
+
{ error: "No paused execution found for this executionId" },
|
|
202
|
+
"No paused execution found for this executionId",
|
|
203
|
+
"timeout",
|
|
204
|
+
],
|
|
205
|
+
[404, { error: "Agent not found" }, "Agent not found", "pending"],
|
|
206
|
+
[500, { error: "Failed to process approval" }, "Failed to process approval", "pending"],
|
|
207
|
+
])("surfaces a %i refusal without claiming the decision applied", async (status, body, text, badge) => {
|
|
208
|
+
mockFetch(Response.json(body, { status }));
|
|
209
|
+
const { session, errors, approve, bubble, last } = setup();
|
|
210
|
+
await approve();
|
|
211
|
+
|
|
212
|
+
expect(bubble()?.approval?.status).toBe(badge);
|
|
213
|
+
expect(last()).toMatchObject({ id: "approval-error-appr_1", content: text });
|
|
214
|
+
expect(session.isStreaming()).toBe(false);
|
|
215
|
+
expect(errors.map((e) => e.message)).toEqual([text]);
|
|
216
|
+
// A refused request settles too: its token must not outlive it.
|
|
217
|
+
expect((session as unknown as { approvalTokens: Map<string, object> }).approvalTokens.size).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("clears the failure notice when a retry succeeds", async () => {
|
|
221
|
+
mockFetch(
|
|
222
|
+
Response.json({ error: "Failed to process approval" }, { status: 500 }),
|
|
223
|
+
sse([{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision: "approved" }])
|
|
224
|
+
);
|
|
225
|
+
const { approve, bubble, all } = setup();
|
|
226
|
+
await approve();
|
|
227
|
+
expect(bubble()?.approval?.status).toBe("pending");
|
|
228
|
+
await approve();
|
|
229
|
+
|
|
230
|
+
expect(bubble()?.approval?.status).toBe("approved");
|
|
231
|
+
expect(all().some((m) => m.id === "approval-error-appr_1")).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("does not overwrite a card a newer resolution already settled", async () => {
|
|
235
|
+
let failFirst!: (r: Response) => void;
|
|
236
|
+
global.fetch = vi
|
|
237
|
+
.fn()
|
|
238
|
+
.mockImplementationOnce(() => new Promise<Response>((res) => { failFirst = res; }))
|
|
239
|
+
.mockImplementationOnce(async () =>
|
|
240
|
+
sse([{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision: "approved" }])
|
|
241
|
+
);
|
|
242
|
+
const { approve, bubble, all } = setup();
|
|
243
|
+
const first = approve();
|
|
244
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
245
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
246
|
+
await approve();
|
|
247
|
+
failFirst(Response.json({ error: "Failed to process approval" }, { status: 500 }));
|
|
248
|
+
await first;
|
|
249
|
+
|
|
250
|
+
expect(bubble()?.approval?.status).toBe("approved");
|
|
251
|
+
expect(all().some((m) => m.id === "approval-error-appr_1")).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("does not let an older failure reopen a card a newer decision settled in the same millisecond", async () => {
|
|
255
|
+
// Both decisions (and the newer one's approval_complete) stamp the same
|
|
256
|
+
// Date.now(), so only a per-request token tells the older failure apart.
|
|
257
|
+
vi.spyOn(Date, "now").mockReturnValue(Date.now());
|
|
258
|
+
let failFirst!: (r: Response) => void;
|
|
259
|
+
global.fetch = vi
|
|
260
|
+
.fn()
|
|
261
|
+
.mockImplementationOnce(() => new Promise<Response>((res) => { failFirst = res; }))
|
|
262
|
+
.mockImplementationOnce(async () =>
|
|
263
|
+
sse([{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision: "denied" }])
|
|
264
|
+
);
|
|
265
|
+
const { session, approve, bubble, all } = setup();
|
|
266
|
+
const first = approve("approved");
|
|
267
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
268
|
+
await approve("denied");
|
|
269
|
+
expect(bubble()?.approval?.status).toBe("denied");
|
|
270
|
+
failFirst(Response.json({ error: "Failed to process approval" }, { status: 500 }));
|
|
271
|
+
await first;
|
|
272
|
+
|
|
273
|
+
expect(bubble()?.approval?.status).toBe("denied");
|
|
274
|
+
expect(all().some((m) => m.id === "approval-error-appr_1")).toBe(false);
|
|
275
|
+
// Both requests settled: no token outlives them.
|
|
276
|
+
expect((session as unknown as { approvalTokens: Map<string, object> }).approvalTokens.size).toBe(0);
|
|
277
|
+
vi.restoreAllMocks();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("does not let an older success clear a newer request's failure notice", async () => {
|
|
281
|
+
let succeedFirst!: (r: Response) => void;
|
|
282
|
+
global.fetch = vi
|
|
283
|
+
.fn()
|
|
284
|
+
.mockImplementationOnce(() => new Promise<Response>((res) => { succeedFirst = res; }))
|
|
285
|
+
.mockResolvedValueOnce(Response.json({ error: "Failed to process approval" }, { status: 500 }));
|
|
286
|
+
const { approve, all } = setup();
|
|
287
|
+
const first = approve();
|
|
288
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
289
|
+
await new Promise((r) => setTimeout(r, 2));
|
|
290
|
+
await approve();
|
|
291
|
+
expect(all().some((m) => m.id === "approval-error-appr_1")).toBe(true);
|
|
292
|
+
succeedFirst(
|
|
293
|
+
sse([{ type: "approval_complete", executionId: "exec_abc", approvalId: "appr_1", decision: "approved" }])
|
|
294
|
+
);
|
|
295
|
+
await first;
|
|
296
|
+
|
|
297
|
+
expect(all().some((m) => m.id === "approval-error-appr_1")).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("keeps the failure notice when a retry rejects without a response", async () => {
|
|
301
|
+
global.fetch = vi
|
|
302
|
+
.fn()
|
|
303
|
+
.mockResolvedValueOnce(Response.json({ error: "Failed to process approval" }, { status: 500 }))
|
|
304
|
+
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
|
305
|
+
const { approve, all } = setup();
|
|
306
|
+
await approve();
|
|
307
|
+
await approve();
|
|
308
|
+
|
|
309
|
+
expect(all().find((m) => m.id === "approval-error-appr_1")?.content).toBe("Failed to process approval");
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eager history-REST registration for the bundled (ESM / CJS) builds and
|
|
3
|
+
* tests. Like `markdown-parsers-eager.ts`, this module must NOT be reachable
|
|
4
|
+
* from `index-global.ts`: the IIFE/CDN build fetches `client-history.js`.
|
|
5
|
+
*/
|
|
6
|
+
import * as clientHistory from "./client-history";
|
|
7
|
+
import { provideClientHistory } from "./client-history-loader";
|
|
8
|
+
|
|
9
|
+
provideClientHistory(clientHistory);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createChunkLoader } from "./utils/chunk-loader";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loader indirection for the visitor-history REST functions
|
|
5
|
+
* (`client-history.ts`). The ESM/CJS fallback imports `./client-history`
|
|
6
|
+
* directly (inlined by `--splitting false`); the IIFE/CDN build marks it
|
|
7
|
+
* external and registers a loader for the sibling `client-history.js` chunk
|
|
8
|
+
* (see `index-global.ts`).
|
|
9
|
+
*/
|
|
10
|
+
export type ClientHistoryModule = typeof import("./client-history");
|
|
11
|
+
|
|
12
|
+
const { setLoader, load, provide } = createChunkLoader<ClientHistoryModule>({
|
|
13
|
+
fallbackImport: () => import("./client-history"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Override how the chunk is fetched (the IIFE build registers a sibling-URL loader). */
|
|
17
|
+
export const setClientHistoryLoader = setLoader;
|
|
18
|
+
|
|
19
|
+
/** Load the history REST functions. Memoized; retries after rejection. */
|
|
20
|
+
export const loadClientHistory = load;
|
|
21
|
+
|
|
22
|
+
/** Eagerly supply the module (`client-history-eager.ts`: npm builds and tests). */
|
|
23
|
+
export const provideClientHistory = provide;
|