@runtypelabs/persona 3.20.0 → 3.21.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.
@@ -642,4 +642,155 @@ describe('AgentWidgetSession.resolveAskUserQuestion', () => {
642
642
  expect(errors.length).toBe(1);
643
643
  expect(errors[0].message).toMatch(/executionId/);
644
644
  });
645
+
646
+ it('flips streaming=true BEFORE the resumeFlow fetch resolves (so the typing indicator shows during the silent gap)', async () => {
647
+ const awaiting = makeAwaitingMessage();
648
+
649
+ // Defer fetch resolution so we can observe state mid-await.
650
+ let resolveFetch!: (value: unknown) => void;
651
+ const fetchPromise = new Promise((res) => { resolveFetch = res; });
652
+ global.fetch = vi.fn().mockImplementation(() => fetchPromise);
653
+
654
+ const streamingEvents: boolean[] = [];
655
+ const session = new AgentWidgetSession(
656
+ {
657
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
658
+ initialMessages: [awaiting],
659
+ },
660
+ {
661
+ onMessagesChanged: () => {},
662
+ onStatusChanged: () => {},
663
+ onStreamingChanged: (s) => { streamingEvents.push(s); },
664
+ }
665
+ );
666
+
667
+ vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
668
+
669
+ // Kick off — don't await.
670
+ const resolvePromise = session.resolveAskUserQuestion(awaiting, 'Hobbyists');
671
+
672
+ // Yield a microtask so synchronous setup (markResolved → setStreaming(true)
673
+ // → message injection) runs before we observe.
674
+ await Promise.resolve();
675
+
676
+ expect(session.isStreaming()).toBe(true);
677
+ expect(streamingEvents).toEqual([true]);
678
+
679
+ // Now resolve the fetch with a body so the rest of the flow runs.
680
+ const encoder = new TextEncoder();
681
+ const stream = new ReadableStream({
682
+ start(controller) {
683
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
684
+ controller.close();
685
+ },
686
+ });
687
+ resolveFetch({ ok: true, body: stream });
688
+
689
+ await resolvePromise;
690
+ });
691
+
692
+ it('flips streaming=false on the error path when resume rejects', async () => {
693
+ const awaiting = makeAwaitingMessage();
694
+
695
+ global.fetch = vi.fn().mockResolvedValue({
696
+ ok: false,
697
+ status: 500,
698
+ json: async () => ({ error: 'boom' }),
699
+ });
700
+
701
+ const streamingEvents: boolean[] = [];
702
+ const errors: Error[] = [];
703
+ const session = new AgentWidgetSession(
704
+ {
705
+ apiUrl: 'http://localhost:43111/api/chat/dispatch',
706
+ initialMessages: [awaiting],
707
+ },
708
+ {
709
+ onMessagesChanged: () => {},
710
+ onStatusChanged: () => {},
711
+ onStreamingChanged: (s) => { streamingEvents.push(s); },
712
+ onError: (e) => errors.push(e),
713
+ }
714
+ );
715
+
716
+ await session.resolveAskUserQuestion(awaiting, 'Hobbyists');
717
+
718
+ expect(streamingEvents[0]).toBe(true);
719
+ expect(streamingEvents[streamingEvents.length - 1]).toBe(false);
720
+ expect(session.isStreaming()).toBe(false);
721
+ expect(errors.length).toBe(1);
722
+ });
723
+ });
724
+
725
+ describe('AgentWidgetSession.resolveApproval', () => {
726
+ const makeApproval = () => ({
727
+ id: 'approval-1',
728
+ status: 'pending' as const,
729
+ agentId: 'agent_abc',
730
+ executionId: 'exec_abc',
731
+ toolName: 'send_email',
732
+ description: 'Send an email',
733
+ });
734
+
735
+ it('flips streaming=true BEFORE the approval fetch resolves', async () => {
736
+ let resolveFetch!: (value: unknown) => void;
737
+ const fetchPromise = new Promise((res) => { resolveFetch = res; });
738
+ global.fetch = vi.fn().mockImplementation(() => fetchPromise);
739
+
740
+ const streamingEvents: boolean[] = [];
741
+ const session = new AgentWidgetSession(
742
+ { apiUrl: 'http://localhost:43111/api/chat/dispatch' },
743
+ {
744
+ onMessagesChanged: () => {},
745
+ onStatusChanged: () => {},
746
+ onStreamingChanged: (s) => { streamingEvents.push(s); },
747
+ }
748
+ );
749
+
750
+ vi.spyOn(session, 'connectStream').mockResolvedValue(undefined);
751
+
752
+ const resolvePromise = session.resolveApproval(makeApproval(), 'approved');
753
+
754
+ await Promise.resolve();
755
+
756
+ expect(session.isStreaming()).toBe(true);
757
+ expect(streamingEvents).toEqual([true]);
758
+
759
+ // Resolve with a body so the rest of the flow runs through connectStream.
760
+ const encoder = new TextEncoder();
761
+ const stream = new ReadableStream({
762
+ start(controller) {
763
+ controller.enqueue(encoder.encode('data: {"type":"flow_complete","success":true}\n\n'));
764
+ controller.close();
765
+ },
766
+ });
767
+ resolveFetch({ ok: true, body: stream });
768
+
769
+ await resolvePromise;
770
+ });
771
+
772
+ it('flips streaming=false on the error path when approval rejects', async () => {
773
+ // The approval response goes through `instanceof Response` checks, so
774
+ // a non-Response mock that "errors" needs to be modeled as a thrown fetch.
775
+ global.fetch = vi.fn().mockRejectedValue(new Error('network boom'));
776
+
777
+ const streamingEvents: boolean[] = [];
778
+ const errors: Error[] = [];
779
+ const session = new AgentWidgetSession(
780
+ { apiUrl: 'http://localhost:43111/api/chat/dispatch' },
781
+ {
782
+ onMessagesChanged: () => {},
783
+ onStatusChanged: () => {},
784
+ onStreamingChanged: (s) => { streamingEvents.push(s); },
785
+ onError: (e) => errors.push(e),
786
+ }
787
+ );
788
+
789
+ await session.resolveApproval(makeApproval(), 'approved');
790
+
791
+ expect(streamingEvents[0]).toBe(true);
792
+ expect(streamingEvents[streamingEvents.length - 1]).toBe(false);
793
+ expect(session.isStreaming()).toBe(false);
794
+ expect(errors.length).toBe(1);
795
+ });
645
796
  });
