@runtypelabs/persona 4.21.1 → 4.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-B_JdVK9o.d.cts → types-9Vxm-PPz.d.cts} +136 -6
  4. package/dist/animations/{types-B_JdVK9o.d.ts → types-9Vxm-PPz.d.ts} +136 -6
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +136 -6
  8. package/dist/animations-extra.d.ts +136 -6
  9. package/dist/{approval-ui-RNNBR5R3.js → approval-ui-K7R5Q5OC.js} +1 -1
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +221 -16
  12. package/dist/approval-ui.d.ts +221 -16
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/artifacts-ui-F4N6VMKF.js +7 -0
  15. package/dist/artifacts-ui.cjs +1 -1
  16. package/dist/artifacts-ui.d.cts +221 -16
  17. package/dist/artifacts-ui.d.ts +221 -16
  18. package/dist/artifacts-ui.js +1 -1
  19. package/dist/chunk-NQLDCJAP.js +4 -0
  20. package/dist/codegen.cjs +1 -1
  21. package/dist/codegen.js +1 -1
  22. package/dist/context-mentions.d.cts +221 -16
  23. package/dist/context-mentions.d.ts +221 -16
  24. package/dist/event-stream-view-GL3ADRY2.js +1 -0
  25. package/dist/event-stream-view.cjs +1 -1
  26. package/dist/event-stream-view.d.cts +221 -16
  27. package/dist/event-stream-view.d.ts +221 -16
  28. package/dist/event-stream-view.js +1 -1
  29. package/dist/forms-ui.d.cts +234 -25
  30. package/dist/forms-ui.d.ts +234 -25
  31. package/dist/history-view.d.cts +136 -6
  32. package/dist/history-view.d.ts +136 -6
  33. package/dist/index.cjs +70 -70
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +302 -32
  36. package/dist/index.d.ts +302 -32
  37. package/dist/index.global.js +54 -54
  38. package/dist/index.global.js.map +1 -1
  39. package/dist/index.js +69 -69
  40. package/dist/index.js.map +1 -1
  41. package/dist/launcher.global.js +2 -2
  42. package/dist/launcher.global.js.map +1 -1
  43. package/dist/session-reconnect.d.cts +221 -16
  44. package/dist/session-reconnect.d.ts +221 -16
  45. package/dist/smart-dom-reader.d.cts +221 -16
  46. package/dist/smart-dom-reader.d.ts +221 -16
  47. package/dist/testing.cjs +3 -3
  48. package/dist/testing.d.cts +3 -4
  49. package/dist/testing.d.ts +3 -4
  50. package/dist/testing.js +3 -3
  51. package/dist/theme-editor-preview.cjs +73 -73
  52. package/dist/theme-editor-preview.d.cts +221 -16
  53. package/dist/theme-editor-preview.d.ts +221 -16
  54. package/dist/theme-editor-preview.js +51 -51
  55. package/dist/theme-editor.cjs +4 -4
  56. package/dist/theme-editor.d.cts +221 -16
  57. package/dist/theme-editor.d.ts +221 -16
  58. package/dist/theme-editor.js +2 -2
  59. package/dist/theme-reference.cjs +1 -1
  60. package/dist/theme-reference.d.cts +65 -1
  61. package/dist/theme-reference.d.ts +65 -1
  62. package/dist/theme-reference.js +1 -1
  63. package/dist/voice-runtime.d.cts +6 -2
  64. package/dist/voice-runtime.d.ts +6 -2
  65. package/dist/webmcp-polyfill.js +1 -4
  66. package/dist/webmcp-runtime-JI7T4QYF.js +6 -0
  67. package/dist/webmcp-runtime.cjs +2 -2
  68. package/dist/webmcp-runtime.d.cts +34 -2
  69. package/dist/webmcp-runtime.d.ts +34 -2
  70. package/dist/webmcp-runtime.js +2 -2
  71. package/package.json +2 -2
  72. package/src/ask-user-question-tool.ts +1 -1
  73. package/src/client-history-rest.test.ts +2 -1
  74. package/src/client-visitor-history.test.ts +20 -4
  75. package/src/client.composer-options.test.ts +1 -1
  76. package/src/client.interrupt.test.ts +2 -2
  77. package/src/client.test.ts +477 -915
  78. package/src/client.ts +134 -494
  79. package/src/client.unified-tools.test.ts +76 -0
  80. package/src/components/artifact-preview.ts +5 -0
  81. package/src/components/event-stream-badge-theming.test.ts +162 -0
  82. package/src/components/event-stream-view.test.ts +76 -76
  83. package/src/components/event-stream-view.ts +55 -20
  84. package/src/components/tool-bubble.test.ts +12 -0
  85. package/src/components/tool-bubble.ts +13 -2
  86. package/src/event-stream-view-bundle.test.ts +2 -2
  87. package/src/generated/runtype-openapi-contract.ts +137 -1
  88. package/src/index-core.ts +2 -0
  89. package/src/internal/runtype-history-provider.test.ts +2 -2
  90. package/src/runtime/init.registry.test.ts +56 -0
  91. package/src/runtime/init.ts +17 -0
  92. package/src/session-history.test.ts +1 -1
  93. package/src/session.quote.test.ts +1 -1
  94. package/src/session.resubmit-from.test.ts +1 -1
  95. package/src/session.test.ts +5 -5
  96. package/src/session.ts +95 -36
  97. package/src/suggest-replies-tool.ts +1 -1
  98. package/src/testing/mock-stream.test.ts +37 -16
  99. package/src/testing/mock-stream.ts +14 -9
  100. package/src/types/theme.ts +41 -1
  101. package/src/types.ts +57 -15
  102. package/src/ui.ask-user-question-plugin.test.ts +7 -7
  103. package/src/ui.composer-defer-one.test.ts +6 -34
  104. package/src/ui.composer-draft-persistence.test.ts +1 -1
  105. package/src/ui.composer-quote.test.ts +1 -1
  106. package/src/ui.message-history-actions.test.ts +1 -1
  107. package/src/ui.reasoning-display.test.ts +136 -2
  108. package/src/ui.streaming-coalescing.test.ts +10 -13
  109. package/src/ui.ts +101 -35
  110. package/src/ui.typing-indicator.test.ts +216 -0
  111. package/src/ui.voice-custom-provider.test.ts +211 -0
  112. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +1740 -0
  113. package/src/utils/event-stream-buffer.test.ts +13 -13
  114. package/src/utils/event-stream-capture.test.ts +20 -20
  115. package/src/utils/formatting.ts +22 -0
  116. package/src/utils/theme.ts +16 -0
  117. package/src/utils/throughput-tracker.test.ts +100 -142
  118. package/src/utils/throughput-tracker.ts +18 -132
  119. package/src/utils/tokens.ts +59 -0
  120. package/src/utils/voice-support.ts +18 -1
  121. package/src/voice/voice-turn-tracker.ts +18 -0
  122. package/src/webmcp-bridge.test.ts +132 -23
  123. package/src/webmcp-bridge.ts +27 -3
  124. package/src/webmcp-runtime-entry.ts +71 -11
  125. package/dist/artifacts-ui-J6AKUGTS.js +0 -7
  126. package/dist/chunk-HBE7LFNT.js +0 -4
  127. package/dist/event-stream-view-73NWKWVQ.js +0 -1
  128. package/dist/webmcp-runtime-P3CHZRHF.js +0 -6
  129. 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 `step_await` the widget resolves automatically (no user
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 step_await re-emits
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
- // step_await resolves in one turn never abort one another. The shared
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
- // step_await inside A's resume SSE stream) the shared controller is still
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 `step_await(local_tool_required)` events for ONE paused
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 step_await re-emits within a batch. Cleared on
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.disconnect();
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 (!connectionConfigChanged(this.config, merged)) {
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 `step_await` for `ask_user_question`, the widget
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 `step_await` (`webmcp:*` page tools
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 `step_await`s for ONE paused execution within the same stream tick;
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 step_await re-emit for a call already in this batch: ignore.
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 step_awaits split across separate
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
- * `step_await`s the stream carried have been collected, even if split across
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 `step_await` re-emit after a
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 `step_await`
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 a `step_await`-derived
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 step_await
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 step_await wire shapes shouldn't silently strand the
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 step_await missing executionId: dispatch left paused.",
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 step_await missing toolCall.id: cannot execute the page tool.",
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 step_await context.
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 step_await emits a tool-variant
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
- // step_await), flips status to "idle", and clears `abortController`
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 `step_await`s in the same stream; batching lets us post ONE
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 `step_await` pause. Reconnect instead of finalizing.
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 `step_await`s has now ended,
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 `step_await` pause? ALL must
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 step_await re-emit must not wipe it before
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 `step_await` re-emit must not flip
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 step_await. resolveWebMcpToolCall's dedupe path returns
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 a `step_await` (a local-tool pause
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: "agent_turn_start", executionId: "e-1", turnId: "t-1" },
25
- { type: "agent_turn_delta", executionId: "e-1", turnId: "t-1", delta: "hi" },
26
- { type: "agent_turn_complete", executionId: "e-1", turnId: "t-1" },
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":"agent_turn_delta"');
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: "agent_turn_delta",
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[0]).toEqual({ type: "agent_turn_start", executionId: "exec-1", turnId: "turn-1" });
73
- expect(frames[frames.length - 1]).toEqual({
74
- type: "agent_turn_complete",
75
- executionId: "exec-1",
76
- turnId: "turn-1",
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.turnId === "turn-1")).toBe(true);
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].turnId).toBe("turn-1");
87
- const deltaCount = frames.filter((f) => f.type === "agent_turn_delta").length;
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 `agent_turn_delta` frame. Default: 32. */
69
+ /** Approximate characters per `text_delta` frame. Default: 32. */
70
70
  chunkSize?: number;
