@runtypelabs/persona 3.21.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.
@@ -7871,8 +7871,10 @@ var AgentWidgetSession = class _AgentWidgetSession {
7871
7871
  */
7872
7872
  async connectStream(stream, options) {
7873
7873
  var _a, _b, _c;
7874
- if (this.streaming) return;
7875
- (_a = this.abortController) == null ? void 0 : _a.abort();
7874
+ if (this.streaming && !(options == null ? void 0 : options.allowReentry)) return;
7875
+ if (!(options == null ? void 0 : options.allowReentry)) {
7876
+ (_a = this.abortController) == null ? void 0 : _a.abort();
7877
+ }
7876
7878
  let hasStale = false;
7877
7879
  for (const msg of this.messages) {
7878
7880
  if (msg.streaming) {
@@ -7906,7 +7908,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
7906
7908
  * and pipes the response stream through connectStream().
7907
7909
  */
7908
7910
  async resolveApproval(approval, decision) {
7909
- var _a, _b, _c;
7911
+ var _a, _b, _c, _d;
7910
7912
  const approvalMessageId = `approval-${approval.id}`;
7911
7913
  const updatedApproval = {
7912
7914
  ...approval,
@@ -7923,6 +7925,9 @@ var AgentWidgetSession = class _AgentWidgetSession {
7923
7925
  approval: updatedApproval
7924
7926
  };
7925
7927
  this.upsertMessage(updatedMessage);
7928
+ (_a = this.abortController) == null ? void 0 : _a.abort();
7929
+ this.abortController = new AbortController();
7930
+ this.setStreaming(true);
7926
7931
  const approvalConfig = this.config.approval;
7927
7932
  const onDecision = approvalConfig && typeof approvalConfig === "object" ? approvalConfig.onDecision : void 0;
7928
7933
  try {
@@ -7953,7 +7958,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
7953
7958
  if (!response.ok) {
7954
7959
  const errorData = await response.json().catch(() => null);
7955
7960
  throw new Error(
7956
- (_a = errorData == null ? void 0 : errorData.error) != null ? _a : `Approval request failed: ${response.status}`
7961
+ (_b = errorData == null ? void 0 : errorData.error) != null ? _b : `Approval request failed: ${response.status}`
7957
7962
  );
7958
7963
  }
7959
7964
  stream = response.body;
@@ -7961,23 +7966,35 @@ var AgentWidgetSession = class _AgentWidgetSession {
7961
7966
  stream = response;
7962
7967
  }
7963
7968
  if (stream) {
7964
- await this.connectStream(stream);
7965
- } else if (decision === "denied") {
7966
- this.appendMessage({
7967
- id: `denial-${approval.id}`,
7968
- role: "assistant",
7969
- content: "Tool execution was denied by user.",
7970
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7971
- streaming: false,
7972
- sequence: this.nextSequence()
7973
- });
7969
+ await this.connectStream(stream, { allowReentry: true });
7970
+ } else {
7971
+ if (decision === "denied") {
7972
+ this.appendMessage({
7973
+ id: `denial-${approval.id}`,
7974
+ role: "assistant",
7975
+ content: "Tool execution was denied by user.",
7976
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7977
+ streaming: false,
7978
+ sequence: this.nextSequence()
7979
+ });
7980
+ }
7981
+ this.setStreaming(false);
7982
+ this.abortController = null;
7974
7983
  }
7984
+ } else {
7985
+ this.setStreaming(false);
7986
+ this.abortController = null;
7975
7987
  }
7976
7988
  } catch (error) {
7977
- (_c = (_b = this.callbacks).onError) == null ? void 0 : _c.call(
7978
- _b,
7979
- error instanceof Error ? error : new Error(String(error))
7980
- );
7989
+ const isAbortError = error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("abort"));
7990
+ this.setStreaming(false);
7991
+ this.abortController = null;
7992
+ if (!isAbortError) {
7993
+ (_d = (_c = this.callbacks).onError) == null ? void 0 : _d.call(
7994
+ _c,
7995
+ error instanceof Error ? error : new Error(String(error))
7996
+ );
7997
+ }
7981
7998
  }
7982
7999
  }
7983
8000
  /**
@@ -8031,7 +8048,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
8031
8048
  });
8032
8049
  }
8033
8050
  async resolveAskUserQuestion(toolMessage, answer) {
8034
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
8051
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
8035
8052
  const live = this.messages.find((m) => m.id === toolMessage.id);
8036
8053
  if (((_a = live == null ? void 0 : live.agentMetadata) == null ? void 0 : _a.askUserQuestionAnswered) === true) return;
8037
8054
  const executionId = (_b = toolMessage.agentMetadata) == null ? void 0 : _b.executionId;
@@ -8055,8 +8072,11 @@ var AgentWidgetSession = class _AgentWidgetSession {
8055
8072
  }
8056
8073
  }
8057
8074
  this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
8075
+ (_h = this.abortController) == null ? void 0 : _h.abort();
8076
+ this.abortController = new AbortController();
8077
+ this.setStreaming(true);
8058
8078
  const toolCallId = toolMessage.toolCall.id;
8059
- const args = (_h = toolMessage.toolCall) == null ? void 0 : _h.args;
8079
+ const args = (_i = toolMessage.toolCall) == null ? void 0 : _i.args;
8060
8080
  const questions = Array.isArray(args == null ? void 0 : args.questions) ? args.questions : [];
8061
8081
  if (questions.length === 0) {
8062
8082
  const fallback = typeof answer === "string" ? answer : Object.entries(answer).map(
@@ -8102,17 +8122,25 @@ var AgentWidgetSession = class _AgentWidgetSession {
8102
8122
  if (!response.ok) {
8103
8123
  const errorData = await response.json().catch(() => null);
8104
8124
  throw new Error(
8105
- (_i = errorData == null ? void 0 : errorData.error) != null ? _i : `Resume failed: ${response.status}`
8125
+ (_j = errorData == null ? void 0 : errorData.error) != null ? _j : `Resume failed: ${response.status}`
8106
8126
  );
8107
8127
  }
8108
8128
  if (response.body) {
8109
- await this.connectStream(response.body);
8129
+ await this.connectStream(response.body, { allowReentry: true });
8130
+ } else {
8131
+ this.setStreaming(false);
8132
+ this.abortController = null;
8110
8133
  }
8111
8134
  } catch (error) {
8112
- (_k = (_j = this.callbacks).onError) == null ? void 0 : _k.call(
8113
- _j,
8114
- error instanceof Error ? error : new Error(String(error))
8115
- );
8135
+ const isAbortError = error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("abort"));
8136
+ this.setStreaming(false);
8137
+ this.abortController = null;
8138
+ if (!isAbortError) {
8139
+ (_l = (_k = this.callbacks).onError) == null ? void 0 : _l.call(
8140
+ _k,
8141
+ error instanceof Error ? error : new Error(String(error))
8142
+ );
8143
+ }
8116
8144
  }
8117
8145
  }
8118
8146
  cancel() {
@@ -7760,8 +7760,10 @@ var AgentWidgetSession = class _AgentWidgetSession {
7760
7760
  */
7761
7761
  async connectStream(stream, options) {
7762
7762
  var _a, _b, _c;
7763
- if (this.streaming) return;
7764
- (_a = this.abortController) == null ? void 0 : _a.abort();
7763
+ if (this.streaming && !(options == null ? void 0 : options.allowReentry)) return;
7764
+ if (!(options == null ? void 0 : options.allowReentry)) {
7765
+ (_a = this.abortController) == null ? void 0 : _a.abort();
7766
+ }
7765
7767
  let hasStale = false;
7766
7768
  for (const msg of this.messages) {
7767
7769
  if (msg.streaming) {
@@ -7795,7 +7797,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
7795
7797
  * and pipes the response stream through connectStream().
7796
7798
  */
7797
7799
  async resolveApproval(approval, decision) {
7798
- var _a, _b, _c;
7800
+ var _a, _b, _c, _d;
7799
7801
  const approvalMessageId = `approval-${approval.id}`;
7800
7802
  const updatedApproval = {
7801
7803
  ...approval,
@@ -7812,6 +7814,9 @@ var AgentWidgetSession = class _AgentWidgetSession {
7812
7814
  approval: updatedApproval
7813
7815
  };
7814
7816
  this.upsertMessage(updatedMessage);
7817
+ (_a = this.abortController) == null ? void 0 : _a.abort();
7818
+ this.abortController = new AbortController();
7819
+ this.setStreaming(true);
7815
7820
  const approvalConfig = this.config.approval;
7816
7821
  const onDecision = approvalConfig && typeof approvalConfig === "object" ? approvalConfig.onDecision : void 0;
7817
7822
  try {
@@ -7842,7 +7847,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
7842
7847
  if (!response.ok) {
7843
7848
  const errorData = await response.json().catch(() => null);
7844
7849
  throw new Error(
7845
- (_a = errorData == null ? void 0 : errorData.error) != null ? _a : `Approval request failed: ${response.status}`
7850
+ (_b = errorData == null ? void 0 : errorData.error) != null ? _b : `Approval request failed: ${response.status}`
7846
7851
  );
7847
7852
  }
7848
7853
  stream = response.body;
@@ -7850,23 +7855,35 @@ var AgentWidgetSession = class _AgentWidgetSession {
7850
7855
  stream = response;
7851
7856
  }
7852
7857
  if (stream) {
7853
- await this.connectStream(stream);
7854
- } else if (decision === "denied") {
7855
- this.appendMessage({
7856
- id: `denial-${approval.id}`,
7857
- role: "assistant",
7858
- content: "Tool execution was denied by user.",
7859
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7860
- streaming: false,
7861
- sequence: this.nextSequence()
7862
- });
7858
+ await this.connectStream(stream, { allowReentry: true });
7859
+ } else {
7860
+ if (decision === "denied") {
7861
+ this.appendMessage({
7862
+ id: `denial-${approval.id}`,
7863
+ role: "assistant",
7864
+ content: "Tool execution was denied by user.",
7865
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7866
+ streaming: false,
7867
+ sequence: this.nextSequence()
7868
+ });
7869
+ }
7870
+ this.setStreaming(false);
7871
+ this.abortController = null;
7863
7872
  }
7873
+ } else {
7874
+ this.setStreaming(false);
7875
+ this.abortController = null;
7864
7876
  }
7865
7877
  } catch (error) {
7866
- (_c = (_b = this.callbacks).onError) == null ? void 0 : _c.call(
7867
- _b,
7868
- error instanceof Error ? error : new Error(String(error))
7869
- );
7878
+ const isAbortError = error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("abort"));
7879
+ this.setStreaming(false);
7880
+ this.abortController = null;
7881
+ if (!isAbortError) {
7882
+ (_d = (_c = this.callbacks).onError) == null ? void 0 : _d.call(
7883
+ _c,
7884
+ error instanceof Error ? error : new Error(String(error))
7885
+ );
7886
+ }
7870
7887
  }
7871
7888
  }
7872
7889
  /**
@@ -7920,7 +7937,7 @@ var AgentWidgetSession = class _AgentWidgetSession {
7920
7937
  });
7921
7938
  }
7922
7939
  async resolveAskUserQuestion(toolMessage, answer) {
7923
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
7940
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l;
7924
7941
  const live = this.messages.find((m) => m.id === toolMessage.id);
7925
7942
  if (((_a = live == null ? void 0 : live.agentMetadata) == null ? void 0 : _a.askUserQuestionAnswered) === true) return;
7926
7943
  const executionId = (_b = toolMessage.agentMetadata) == null ? void 0 : _b.executionId;
@@ -7944,8 +7961,11 @@ var AgentWidgetSession = class _AgentWidgetSession {
7944
7961
  }
7945
7962
  }
7946
7963
  this.markAskUserQuestionResolved(toolMessage, structuredAnswers);
7964
+ (_h = this.abortController) == null ? void 0 : _h.abort();
7965
+ this.abortController = new AbortController();
7966
+ this.setStreaming(true);
7947
7967
  const toolCallId = toolMessage.toolCall.id;
7948
- const args = (_h = toolMessage.toolCall) == null ? void 0 : _h.args;
7968
+ const args = (_i = toolMessage.toolCall) == null ? void 0 : _i.args;
7949
7969
  const questions = Array.isArray(args == null ? void 0 : args.questions) ? args.questions : [];
7950
7970
  if (questions.length === 0) {
7951
7971
  const fallback = typeof answer === "string" ? answer : Object.entries(answer).map(
@@ -7991,17 +8011,25 @@ var AgentWidgetSession = class _AgentWidgetSession {
7991
8011
  if (!response.ok) {
7992
8012
  const errorData = await response.json().catch(() => null);
7993
8013
  throw new Error(
7994
- (_i = errorData == null ? void 0 : errorData.error) != null ? _i : `Resume failed: ${response.status}`
8014
+ (_j = errorData == null ? void 0 : errorData.error) != null ? _j : `Resume failed: ${response.status}`
7995
8015
  );
7996
8016
  }
7997
8017
  if (response.body) {
7998
- await this.connectStream(response.body);
8018
+ await this.connectStream(response.body, { allowReentry: true });
8019
+ } else {
8020
+ this.setStreaming(false);
8021
+ this.abortController = null;
7999
8022
  }
8000
8023
  } catch (error) {
8001
- (_k = (_j = this.callbacks).onError) == null ? void 0 : _k.call(
8002
- _j,
8003
- error instanceof Error ? error : new Error(String(error))
8004
- );
8024
+ const isAbortError = error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted") || error.message.includes("abort"));
8025
+ this.setStreaming(false);
8026
+ this.abortController = null;
8027
+ if (!isAbortError) {
8028
+ (_l = (_k = this.callbacks).onError) == null ? void 0 : _l.call(
8029
+ _k,
8030
+ error instanceof Error ? error : new Error(String(error))
8031
+ );
8032
+ }
8005
8033
  }
8006
8034
  }
8007
8035
  cancel() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.21.0",
3
+ "version": "3.21.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -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,