package/src/session.ts CHANGED
@@ -908,10 +908,12 @@ export class AgentWidgetSession {
908
908
  */
909
909
  public async connectStream(
910
910
  stream: ReadableStream<Uint8Array>,
911
- options?: { assistantMessageId?: string }
911
+ options?: { assistantMessageId?: string; allowReentry?: boolean }
912
912
  ): Promise<void> {
913
- if (this.streaming) return;
914
- this.abortController?.abort();
913
+ if (this.streaming && !options?.allowReentry) return;
914
+ if (!options?.allowReentry) {
915
+ this.abortController?.abort();
916
+ }
915
917
 
916
918
  // Finalize any stale streaming messages from the previous stream
917
919
  // (e.g., tool messages interrupted by approval pause)
@@ -971,6 +973,13 @@ export class AgentWidgetSession {
971
973
  };
972
974
  this.upsertMessage(updatedMessage);
973
975
 
976
+ // Show the standalone typing indicator immediately while we wait for the
977
+ // approval round-trip. Install an abortController so cancel() works during
978
+ // the silent gap. See `resolveAskUserQuestion` for the same pattern.
979
+ this.abortController?.abort();
980
+ this.abortController = new AbortController();
981
+ this.setStreaming(true);
982
+
974
983
  // 2. Call onDecision callback if provided, otherwise use client.resolveApproval()
975
984
  const approvalConfig = this.config.approval;
976
985
  const onDecision = approvalConfig && typeof approvalConfig === 'object' ? approvalConfig.onDecision : undefined;
@@ -1015,23 +1024,44 @@ export class AgentWidgetSession {
1015
1024
  }
1016
1025
 
1017
1026
  if (stream) {
1018
- await this.connectStream(stream);
1019
- } else if (decision === 'denied') {
1020
- // No stream body for denied — inject a denial message
1021
- this.appendMessage({
1022
- id: `denial-${approval.id}`,
1023
- role: "assistant",
1024
- content: "Tool execution was denied by user.",
1025
- createdAt: new Date().toISOString(),
1026
- streaming: false,
1027
- sequence: this.nextSequence(),
1028
- });
1027
+ await this.connectStream(stream, { allowReentry: true });
1028
+ } else {
1029
+ if (decision === 'denied') {
1030
+ // No stream body for denied — inject a denial message
1031
+ this.appendMessage({
1032
+ id: `denial-${approval.id}`,
1033
+ role: "assistant",
1034
+ content: "Tool execution was denied by user.",
1035
+ createdAt: new Date().toISOString(),
1036
+ streaming: false,
1037
+ sequence: this.nextSequence(),
1038
+ });
1039
+ }
1040
+ // No body to pipe — drop the pre-set streaming flag so the indicator
1041
+ // doesn't linger forever.
1042
+ this.setStreaming(false);
1043
+ this.abortController = null;
1029
1044
  }
1045
+ } else {
1046
+ // onDecision returned void / no response — drop the pre-set flag.
1047
+ this.setStreaming(false);
1048
+ this.abortController = null;
1030
1049
  }
1031
1050
  } catch (error) {
1032
- this.callbacks.onError?.(
1033
- error instanceof Error ? error : new Error(String(error))
1034
- );
1051
+ const isAbortError =
1052
+ error instanceof Error &&
1053
+ (error.name === 'AbortError' ||
1054
+ error.message.includes('aborted') ||
1055
+ error.message.includes('abort'));
1056
+
1057
+ this.setStreaming(false);
1058
+ this.abortController = null;
1059
+
1060
+ if (!isAbortError) {
1061
+ this.callbacks.onError?.(
1062
+ error instanceof Error ? error : new Error(String(error))
1063
+ );
1064
+ }
1035
1065
  }
