@runtypelabs/persona 3.21.0 → 3.21.2

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/src/client.ts CHANGED
@@ -1125,6 +1125,11 @@ export class AgentWidgetClient {
1125
1125
  };
1126
1126
 
1127
1127
  let assistantMessage: AgentWidgetMessage | null = null;
1128
+ // Tracks the most recently touched assistant text message for the
1129
+ // current agent turn so `agent_turn_complete.stopReason` can attach
1130
+ // to the final visible text segment even after `assistantMessage`
1131
+ // has been finalized at a tool-call boundary within the turn.
1132
+ let lastAssistantInTurn: AgentWidgetMessage | null = null;
1128
1133
  // Reference to track assistant message for custom event handler
1129
1134
  const assistantMessageRef = { current: null as AgentWidgetMessage | null };
1130
1135
  // Track current partId for message segmentation at tool boundaries
@@ -2464,7 +2469,11 @@ export class AgentWidgetClient {
2464
2469
  }
2465
2470
  }
2466
2471
  } else if (payloadType === "agent_turn_start") {
2467
- // Nothing to do - turn tracking is handled by deltas
2472
+ // Reset the per-turn assistant tracker. lastAssistantInTurn is
2473
+ // used by agent_turn_complete to attach stopReason to the final
2474
+ // text segment of the turn even if that segment was sealed by an
2475
+ // intervening tool-call boundary.
2476
+ lastAssistantInTurn = null;
2468
2477
  } else if (payloadType === "agent_turn_delta") {
2469
2478
  if (payload.contentType === 'text') {
2470
2479
  // Stream text to assistant message
@@ -2476,6 +2485,7 @@ export class AgentWidgetClient {
2476
2485
  turnId: payload.turnId,
2477
2486
  agentName: agentExecution?.agentName
2478
2487
  };
2488
+ lastAssistantInTurn = assistant;
2479
2489
  emitMessage(assistant);
2480
2490
  } else if (payload.contentType === 'thinking') {
2481
2491
  // Stream thinking content to a reasoning message
@@ -2526,19 +2536,33 @@ export class AgentWidgetClient {
2526
2536
  // Attach the turn-level stopReason to the assistant message
2527
2537
  // produced by this turn. Only overwrite the current message —
2528
2538
  // prior turns already sealed their own stopReason via step_complete.
2539
+ // Falls back to lastAssistantInTurn when the current bubble was
2540
+ // sealed at a tool-call boundary mid-turn, so the notice still
2541
+ // attaches to the final visible text segment.
2529
2542
  const turnStopReason = (payload as any).stopReason as
2530
2543
  | StopReasonKind
2531
2544
  | undefined;
2532
- if (turnStopReason && assistantMessage !== null) {
2545
+ const stopReasonTarget = assistantMessage ?? lastAssistantInTurn;
2546
+ if (turnStopReason && stopReasonTarget !== null) {
2533
2547
  const turnId = payload.turnId;
2534
2548
  const matchesTurn =
2535
- !turnId || assistantMessage.agentMetadata?.turnId === turnId;
2549
+ !turnId || stopReasonTarget.agentMetadata?.turnId === turnId;
2536
2550
  if (matchesTurn) {
2537
- assistantMessage.stopReason = turnStopReason;
2538
- emitMessage(assistantMessage);
2551
+ stopReasonTarget.stopReason = turnStopReason;
2552
+ emitMessage(stopReasonTarget);
2539
2553
  }
2540
2554
  }
2541
2555
  } else if (payloadType === "agent_tool_start") {
2556
+ // Finalize any in-flight assistant text bubble so subsequent text
2557
+ // deltas in this turn create a NEW bubble. Without this, text
2558
+ // emitted before AND after a tool call accumulates into one
2559
+ // message that renders below all the tool bubbles, losing the
2560
+ // chronological text→tool→text→tool interleaving.
2561
+ if (assistantMessage) {
2562
+ assistantMessage.streaming = false;
2563
+ emitMessage(assistantMessage);
2564
+ assistantMessage = null;
2565
+ }
2542
2566
  const toolId = payload.toolCallId ?? `agent-tool-${nextSequence()}`;
2543
2567
  trackToolId(getToolCallKey(payload), toolId);
2544
2568
  const toolMessage = ensureToolMessage(toolId);
@@ -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
 
@@ -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,