@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.
- package/README.md +7 -1
- package/dist/index.cjs +12 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.global.js +26 -26
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +12 -12
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +54 -26
- package/dist/theme-editor.js +54 -26
- package/package.json +1 -1
- package/src/session.test.ts +151 -0
- package/src/session.ts +78 -21
- package/src/ui.ask-user-question-plugin.test.ts +42 -0
package/dist/theme-editor.cjs
CHANGED
|
@@ -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
|
-
(
|
|
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
|
-
(
|
|
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
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
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
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
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 = (
|
|
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
|
-
(
|
|
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
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
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() {
|
package/dist/theme-editor.js
CHANGED
|
@@ -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
|
-
(
|
|
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
|
-
(
|
|
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
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
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
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
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 = (
|
|
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
|
-
(
|
|
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
|
-
|
|
8002
|
-
|
|
8003
|
-
|
|
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.
|
|
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",
|
package/src/session.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
1033
|
-
error instanceof 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
|
-
|
|
1221
|
-
|
|
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,
|