1036
1066
  }
1037
1067
 
@@ -1143,6 +1173,15 @@ export class AgentWidgetSession {
1143
1173
  }
1144
1174
  this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
1145
1175
 
1176
+ // Show the standalone typing indicator immediately — the network round-trip
1177
+ // to /resume is otherwise silent, which reads as broken. The render
1178
+ // condition in ui.ts already shows the indicator once streaming flips true
1179
+ // and the last message is a user bubble (the answer we inject below).
1180
+ // Install an abortController so cancel() works during this silent gap.
1181
+ this.abortController?.abort();
1182
+ this.abortController = new AbortController();
1183
+ this.setStreaming(true);
1184
+
1146
1185
  // Inject Q→A pair messages — one assistant bubble per question, one user
1147
1186
  // bubble per answer — so the transcript reads like a normal conversation.
1148
1187
  // The original ask_user_question tool message is suppressed by the
@@ -1214,12 +1253,30 @@ export class AgentWidgetSession {
1214
1253
  }
1215
1254
 
1216
1255
  if (response.body) {
1217
- await this.connectStream(response.body);
1256
+ await this.connectStream(response.body, { allowReentry: true });
1257
+ } else {
1258
+ // No body to pipe — drop the pre-set streaming flag so the indicator
1259
+ // doesn't linger forever.
1260
+ this.setStreaming(false);
1261
+ this.abortController = null;
1218
1262
  }
