@runtypelabs/persona 4.22.0 → 4.23.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-B_JdVK9o.d.cts → types-9Vxm-PPz.d.cts} +136 -6
- package/dist/animations/{types-B_JdVK9o.d.ts → types-9Vxm-PPz.d.ts} +136 -6
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +136 -6
- package/dist/animations-extra.d.ts +136 -6
- package/dist/approval-ui.d.cts +148 -14
- package/dist/approval-ui.d.ts +148 -14
- package/dist/artifacts-ui.d.cts +148 -14
- package/dist/artifacts-ui.d.ts +148 -14
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +148 -14
- package/dist/context-mentions.d.ts +148 -14
- package/dist/event-stream-view-GL3ADRY2.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +148 -14
- package/dist/event-stream-view.d.ts +148 -14
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +161 -23
- package/dist/forms-ui.d.ts +161 -23
- package/dist/history-view.d.cts +136 -6
- package/dist/history-view.d.ts +136 -6
- package/dist/index.cjs +67 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +172 -27
- package/dist/index.d.ts +172 -27
- package/dist/index.global.js +51 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -67
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +148 -14
- package/dist/session-reconnect.d.ts +148 -14
- package/dist/smart-dom-reader.d.cts +148 -14
- package/dist/smart-dom-reader.d.ts +148 -14
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +3 -4
- package/dist/testing.d.ts +3 -4
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +71 -71
- package/dist/theme-editor-preview.d.cts +154 -14
- package/dist/theme-editor-preview.d.ts +154 -14
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.d.cts +148 -14
- package/dist/theme-editor.d.ts +148 -14
- package/dist/voice-runtime.d.cts +6 -2
- package/dist/voice-runtime.d.ts +6 -2
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +1 -1
- package/src/client-history-rest.test.ts +2 -1
- package/src/client-visitor-history.test.ts +106 -4
- package/src/client.composer-options.test.ts +1 -1
- package/src/client.interrupt.test.ts +2 -2
- package/src/client.test.ts +477 -915
- package/src/client.ts +134 -494
- package/src/client.unified-tools.test.ts +76 -0
- package/src/components/event-stream-badge-theming.test.ts +3 -0
- package/src/components/event-stream-view.test.ts +76 -76
- package/src/components/event-stream-view.ts +5 -0
- package/src/components/tool-bubble.test.ts +12 -0
- package/src/components/tool-bubble.ts +13 -2
- package/src/event-stream-view-bundle.test.ts +2 -2
- package/src/generated/runtype-openapi-contract.ts +129 -1
- package/src/internal/runtype-history-provider.test.ts +2 -2
- package/src/launcher-global.ts +1 -1
- package/src/session-history.test.ts +1 -1
- package/src/session.quote.test.ts +1 -1
- package/src/session.resubmit-from.test.ts +1 -1
- package/src/session.test.ts +5 -5
- package/src/session.ts +95 -36
- package/src/suggest-replies-tool.ts +1 -1
- package/src/testing/mock-stream.test.ts +37 -16
- package/src/testing/mock-stream.ts +14 -9
- package/src/types.ts +19 -14
- package/src/ui.ask-user-question-plugin.test.ts +7 -7
- package/src/ui.composer-defer-one.test.ts +6 -34
- package/src/ui.composer-draft-persistence.test.ts +1 -1
- package/src/ui.composer-quote.test.ts +1 -1
- package/src/ui.message-history-actions.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +10 -13
- package/src/ui.ts +80 -32
- package/src/ui.typing-indicator.test.ts +216 -0
- package/src/ui.voice-custom-provider.test.ts +211 -0
- package/src/utils/event-stream-buffer.test.ts +13 -13
- package/src/utils/event-stream-capture.test.ts +20 -20
- package/src/utils/throughput-tracker.test.ts +100 -142
- package/src/utils/throughput-tracker.ts +18 -132
- package/src/utils/voice-support.ts +18 -1
- package/src/voice/voice-turn-tracker.ts +18 -0
- package/src/webmcp-bridge.ts +1 -1
- package/dist/event-stream-view-FKWDY3UB.js +0 -1
- package/src/utils/__fixtures__/unified-translator.oracle.ts +0 -717
package/src/session.ts
CHANGED
|
@@ -78,7 +78,8 @@ import {
|
|
|
78
78
|
ReadAloudController,
|
|
79
79
|
type ReadAloudListener,
|
|
80
80
|
} from "./voice/read-aloud-controller";
|
|
81
|
-
import { isVoiceSupportedProbe } from "./utils/voice-support";
|
|
81
|
+
import { isVoiceSupportedProbe, usesSessionVoice, voiceConnectionChanged } from "./utils/voice-support";
|
|
82
|
+
import { VoiceTurnTracker } from "./voice/voice-turn-tracker";
|
|
82
83
|
import { loadVoiceRuntime } from "./voice-runtime-loader";
|
|
83
84
|
import { resolveSpeakableText } from "./utils/speech-text";
|
|
84
85
|
import { loadRuntypeTts } from "./voice/runtype-tts-loader";
|
|
@@ -275,7 +276,7 @@ const getWebMcpErrorMessage = (
|
|
|
275
276
|
};
|
|
276
277
|
|
|
277
278
|
/**
|
|
278
|
-
* Tool names whose `
|
|
279
|
+
* Tool names whose `await` the widget resolves automatically (no user
|
|
279
280
|
* pill click): `webmcp:*` page tools and the built-in fire-and-forget
|
|
280
281
|
* `suggest_replies`. These share the await-batch / dedupe / resume machinery;
|
|
281
282
|
* `ask_user_question` is NOT one of them: it blocks on the answer sheet.
|
|
@@ -334,7 +335,7 @@ export class AgentWidgetSession {
|
|
|
334
335
|
// concurrent re-fire during the resolve round-trip.
|
|
335
336
|
// webMcpResolvedKeys: /resume HTTP returned 2xx; not cleared on a new
|
|
336
337
|
// dispatch (executionId scoping makes that
|
|
337
|
-
// unnecessary). Blocks stale
|
|
338
|
+
// unnecessary). Blocks stale await re-emits
|
|
338
339
|
// for the same execution.
|
|
339
340
|
//
|
|
340
341
|
// If `/resume` throws (network error, server 5xx), we DO want a retry path:
|
|
@@ -343,10 +344,10 @@ export class AgentWidgetSession {
|
|
|
343
344
|
private webMcpInflightKeys: Set<string> = new Set();
|
|
344
345
|
private webMcpResolvedKeys: Set<string> = new Set();
|
|
345
346
|
// Per-resolve AbortControllers, kept in a set so multiple `webmcp:*`
|
|
346
|
-
//
|
|
347
|
+
// await resolves in one turn never abort one another. The shared
|
|
347
348
|
// `this.abortController` is intentionally NOT used by resolveWebMcpToolCall:
|
|
348
349
|
// in a CHAINED turn (tool A → /resume → tool B, where the server emits B's
|
|
349
|
-
//
|
|
350
|
+
// await inside A's resume SSE stream) the shared controller is still
|
|
350
351
|
// piping A's resume stream: the very stream that just delivered B. Aborting
|
|
351
352
|
// it mid-chain (the prior shared-controller pre-abort) tore that stream down,
|
|
352
353
|
// so B never reached execute() and its /resume was never POSTed, pausing the
|
|
@@ -369,7 +370,7 @@ export class AgentWidgetSession {
|
|
|
369
370
|
new Map();
|
|
370
371
|
private webMcpApprovalSeq = 0;
|
|
371
372
|
// Parallel local-tool batching (core#3878). A single model turn can emit
|
|
372
|
-
// multiple `
|
|
373
|
+
// multiple `await(local_tool_required)` events for ONE paused
|
|
373
374
|
// executionId: including two PARALLEL calls to the SAME tool ("add SHOE-001
|
|
374
375
|
// and SHOE-007"). Those collapse to an identical `toolId`/`index` and differ
|
|
375
376
|
// only by the per-call `webMcpToolCallId`. We collect all awaits for an
|
|
@@ -377,7 +378,7 @@ export class AgentWidgetSession {
|
|
|
377
378
|
// keyed by `webMcpToolCallId`: NOT one `/resume` per tool keyed by name
|
|
378
379
|
// (which collides for same-tool calls, and whose concurrent posts on one
|
|
379
380
|
// execution raced → the second 404'd → the turn hung). Keyed by executionId;
|
|
380
|
-
// `seen` dedupes duplicate
|
|
381
|
+
// `seen` dedupes duplicate await re-emits within a batch. Cleared on
|
|
381
382
|
// every teardown via `abortWebMcpResolves`.
|
|
382
383
|
private webMcpAwaitBatches: Map<
|
|
383
384
|
string,
|
|
@@ -576,6 +577,7 @@ export class AgentWidgetSession {
|
|
|
576
577
|
*/
|
|
577
578
|
public stopVoicePlayback(): void {
|
|
578
579
|
if (this.voiceProvider?.stopPlayback) {
|
|
580
|
+
this.voiceTurns.cancel();
|
|
579
581
|
this.voiceProvider.stopPlayback();
|
|
580
582
|
}
|
|
581
583
|
}
|
|
@@ -595,6 +597,8 @@ export class AgentWidgetSession {
|
|
|
595
597
|
// Pending placeholder IDs for Runtype two-phase voice flow
|
|
596
598
|
private pendingVoiceUserMessageId: string | null = null;
|
|
597
599
|
private pendingVoiceAssistantMessageId: string | null = null;
|
|
600
|
+
private voiceTurns = new VoiceTurnTracker();
|
|
601
|
+
private voiceDisconnectPromise: Promise<void> = Promise.resolve();
|
|
598
602
|
|
|
599
603
|
// Track message IDs where the Runtype provider already played TTS audio
|
|
600
604
|
// so browser TTS doesn't double-speak them
|
|
@@ -688,6 +692,9 @@ export class AgentWidgetSession {
|
|
|
688
692
|
throw new Error('Voice configuration not provided');
|
|
689
693
|
}
|
|
690
694
|
|
|
695
|
+
this.cleanupVoice();
|
|
696
|
+
const disconnected = this.voiceDisconnectPromise;
|
|
697
|
+
|
|
691
698
|
// The provider runtime ships in the lazy voice-runtime chunk. The sync
|
|
692
699
|
// signature is preserved: construction + wiring + connect() happen when
|
|
693
700
|
// the chunk resolves (prefetched at session construction when a
|
|
@@ -696,7 +703,8 @@ export class AgentWidgetSession {
|
|
|
696
703
|
// discarded via the generation token.
|
|
697
704
|
const generation = ++this.voiceSetupGeneration;
|
|
698
705
|
this.voiceSetupPromise = loadVoiceRuntime()
|
|
699
|
-
.then((mod) => {
|
|
706
|
+
.then(async (mod) => {
|
|
707
|
+
await disconnected;
|
|
700
708
|
if (generation !== this.voiceSetupGeneration) return;
|
|
701
709
|
this.wireVoiceProvider(mod.createVoiceProvider(voiceConfig));
|
|
702
710
|
})
|
|
@@ -717,6 +725,8 @@ export class AgentWidgetSession {
|
|
|
717
725
|
private wireVoiceProvider(provider: VoiceProvider): void {
|
|
718
726
|
try {
|
|
719
727
|
this.voiceProvider = provider;
|
|
728
|
+
const generation = this.voiceSetupGeneration;
|
|
729
|
+
const isCurrent = () => this.voiceProvider === provider && generation === this.voiceSetupGeneration;
|
|
720
730
|
|
|
721
731
|
// Read configurable text from widget config
|
|
722
732
|
const voiceRecognitionConfig = this.config.voiceRecognition ?? {};
|
|
@@ -727,6 +737,7 @@ export class AgentWidgetSession {
|
|
|
727
737
|
// via the standard SSE chat path. Only the realtime `runtype` provider is
|
|
728
738
|
// excluded here: it owns the whole turn and drives onTranscript below.
|
|
729
739
|
this.voiceProvider.onResult((result) => {
|
|
740
|
+
if (!isCurrent()) return;
|
|
730
741
|
if (result.provider !== 'runtype') {
|
|
731
742
|
if (result.text && result.text.trim()) {
|
|
732
743
|
this.sendMessage(result.text, { viaVoice: true });
|
|
@@ -741,7 +752,8 @@ export class AgentWidgetSession {
|
|
|
741
752
|
// In-flight bubbles carry voiceProcessing=true so consumers can style
|
|
742
753
|
// them via messageTransform; it clears once the text is final.
|
|
743
754
|
if (this.voiceProvider.onTranscript) {
|
|
744
|
-
this.voiceProvider.onTranscript((role, text, isFinal) => {
|
|
755
|
+
this.voiceProvider.onTranscript((role, text, isFinal, metadata) => {
|
|
756
|
+
if (!isCurrent()) return;
|
|
745
757
|
if (role === 'user') {
|
|
746
758
|
if (!this.pendingVoiceUserMessageId) {
|
|
747
759
|
const msg = this.injectMessage({
|
|
@@ -763,6 +775,7 @@ export class AgentWidgetSession {
|
|
|
763
775
|
}
|
|
764
776
|
|
|
765
777
|
if (isFinal) {
|
|
778
|
+
this.voiceTurns.start(metadata?.turnId);
|
|
766
779
|
// User finished: the agent is now thinking. Release the user
|
|
767
780
|
// bubble (a new interim starts a fresh turn) and show a typing
|
|
768
781
|
// indicator in a fresh assistant placeholder.
|
|
@@ -777,6 +790,7 @@ export class AgentWidgetSession {
|
|
|
777
790
|
this.setStreaming(true);
|
|
778
791
|
}
|
|
779
792
|
} else {
|
|
793
|
+
if (!this.voiceTurns.accepts(metadata?.turnId)) return;
|
|
780
794
|
// assistant: runtype sends a single final; the isFinal=false path
|
|
781
795
|
// is reserved for delta-streaming providers (future BYO).
|
|
782
796
|
if (this.pendingVoiceAssistantMessageId) {
|
|
@@ -816,6 +830,7 @@ export class AgentWidgetSession {
|
|
|
816
830
|
// not forwarded per callback: the UI samples it on its own frame loop.
|
|
817
831
|
if (this.voiceProvider.onLevel) {
|
|
818
832
|
this.voiceProvider.onLevel((level) => {
|
|
833
|
+
if (!isCurrent()) return;
|
|
819
834
|
this.voiceLevel = Number.isFinite(level)
|
|
820
835
|
? Math.max(0, Math.min(1, level))
|
|
821
836
|
: 0;
|
|
@@ -825,11 +840,13 @@ export class AgentWidgetSession {
|
|
|
825
840
|
// Surface per-turn latency metrics to the optional config hook.
|
|
826
841
|
if (this.voiceProvider.onMetrics) {
|
|
827
842
|
this.voiceProvider.onMetrics((metrics) => {
|
|
843
|
+
if (!isCurrent()) return;
|
|
828
844
|
this.config.voiceRecognition?.onMetrics?.(metrics);
|
|
829
845
|
});
|
|
830
846
|
}
|
|
831
847
|
|
|
832
848
|
this.voiceProvider.onError((error) => {
|
|
849
|
+
if (!isCurrent()) return;
|
|
833
850
|
console.error('Voice error:', error);
|
|
834
851
|
|
|
835
852
|
// If error occurs while placeholders are pending, update assistant with error text
|
|
@@ -849,8 +866,12 @@ export class AgentWidgetSession {
|
|
|
849
866
|
});
|
|
850
867
|
|
|
851
868
|
this.voiceProvider.onStatusChange((status) => {
|
|
869
|
+
if (!isCurrent()) return;
|
|
852
870
|
this.voiceStatus = status;
|
|
853
871
|
this.voiceActive = status === 'listening';
|
|
872
|
+
if (status === 'listening' || status === 'idle' || status === 'disconnected') {
|
|
873
|
+
this.settlePendingVoiceTurn(status !== 'listening');
|
|
874
|
+
}
|
|
854
875
|
this.callbacks.onVoiceStatusChanged?.(status);
|
|
855
876
|
});
|
|
856
877
|
|
|
@@ -892,14 +913,44 @@ export class AgentWidgetSession {
|
|
|
892
913
|
* Cleanup voice resources
|
|
893
914
|
*/
|
|
894
915
|
public cleanupVoice() {
|
|
916
|
+
const notifyDisconnected = this.voiceProvider !== null;
|
|
895
917
|
this.voiceSetupGeneration++;
|
|
896
918
|
this.voiceSetupPromise = null;
|
|
897
919
|
if (this.voiceProvider) {
|
|
898
|
-
this.voiceProvider
|
|
920
|
+
const provider = this.voiceProvider;
|
|
899
921
|
this.voiceProvider = null;
|
|
922
|
+
this.voiceDisconnectPromise = this.disconnectVoice(provider);
|
|
900
923
|
}
|
|
901
924
|
this.voiceActive = false;
|
|
902
925
|
this.voiceStatus = 'disconnected';
|
|
926
|
+
this.settlePendingVoiceTurn(true);
|
|
927
|
+
this.voiceTurns = new VoiceTurnTracker();
|
|
928
|
+
if (notifyDisconnected) this.callbacks.onVoiceStatusChanged?.('disconnected');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private async disconnectVoice(provider: VoiceProvider): Promise<void> {
|
|
932
|
+
try {
|
|
933
|
+
await provider.disconnect();
|
|
934
|
+
} catch (error) {
|
|
935
|
+
console.error('Failed to disconnect voice:', error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private settlePendingVoiceTurn(includeUser: boolean): void {
|
|
940
|
+
const userId = includeUser ? this.pendingVoiceUserMessageId : null;
|
|
941
|
+
const assistantId = this.pendingVoiceAssistantMessageId;
|
|
942
|
+
if (!userId && !assistantId) return;
|
|
943
|
+
if (assistantId) this.voiceTurns.cancel();
|
|
944
|
+
if (includeUser) this.pendingVoiceUserMessageId = null;
|
|
945
|
+
this.pendingVoiceAssistantMessageId = null;
|
|
946
|
+
if (assistantId) this.ttsSpokenMessageIds.add(assistantId);
|
|
947
|
+
this.messages = this.messages.flatMap((message) => {
|
|
948
|
+
if (message.id !== userId && message.id !== assistantId) return [message];
|
|
949
|
+
if (message.id === assistantId && !message.content.trim()) return [];
|
|
950
|
+
return [{ ...message, streaming: false, voiceProcessing: false }];
|
|
951
|
+
});
|
|
952
|
+
this.callbacks.onMessagesChanged([...this.messages]);
|
|
953
|
+
if (assistantId) this.setStreaming(false);
|
|
903
954
|
}
|
|
904
955
|
|
|
905
956
|
/**
|
|
@@ -1088,6 +1139,7 @@ export class AgentWidgetSession {
|
|
|
1088
1139
|
|
|
1089
1140
|
/** Widget teardown: release the visitor-store subscription. */
|
|
1090
1141
|
public destroy(): void {
|
|
1142
|
+
this.cleanupVoice();
|
|
1091
1143
|
this.visitorStoreUnsubscribe?.();
|
|
1092
1144
|
this.visitorStoreUnsubscribe = null;
|
|
1093
1145
|
this.subscribedVisitorStore = null;
|
|
@@ -2103,6 +2155,15 @@ export class AgentWidgetSession {
|
|
|
2103
2155
|
public updateConfig(next: AgentWidgetConfig) {
|
|
2104
2156
|
const previousArtifactDisplay = this.config.features?.artifacts?.display;
|
|
2105
2157
|
const merged = { ...this.config, ...next };
|
|
2158
|
+
const replaceVoice = voiceConnectionChanged(this.config, merged);
|
|
2159
|
+
const replaceClient = connectionConfigChanged(this.config, merged);
|
|
2160
|
+
this.config = merged;
|
|
2161
|
+
if (replaceVoice) {
|
|
2162
|
+
this.cleanupVoice();
|
|
2163
|
+
if (merged.voiceRecognition?.enabled === true && usesSessionVoice(merged.voiceRecognition.provider)) {
|
|
2164
|
+
this.setupVoice();
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2106
2167
|
const artifactDisplayChanged =
|
|
2107
2168
|
JSON.stringify(previousArtifactDisplay) !==
|
|
2108
2169
|
JSON.stringify(merged.features?.artifacts?.display);
|
|
@@ -2114,8 +2175,7 @@ export class AgentWidgetSession {
|
|
|
2114
2175
|
// self-styling widget work: a `webmcp:*` theme tool mutates config and
|
|
2115
2176
|
// re-renders mid-turn; recreating the client there would abort the very
|
|
2116
2177
|
// turn that's restyling the widget and strand the paused execution.
|
|
2117
|
-
if (!
|
|
2118
|
-
this.config = merged;
|
|
2178
|
+
if (!replaceClient) {
|
|
2119
2179
|
this.client.updateConfig(merged);
|
|
2120
2180
|
if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
|
|
2121
2181
|
return;
|
|
@@ -2132,7 +2192,6 @@ export class AgentWidgetSession {
|
|
|
2132
2192
|
this.webMcpInflightKeys.clear();
|
|
2133
2193
|
this.webMcpResolvedKeys.clear();
|
|
2134
2194
|
const prevSSECallback = this.client.getSSEEventCallback();
|
|
2135
|
-
this.config = merged;
|
|
2136
2195
|
this.client = new AgentWidgetClient(this.config, this.historyInternals);
|
|
2137
2196
|
if (artifactDisplayChanged) this.refreshArtifactReferenceBlocks();
|
|
2138
2197
|
this.wireDefaultWebMcpConfirm();
|
|
@@ -3169,7 +3228,7 @@ export class AgentWidgetSession {
|
|
|
3169
3228
|
/**
|
|
3170
3229
|
* Resolve a paused `ask_user_question` LOCAL tool call.
|
|
3171
3230
|
*
|
|
3172
|
-
* When the server emits `
|
|
3231
|
+
* When the server emits `await` for `ask_user_question`, the widget
|
|
3173
3232
|
* renders the answer-pill sheet and calls this method once the user
|
|
3174
3233
|
* picks. Steps:
|
|
3175
3234
|
* 1. POST the answer to `/resume` via `client.resumeFlow`.
|
|
@@ -3389,10 +3448,10 @@ export class AgentWidgetSession {
|
|
|
3389
3448
|
}
|
|
3390
3449
|
|
|
3391
3450
|
/**
|
|
3392
|
-
* Collect an auto-resolving LOCAL-tool `
|
|
3451
|
+
* Collect an auto-resolving LOCAL-tool `await` (`webmcp:*` page tools
|
|
3393
3452
|
* and the built-in `suggest_replies`) into a per-executionId batch
|
|
3394
3453
|
* and schedule a single deferred flush. Parallel calls (core#3878) emit
|
|
3395
|
-
* several `
|
|
3454
|
+
* several `await`s for ONE paused execution within the same stream tick;
|
|
3396
3455
|
* buffering them and flushing once lets us post ONE `/resume` keyed by the
|
|
3397
3456
|
* per-call `webMcpToolCallId` rather than racing N name-keyed resumes on the
|
|
3398
3457
|
* same execution (which 404'd on the second and hung the turn).
|
|
@@ -3423,14 +3482,14 @@ export class AgentWidgetSession {
|
|
|
3423
3482
|
batch = { snapshots: [], seen: new Set() };
|
|
3424
3483
|
this.webMcpAwaitBatches.set(executionId, batch);
|
|
3425
3484
|
}
|
|
3426
|
-
// Duplicate
|
|
3485
|
+
// Duplicate await re-emit for a call already in this batch: ignore.
|
|
3427
3486
|
if (batch.seen.has(callId)) return;
|
|
3428
3487
|
batch.seen.add(callId);
|
|
3429
3488
|
batch.snapshots.push(toolMessage);
|
|
3430
3489
|
// NB: no flush is scheduled here. Flushing happens once the stream that is
|
|
3431
3490
|
// delivering these awaits ENDS (handleEvent's `status: idle` →
|
|
3432
3491
|
// scheduleWebMcpBatchFlush). Flushing per-await on the next microtask would
|
|
3433
|
-
// race SSE chunk boundaries: two PARALLEL
|
|
3492
|
+
// race SSE chunk boundaries: two PARALLEL awaits split across separate
|
|
3434
3493
|
// `read()` chunks would flush the first alone and post a partial resume.
|
|
3435
3494
|
// Waiting for stream end guarantees every parallel await is collected first.
|
|
3436
3495
|
}
|
|
@@ -3438,7 +3497,7 @@ export class AgentWidgetSession {
|
|
|
3438
3497
|
/**
|
|
3439
3498
|
* Flush every buffered local-tool await batch, one `/resume` per executionId.
|
|
3440
3499
|
* Called once a stream ends (`status: idle` / `error`): by then all parallel
|
|
3441
|
-
* `
|
|
3500
|
+
* `await`s the stream carried have been collected, even if split across
|
|
3442
3501
|
* SSE chunks. Deferred via `queueMicrotask` (epoch-guarded) so the idle
|
|
3443
3502
|
* handler returns first and the stream's end-of-stream teardown (streaming /
|
|
3444
3503
|
* abortController) settles before a resolve grabs them: the same ordering the
|
|
@@ -3495,7 +3554,7 @@ export class AgentWidgetSession {
|
|
|
3495
3554
|
* Persisted-resolution guard for `suggest_replies`. The in-memory dedupe
|
|
3496
3555
|
* sets (`webMcpInflightKeys` / `webMcpResolvedKeys`) are cleared by
|
|
3497
3556
|
* hydrateMessages/clearMessages/cancel, but `suggestRepliesResolved`
|
|
3498
|
-
* survives on the stored message, so a stale `
|
|
3557
|
+
* survives on the stored message, so a stale `await` re-emit after a
|
|
3499
3558
|
* hydration must not re-POST `/resume` for an already-resolved call (the
|
|
3500
3559
|
* historical double-resume failure mode the batching work exists to avoid).
|
|
3501
3560
|
* Checks the LIVE message first; the handleEvent snapshot is a fresh wire
|
|
@@ -3631,7 +3690,7 @@ export class AgentWidgetSession {
|
|
|
3631
3690
|
claimedKeys.push(dedupeKey);
|
|
3632
3691
|
|
|
3633
3692
|
// Clear the awaiting flag and keep the tool bubble running while the
|
|
3634
|
-
// browser-side WebMCP promise is in flight. The initial `
|
|
3693
|
+
// browser-side WebMCP promise is in flight. The initial `await`
|
|
3635
3694
|
// only means the server paused for a local tool; it is not completion.
|
|
3636
3695
|
const startedAt = this.markWebMcpToolRunning(toolMessage);
|
|
3637
3696
|
|
|
@@ -3809,12 +3868,12 @@ export class AgentWidgetSession {
|
|
|
3809
3868
|
* resumes with a canned "shown" result (the chips render from the message
|
|
3810
3869
|
* list, not from this resolve).
|
|
3811
3870
|
*
|
|
3812
|
-
* Triggered automatically from `handleEvent` when
|
|
3871
|
+
* Triggered automatically from `handleEvent` when an `await`-derived
|
|
3813
3872
|
* message arrives for such a tool: the user does not click a pill; the
|
|
3814
3873
|
* bridge's confirm-bubble gate (WebMCP only) is the only interactive
|
|
3815
3874
|
* surface.
|
|
3816
3875
|
*
|
|
3817
|
-
* Idempotent on the message's `toolCall.id`: re-emits of the same
|
|
3876
|
+
* Idempotent on the message's `toolCall.id`: re-emits of the same await
|
|
3818
3877
|
* (e.g. from message coalescing) won't double-fire `tool.execute`. Failure
|
|
3819
3878
|
* modes, declined, timed out, throw, unknown tool, all resolve into a
|
|
3820
3879
|
* `{ isError: true, content: [...] }` payload that resumes the dispatch
|
|
@@ -3831,7 +3890,7 @@ export class AgentWidgetSession {
|
|
|
3831
3890
|
const wireToolName = toolMessage.toolCall?.name;
|
|
3832
3891
|
const toolCallId = toolMessage.toolCall?.id;
|
|
3833
3892
|
|
|
3834
|
-
// Malformed
|
|
3893
|
+
// Malformed await wire shapes shouldn't silently strand the
|
|
3835
3894
|
// server-side dispatch. Three failure modes:
|
|
3836
3895
|
// - no executionId: no /resume target exists; surface to the host
|
|
3837
3896
|
// via onError so an operator can react. This is a server-side
|
|
@@ -3846,7 +3905,7 @@ export class AgentWidgetSession {
|
|
|
3846
3905
|
if (!executionId) {
|
|
3847
3906
|
this.callbacks.onError?.(
|
|
3848
3907
|
new Error(
|
|
3849
|
-
"WebMCP
|
|
3908
|
+
"WebMCP await missing executionId: dispatch left paused.",
|
|
3850
3909
|
),
|
|
3851
3910
|
);
|
|
3852
3911
|
return;
|
|
@@ -3870,7 +3929,7 @@ export class AgentWidgetSession {
|
|
|
3870
3929
|
content: [
|
|
3871
3930
|
{
|
|
3872
3931
|
type: "text",
|
|
3873
|
-
text: "WebMCP
|
|
3932
|
+
text: "WebMCP await missing toolCall.id: cannot execute the page tool.",
|
|
3874
3933
|
},
|
|
3875
3934
|
],
|
|
3876
3935
|
});
|
|
@@ -3992,7 +4051,7 @@ export class AgentWidgetSession {
|
|
|
3992
4051
|
this.teardownReconnect();
|
|
3993
4052
|
// Tear down every in-flight WebMCP resolve (each owns its own controller,
|
|
3994
4053
|
// independent of the shared one above). Clear the inflight set so retries
|
|
3995
|
-
// are possible if the user re-issues the same
|
|
4054
|
+
// are possible if the user re-issues the same await context.
|
|
3996
4055
|
this.abortWebMcpResolves();
|
|
3997
4056
|
this.webMcpInflightKeys.clear();
|
|
3998
4057
|
// Stop any in-progress audio too: when the user hits "stop", they want
|
|
@@ -4283,7 +4342,7 @@ export class AgentWidgetSession {
|
|
|
4283
4342
|
this.activeAssistantMessageId = event.message.id;
|
|
4284
4343
|
}
|
|
4285
4344
|
|
|
4286
|
-
// Local-tool auto-resolve: when a
|
|
4345
|
+
// Local-tool auto-resolve: when a await emits a tool-variant
|
|
4287
4346
|
// message for a `webmcp:*` tool, or the built-in fire-and-forget
|
|
4288
4347
|
// `suggest_replies`: resolve it and post the result to /resume.
|
|
4289
4348
|
// Unlike ask_user_question, no user pill click is required; for WebMCP
|
|
@@ -4294,7 +4353,7 @@ export class AgentWidgetSession {
|
|
|
4294
4353
|
// Defer via `queueMicrotask` so handleEvent returns FIRST. The current
|
|
4295
4354
|
// SSE consumer is still mid-loop; once we return, the dispatch's
|
|
4296
4355
|
// `connectStream` sees end-of-stream (server closes the SSE at
|
|
4297
|
-
//
|
|
4356
|
+
// await), flips status to "idle", and clears `abortController`
|
|
4298
4357
|
// before our resolve grabs them. Without this, the original dispatch's
|
|
4299
4358
|
// finalizer would clobber the new abort controller and `streaming=true`
|
|
4300
4359
|
// set inside `resolveWebMcpToolCall`.
|
|
@@ -4319,7 +4378,7 @@ export class AgentWidgetSession {
|
|
|
4319
4378
|
) {
|
|
4320
4379
|
// Collect the await into its executionId's batch instead of resolving
|
|
4321
4380
|
// it on the spot. Parallel same-tool calls (core#3878) arrive as
|
|
4322
|
-
// separate `
|
|
4381
|
+
// separate `await`s in the same stream; batching lets us post ONE
|
|
4323
4382
|
// `/resume` keyed by per-call id (see `enqueueWebMcpAwait`).
|
|
4324
4383
|
this.enqueueWebMcpAwait(event.message);
|
|
4325
4384
|
}
|
|
@@ -4348,7 +4407,7 @@ export class AgentWidgetSession {
|
|
|
4348
4407
|
} else if (event.type === "status") {
|
|
4349
4408
|
// A plain `idle` (no `terminal`) on the durable lane, while the run is
|
|
4350
4409
|
// genuinely mid-turn, is a dropped connection, not a finish and not an
|
|
4351
|
-
// intentional `
|
|
4410
|
+
// intentional `await` pause. Reconnect instead of finalizing.
|
|
4352
4411
|
if (event.status === "idle" && !event.terminal && this.isDurableDrop()) {
|
|
4353
4412
|
this.beginReconnect();
|
|
4354
4413
|
return;
|
|
@@ -4391,7 +4450,7 @@ export class AgentWidgetSession {
|
|
|
4391
4450
|
this.agentExecution.status = 'complete';
|
|
4392
4451
|
}
|
|
4393
4452
|
}
|
|
4394
|
-
// The stream that delivered any local-tool `
|
|
4453
|
+
// The stream that delivered any local-tool `await`s has now ended,
|
|
4395
4454
|
// so every parallel await it carried is collected. Flush them as ONE
|
|
4396
4455
|
// batched `/resume` per executionId (deferred: see
|
|
4397
4456
|
// scheduleWebMcpBatchFlush). Runs AFTER the teardown above so a resolve
|
|
@@ -4455,7 +4514,7 @@ export class AgentWidgetSession {
|
|
|
4455
4514
|
|
|
4456
4515
|
/**
|
|
4457
4516
|
* The drop gate: is this stream-end a dropped connection we should reconnect,
|
|
4458
|
-
* rather than a graceful finish or an intentional `
|
|
4517
|
+
* rather than a graceful finish or an intentional `await` pause? ALL must
|
|
4459
4518
|
* hold (see plan §Design overview keystone 3).
|
|
4460
4519
|
*/
|
|
4461
4520
|
private isDurableDrop(): boolean {
|
|
@@ -4857,7 +4916,7 @@ export class AgentWidgetSession {
|
|
|
4857
4916
|
// suggest_replies equivalent: preserve the persisted fire-and-forget
|
|
4858
4917
|
// resolution across re-emissions. It is the only dedupe signal that
|
|
4859
4918
|
// survives a hydration (the in-memory key sets are cleared), so a
|
|
4860
|
-
// stale
|
|
4919
|
+
// stale await re-emit must not wipe it before
|
|
4861
4920
|
// `isSuggestRepliesAlreadyResolved` checks it in the resolve path.
|
|
4862
4921
|
if (
|
|
4863
4922
|
existing.agentMetadata?.suggestRepliesResolved === true &&
|
|
@@ -4898,11 +4957,11 @@ export class AgentWidgetSession {
|
|
|
4898
4957
|
}
|
|
4899
4958
|
// Auto-resolved local-tool equivalent (`webmcp:*` and the built-in
|
|
4900
4959
|
// `suggest_replies`): once such a tool has started resolving (inflight)
|
|
4901
|
-
// or resolved, a duplicate `
|
|
4960
|
+
// or resolved, a duplicate `await` re-emit must not flip
|
|
4902
4961
|
// `awaitingLocalTool` back to true and resurrect the "waiting on
|
|
4903
4962
|
// local tool" UI. It also must not overwrite an existing running or
|
|
4904
4963
|
// completed toolCall with the fresh running skeleton emitted by client.ts
|
|
4905
|
-
// for every
|
|
4964
|
+
// for every await. resolveWebMcpToolCall's dedupe path returns
|
|
4906
4965
|
// without re-touching the message, so correct the merge here (also avoids
|
|
4907
4966
|
// a one-frame flash before that microtask runs).
|
|
4908
4967
|
const reTcName = withSequence.toolCall?.name;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The widget can advertise this tool to the agent on every dispatch via
|
|
5
5
|
* `clientTools[]` (set `suggestions.followUps.expose: true`): the same
|
|
6
6
|
* wire surface as `ask_user_question` and WebMCP page tools. When the model
|
|
7
|
-
* calls it, the execution pauses with
|
|
7
|
+
* calls it, the execution pauses with an `await` (a local-tool pause
|
|
8
8
|
* carries no `awaitReason`; that discriminator is internal). Unlike
|
|
9
9
|
* `ask_user_question`, the widget resolves it FIRE-AND-FORGET: it renders the
|
|
10
10
|
* configured follow-up surface and immediately resumes the execution with a
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AgentWidgetClient } from "../client";
|
|
3
|
+
import type { AgentWidgetEvent, AgentWidgetMessage } from "../types";
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
buildAssistantTurnFrames,
|
|
@@ -21,15 +23,15 @@ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
|
21
23
|
describe("createMockSSEStream", () => {
|
|
22
24
|
it("emits bare `data:` frames by default", async () => {
|
|
23
25
|
const frames = [
|
|
24
|
-
{ type: "
|
|
25
|
-
{ type: "
|
|
26
|
-
{ type: "
|
|
26
|
+
{ type: "text_start", executionId: "e-1", turnId: "t-1" },
|
|
27
|
+
{ type: "text_delta", executionId: "e-1", turnId: "t-1", delta: "hi" },
|
|
28
|
+
{ type: "text_complete", executionId: "e-1", turnId: "t-1" },
|
|
27
29
|
];
|
|
28
30
|
const text = await readAll(createMockSSEStream(frames, { delayMs: 0 }));
|
|
29
31
|
|
|
30
32
|
expect(text).not.toContain("event:");
|
|
31
33
|
expect(text.split("\n\n").filter(Boolean)).toHaveLength(3);
|
|
32
|
-
expect(text).toContain('"type":"
|
|
34
|
+
expect(text).toContain('"type":"text_delta"');
|
|
33
35
|
expect(text).toContain('"delta":"hi"');
|
|
34
36
|
});
|
|
35
37
|
|
|
@@ -43,7 +45,7 @@ describe("createMockSSEStream", () => {
|
|
|
43
45
|
it("rejects reads with AbortError once the signal aborts, like a real fetch", async () => {
|
|
44
46
|
const abort = new AbortController();
|
|
45
47
|
const frames = Array.from({ length: 5 }, (_, i) => ({
|
|
46
|
-
type: "
|
|
48
|
+
type: "text_delta",
|
|
47
49
|
delta: `chunk-${i}`,
|
|
48
50
|
}));
|
|
49
51
|
const reader = createMockSSEStream(frames, {
|
|
@@ -69,26 +71,45 @@ describe("buildAssistantTurnFrames", () => {
|
|
|
69
71
|
chunkSize: 4,
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
expect(frames
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const deltas = frames.filter((f) => f.type === "agent_turn_delta");
|
|
74
|
+
expect(frames.map((f) => f.type)).toEqual([
|
|
75
|
+
"turn_start", "text_start", "text_delta", "text_delta", "text_delta", "text_complete", "turn_complete",
|
|
76
|
+
]);
|
|
77
|
+
expect(frames[0]).toMatchObject({ id: "turn-1", role: "assistant" });
|
|
78
|
+
const deltas = frames.filter((f) => f.type === "text_delta");
|
|
80
79
|
expect(deltas.map((f) => f.delta)).toEqual(["abcd", "efgh", "ij"]);
|
|
81
|
-
expect(deltas.every((f) => f.executionId === "exec-1" && f.
|
|
80
|
+
expect(deltas.every((f) => f.executionId === "exec-1" && f.id === "turn-1-text")).toBe(true);
|
|
81
|
+
expect(frames.at(-2)).toMatchObject({ id: "turn-1-text", text: "abcdefghij" });
|
|
82
|
+
expect(frames.at(-1)).toMatchObject({ id: "turn-1", content: "abcdefghij" });
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
it("defaults turnId and chunkSize", () => {
|
|
85
86
|
const frames = buildAssistantTurnFrames({ executionId: "exec-2", text: "hello" });
|
|
86
|
-
expect(frames[0].
|
|
87
|
-
const deltaCount = frames.filter((f) => f.type === "
|
|
87
|
+
expect(frames[0].id).toBe("turn-1");
|
|
88
|
+
const deltaCount = frames.filter((f) => f.type === "text_delta").length;
|
|
88
89
|
expect(deltaCount).toBeGreaterThanOrEqual(1);
|
|
89
90
|
});
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
it("renders helper-generated turns through the real client without duplicating completed text", async () => {
|
|
94
|
+
const events: AgentWidgetEvent[] = [];
|
|
95
|
+
const client = new AgentWidgetClient({
|
|
96
|
+
apiUrl: "https://example.test/chat",
|
|
97
|
+
customFetch: async () => createMockSSEResponse([
|
|
98
|
+
{ type: "execution_start", executionId: "exec-1", kind: "agent" },
|
|
99
|
+
...buildAssistantTurnFrames({ executionId: "exec-1", turnId: "turn-1", text: "First reply", chunkSize: 3 }),
|
|
100
|
+
...buildAssistantTurnFrames({ executionId: "exec-1", turnId: "turn-2", text: "Second reply", chunkSize: 4 }),
|
|
101
|
+
{ type: "execution_complete", executionId: "exec-1", kind: "agent", success: true },
|
|
102
|
+
], { delayMs: 0 }),
|
|
103
|
+
});
|
|
104
|
+
await client.dispatch({ messages: [] }, (event) => events.push(event));
|
|
105
|
+
const messages = new Map<string, AgentWidgetMessage>();
|
|
106
|
+
for (const event of events) {
|
|
107
|
+
if (event.type === "message") messages.set(event.message.id, { ...event.message });
|
|
108
|
+
}
|
|
109
|
+
expect([...messages.values()].map((message) => message.content)).toEqual(["First reply", "Second reply"]);
|
|
110
|
+
expect([...messages.values()].every((message) => !message.streaming)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
describe("createMockSSEResponse", () => {
|
|
93
114
|
it("wraps the stream in a text/event-stream Response", async () => {
|
|
94
115
|
const res = createMockSSEResponse([{ type: "ping" }], { delayMs: 0 });
|
|
@@ -66,30 +66,35 @@ export interface AssistantTurnFramesOptions {
|
|
|
66
66
|
turnId?: string;
|
|
67
67
|
/** Assistant text content to stream. */
|
|
68
68
|
text: string;
|
|
69
|
-
/** Approximate characters per `
|
|
69
|
+
/** Approximate characters per `text_delta` frame. Default: 32. */
|
|
70
70
|
chunkSize?: number;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Builds
|
|
75
|
-
*
|
|
76
|
-
* client pipeline as real SSE, so stream animations (typewriter, word-fade, etc.) engage.
|
|
74
|
+
* Builds a unified assistant turn with a text block. Execution lifecycle frames
|
|
75
|
+
* can be supplied by the caller when composing a multi-turn execution.
|
|
77
76
|
*/
|
|
78
77
|
export function buildAssistantTurnFrames(options: AssistantTurnFramesOptions): MockSSEFrame[] {
|
|
79
78
|
const { executionId, text } = options;
|
|
80
79
|
const turnId = options.turnId ?? "turn-1";
|
|
80
|
+
const textId = `${turnId}-text`;
|
|
81
81
|
const chunkSize = Math.max(1, options.chunkSize ?? 32);
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
const frames: MockSSEFrame[] = [
|
|
83
|
+
{ type: "turn_start", executionId, id: turnId, role: "assistant" },
|
|
84
|
+
{ type: "text_start", executionId, id: textId, turnId, role: "assistant" },
|
|
85
|
+
];
|
|
84
86
|
for (let i = 0; i < text.length; i += chunkSize) {
|
|
85
87
|
frames.push({
|
|
86
|
-
type: "
|
|
88
|
+
type: "text_delta",
|
|
87
89
|
executionId,
|
|
88
|
-
|
|
90
|
+
id: textId,
|
|
89
91
|
delta: text.slice(i, i + chunkSize),
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
|
-
frames.push(
|
|
94
|
+
frames.push(
|
|
95
|
+
{ type: "text_complete", executionId, id: textId, text },
|
|
96
|
+
{ type: "turn_complete", executionId, id: turnId, role: "assistant", content: text },
|
|
97
|
+
);
|
|
93
98
|
return frames;
|
|
94
99
|
}
|
|
95
100
|
|