71
71
  }
72
72
 
73
73
  /**
74
- * Builds the standard `agent_turn_start` → many `agent_turn_delta` → `agent_turn_complete`
75
- * frame sequence for simulating a streaming assistant reply. The frames drive the same
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
- const frames: MockSSEFrame[] = [{ type: "agent_turn_start", executionId, turnId }];
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: "agent_turn_delta",
88
+ type: "text_delta",
87
89
  executionId,
88
- turnId,
90
+ id: textId,
89
91
  delta: text.slice(i, i + chunkSize),
90
92
  });
91
93
  }
92
- frames.push({ type: "agent_turn_complete", executionId, turnId });
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
 
@@ -26,7 +26,11 @@ export interface ColorPalette {
26
26
  warning: ColorShade;
27
27
  error: ColorShade;
28
28
  info: ColorShade;
29
- [key: string]: ColorShade;
29
+ /** Event-stream tool badge family. */
30
+ purple?: ColorShade;
31
+ /** Event-stream agent badge family. */
32
+ teal?: ColorShade;
33
+ [key: string]: ColorShade | undefined;
30
34
  }
31
35
 
32
36
  export interface SpacingScale {
@@ -628,6 +632,40 @@ export interface ReasoningBubbleTokens {
628
632
  shadow: string;
629
633
  }
630
634
 
635
+ /** One event-stream badge chip: fill + text pair. The chip border derives
636
+ * from `foreground` at ~31% alpha. */
637
+ export interface EventStreamBadgePairTokens {
638
+ background: TokenReference<'color'>;
639
+ foreground: TokenReference<'color'>;
640
+ }
641
+
642
+ /**
643
+ * Event-stream inspector badge chips (`features.eventStream`), one pair per
644
+ * unified event family. Materialized into every theme and emitted as
645
+ * `--persona-event-badge-<family>-{bg,fg}`, with scheme-appropriate defaults:
646
+ * light themes pair a 100-tone fill with a 700-tone text, the built-in dark
647
+ * layer flips to a 900-tone fill with a 300-tone text. `badge.default` colors
648
+ * event types no family matches.
649
+ */
650
+ export interface EventStreamTokens {
651
+ badge: {
652
+ /** `flow_*` events. */
653
+ flow: EventStreamBadgePairTokens;
654
+ /** `step_*` events. */
655
+ step: EventStreamBadgePairTokens;
656
+ /** `reasoning_*` events. */
657
+ reasoning: EventStreamBadgePairTokens;
658
+ /** `tool_*` events. */
659
+ tool: EventStreamBadgePairTokens;
660
+ /** `agent_*` events. */
661
+ agent: EventStreamBadgePairTokens;
662
+ /** `error` / `execution_error` events. */
663
+ error: EventStreamBadgePairTokens;
664
+ /** Any event type no other family matches. */
665
+ default: EventStreamBadgePairTokens;
666
+ };
667
+ }
668
+
631
669
  /** Scrollbar appearance shared by every scroller in the widget. */
632
670
  export interface ScrollbarTokens {
633
671
  /** Thumb color. @default semantic.colors.border */
@@ -994,6 +1032,8 @@ export interface ComponentTokens {
994
1032
  attachment: AttachmentTokens;
995
1033
  toolBubble: ToolBubbleTokens;
996
1034
  reasoningBubble: ReasoningBubbleTokens;
1035
+ /** Event-stream inspector badge chips. */
1036
+ eventStream?: EventStreamTokens;
997
1037
  composer: ComposerChromeTokens;
998
1038
  /** Shared motion timing for composer chrome animation. */
999
1039
  motion?: MotionTokens;