1219
1263
  } catch (error) {
1220
- this.callbacks.onError?.(
1221
- error instanceof Error ? error : new Error(String(error))
1222
- );
1264
+ // Mirror sendMessage: a cancel() during the await aborts the controller
1265
+ // and surfaces an AbortError don't treat that as a real failure.
1266
+ const isAbortError =
1267
+ error instanceof Error &&
1268
+ (error.name === 'AbortError' ||
1269
+ error.message.includes('aborted') ||
1270
+ error.message.includes('abort'));
1271
+
1272
+ this.setStreaming(false);
1273
+ this.abortController = null;
1274
+
1275
+ if (!isAbortError) {
1276
+ this.callbacks.onError?.(
1277
+ error instanceof Error ? error : new Error(String(error))
1278
+ );
1279
+ }
1223
1280
  }
1224
1281
  }
1225
1282
 
package/src/types.ts CHANGED
@@ -30,15 +30,40 @@ export type ImageContentPart = {
30
30
  */
31
31
  export type FileContentPart = {
32
32
  type: 'file';
33
- data: string; // base64 data URI
33
+ data: string; // base64 data URI or URL
34
34
  mimeType: string;
35
35
  filename: string;
36
36
  };
37
37
 
38
+ /**
39
+ * Audio content part for multi-modal messages
40
+ * Supports base64 data URIs or URLs
41
+ */
42
+ export type AudioContentPart = {
43
+ type: 'audio';
44
+ audio: string; // base64 data URI or URL
45
+ mimeType?: string;
46
+ };
47
+
48
+ /**
49
+ * Video content part for multi-modal messages
50
+ * Supports base64 data URIs or URLs
51
+ */
52
+ export type VideoContentPart = {
53
+ type: 'video';
54
+ video: string; // base64 data URI or URL
55
+ mimeType?: string;
56
+ };
57
+
38
58
  /**
39
59
  * Union type for all content part types
40
60
  */
41
- export type ContentPart = TextContentPart | ImageContentPart | FileContentPart;
61
+ export type ContentPart =
62
+ | TextContentPart
63
+ | ImageContentPart
64
+ | FileContentPart
65
+ | AudioContentPart
66
+ | VideoContentPart;
42
67
 
43
68
  /**
44
69
  * Message content can be a simple string or an array of content parts
@@ -598,6 +598,48 @@ describe("renderAskUserQuestion plugin hook", () => {
598
598
  controller.destroy();
599
599
  });
600
600
 
601
+ it("shows the standalone typing indicator immediately after picking an option (resumeFlow pending)", async () => {
602
+ // Defer the fetch response so we observe the DOM during the silent gap
603
+ // between the user's pick and the next streamed token.
604
+ let resolveFetch!: (value: unknown) => void;
605
+ const fetchPromise = new Promise((res) => { resolveFetch = res; });
606
+ global.fetch = vi.fn().mockImplementation(() => fetchPromise) as unknown as typeof fetch;
607
+
608
+ const mount = createMount();
609
+ const controller = createAgentExperience(mount, {
610
+ apiUrl: "https://api.example.com/chat",
611
+ launcher: { enabled: false },
612
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
613
+
614
+ injectAskUserQuestion(controller);
615
+
616
+ // Pre-condition: no typing indicator before the user picks.
617
+ expect(mount.querySelector('[data-typing-indicator="true"]')).toBeNull();
618
+
619
+ const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
620
+ (sheet.querySelector('[data-option-label="Hobbyists"]') as HTMLElement).click();
621
+
622
+ // Let the synchronous setStreaming(true) + injectMessage path run through.
623
+ await Promise.resolve();
624
+ await Promise.resolve();
625
+
626
+ // The typing indicator should be visible while resumeFlow is pending.
627
+ expect(mount.querySelector('[data-typing-indicator="true"]')).not.toBeNull();
628
+
629
+ // Drain the pending fetch so the test doesn't leak.
630
+ resolveFetch({
631
+ ok: true,
632
+ body: new ReadableStream({
633
+ start(c) {
634
+ c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
635
+ c.close();
636
+ },
637
+ }),
638
+ });
639
+
640
+ controller.destroy();
641
+ });
642
+
601
643
  it("wires resolve(answer) to session.resolveAskUserQuestion via /resume", async () => {
602
644
  const fetchMock = vi.fn().mockResolvedValue({
603
645
  ok: true,