@runtypelabs/persona 4.22.0 → 4.23.0
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/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_JdVK9o.d.cts → types-9Vxm-PPz.d.cts} +136 -6
- package/dist/animations/{types-B_JdVK9o.d.ts → types-9Vxm-PPz.d.ts} +136 -6
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/animations-extra.d.cts +136 -6
- package/dist/animations-extra.d.ts +136 -6
- package/dist/approval-ui.d.cts +148 -14
- package/dist/approval-ui.d.ts +148 -14
- package/dist/artifacts-ui.d.cts +148 -14
- package/dist/artifacts-ui.d.ts +148 -14
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +148 -14
- package/dist/context-mentions.d.ts +148 -14
- package/dist/event-stream-view-GL3ADRY2.js +1 -0
- package/dist/event-stream-view.cjs +1 -1
- package/dist/event-stream-view.d.cts +148 -14
- package/dist/event-stream-view.d.ts +148 -14
- package/dist/event-stream-view.js +1 -1
- package/dist/forms-ui.d.cts +161 -23
- package/dist/forms-ui.d.ts +161 -23
- package/dist/history-view.d.cts +136 -6
- package/dist/history-view.d.ts +136 -6
- package/dist/index.cjs +67 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +172 -27
- package/dist/index.d.ts +172 -27
- package/dist/index.global.js +51 -51
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +67 -67
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/session-reconnect.d.cts +148 -14
- package/dist/session-reconnect.d.ts +148 -14
- package/dist/smart-dom-reader.d.cts +148 -14
- package/dist/smart-dom-reader.d.ts +148 -14
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +3 -4
- package/dist/testing.d.ts +3 -4
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +71 -71
- package/dist/theme-editor-preview.d.cts +154 -14
- package/dist/theme-editor-preview.d.ts +154 -14
- package/dist/theme-editor-preview.js +48 -48
- package/dist/theme-editor.d.cts +148 -14
- package/dist/theme-editor.d.ts +148 -14
- package/dist/voice-runtime.d.cts +6 -2
- package/dist/voice-runtime.d.ts +6 -2
- package/package.json +1 -1
- package/src/ask-user-question-tool.ts +1 -1
- package/src/client-history-rest.test.ts +2 -1
- package/src/client-visitor-history.test.ts +106 -4
- package/src/client.composer-options.test.ts +1 -1
- package/src/client.interrupt.test.ts +2 -2
- package/src/client.test.ts +477 -915
- package/src/client.ts +134 -494
- package/src/client.unified-tools.test.ts +76 -0
- package/src/components/event-stream-badge-theming.test.ts +3 -0
- package/src/components/event-stream-view.test.ts +76 -76
- package/src/components/event-stream-view.ts +5 -0
- package/src/components/tool-bubble.test.ts +12 -0
- package/src/components/tool-bubble.ts +13 -2
- package/src/event-stream-view-bundle.test.ts +2 -2
- package/src/generated/runtype-openapi-contract.ts +129 -1
- package/src/internal/runtype-history-provider.test.ts +2 -2
- package/src/launcher-global.ts +1 -1
- package/src/session-history.test.ts +1 -1
- package/src/session.quote.test.ts +1 -1
- package/src/session.resubmit-from.test.ts +1 -1
- package/src/session.test.ts +5 -5
- package/src/session.ts +95 -36
- package/src/suggest-replies-tool.ts +1 -1
- package/src/testing/mock-stream.test.ts +37 -16
- package/src/testing/mock-stream.ts +14 -9
- package/src/types.ts +19 -14
- package/src/ui.ask-user-question-plugin.test.ts +7 -7
- package/src/ui.composer-defer-one.test.ts +6 -34
- package/src/ui.composer-draft-persistence.test.ts +1 -1
- package/src/ui.composer-quote.test.ts +1 -1
- package/src/ui.message-history-actions.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +10 -13
- package/src/ui.ts +80 -32
- package/src/ui.typing-indicator.test.ts +216 -0
- package/src/ui.voice-custom-provider.test.ts +211 -0
- package/src/utils/event-stream-buffer.test.ts +13 -13
- package/src/utils/event-stream-capture.test.ts +20 -20
- package/src/utils/throughput-tracker.test.ts +100 -142
- package/src/utils/throughput-tracker.ts +18 -132
- package/src/utils/voice-support.ts +18 -1
- package/src/voice/voice-turn-tracker.ts +18 -0
- package/src/webmcp-bridge.ts +1 -1
- package/dist/event-stream-view-FKWDY3UB.js +0 -1
- package/src/utils/__fixtures__/unified-translator.oracle.ts +0 -717
|
@@ -133,3 +133,219 @@ describe("standalone typing indicator bubble", () => {
|
|
|
133
133
|
destroy();
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Regression coverage for hidden tool/reasoning rows. With
|
|
139
|
+
* `features.showToolCalls: false` the tool row never renders, so it must not
|
|
140
|
+
* count as "the assistant is already responding" and hide the standalone dots
|
|
141
|
+
* for the whole tool phase.
|
|
142
|
+
*/
|
|
143
|
+
describe("standalone typing indicator with hidden activity rows", () => {
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
document.body.innerHTML = "";
|
|
146
|
+
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
147
|
+
vi.restoreAllMocks();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const mountStreaming = async (config: Record<string, unknown>) => {
|
|
151
|
+
global.fetch = vi.fn().mockImplementation(
|
|
152
|
+
() => new Promise(() => {})
|
|
153
|
+
) as unknown as typeof fetch;
|
|
154
|
+
|
|
155
|
+
const mount = createMount();
|
|
156
|
+
const controller = createAgentExperience(mount, {
|
|
157
|
+
apiUrl: "https://api.example.com/chat",
|
|
158
|
+
launcher: { enabled: false },
|
|
159
|
+
...config,
|
|
160
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
161
|
+
|
|
162
|
+
controller.submitMessage("hello");
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
await Promise.resolve();
|
|
165
|
+
return { mount, controller };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// The session orders by createdAt first, then by its Date.now()-based
|
|
169
|
+
// sequence, so injected rows need both stamped in the future to sort after
|
|
170
|
+
// the user message the way real stream rows do.
|
|
171
|
+
const seq = (n: number) => Date.now() + 60_000 + n;
|
|
172
|
+
const at = (n: number) => new Date(seq(n)).toISOString();
|
|
173
|
+
|
|
174
|
+
const injectTool = (
|
|
175
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
176
|
+
id: string,
|
|
177
|
+
status: "running" | "complete",
|
|
178
|
+
sequence: number
|
|
179
|
+
) => {
|
|
180
|
+
controller.injectTestMessage({
|
|
181
|
+
type: "message",
|
|
182
|
+
message: {
|
|
183
|
+
id,
|
|
184
|
+
role: "assistant",
|
|
185
|
+
content: "",
|
|
186
|
+
createdAt: new Date(sequence).toISOString(),
|
|
187
|
+
sequence,
|
|
188
|
+
streaming: status !== "complete",
|
|
189
|
+
variant: "tool",
|
|
190
|
+
toolCall: { id, name: "lookup", status },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const injectReasoning = (
|
|
196
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
197
|
+
id: string,
|
|
198
|
+
status: "streaming" | "complete",
|
|
199
|
+
sequence: number
|
|
200
|
+
) => {
|
|
201
|
+
controller.injectTestMessage({
|
|
202
|
+
type: "message",
|
|
203
|
+
message: {
|
|
204
|
+
id,
|
|
205
|
+
role: "assistant",
|
|
206
|
+
content: "",
|
|
207
|
+
createdAt: new Date(sequence).toISOString(),
|
|
208
|
+
sequence,
|
|
209
|
+
streaming: status !== "complete",
|
|
210
|
+
variant: "reasoning",
|
|
211
|
+
reasoning: { id, status, chunks: ["thinking"] },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const typingIndicator = (mount: HTMLElement) =>
|
|
217
|
+
mount.querySelector<HTMLElement>('[data-typing-indicator="true"]');
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
it("keeps the dots while a hidden tool call is running", async () => {
|
|
221
|
+
const { mount, controller } = await mountStreaming({
|
|
222
|
+
features: { showToolCalls: false },
|
|
223
|
+
});
|
|
224
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
225
|
+
|
|
226
|
+
injectTool(controller, "tool-1", "running", seq(1));
|
|
227
|
+
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
228
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
229
|
+
|
|
230
|
+
controller.destroy();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("keeps the dots after a hidden tool call completes, before the next text", async () => {
|
|
234
|
+
const { mount, controller } = await mountStreaming({
|
|
235
|
+
features: { showToolCalls: false },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
injectTool(controller, "tool-1", "running", seq(1));
|
|
239
|
+
injectTool(controller, "tool-1", "complete", seq(1));
|
|
240
|
+
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
241
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
242
|
+
|
|
243
|
+
controller.destroy();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps the dots when a hidden tool call follows an intermediate assistant text", async () => {
|
|
247
|
+
const { mount, controller } = await mountStreaming({
|
|
248
|
+
features: { showToolCalls: false },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
controller.injectTestMessage({
|
|
252
|
+
type: "message",
|
|
253
|
+
message: {
|
|
254
|
+
id: "text-1",
|
|
255
|
+
role: "assistant",
|
|
256
|
+
content: "Let me look that up.",
|
|
257
|
+
createdAt: at(1),
|
|
258
|
+
sequence: seq(1),
|
|
259
|
+
streaming: false,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
injectTool(controller, "tool-1", "complete", seq(2));
|
|
263
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
264
|
+
|
|
265
|
+
controller.destroy();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("still hides the dots while a visible tool call renders its own bubble", async () => {
|
|
269
|
+
const { mount, controller } = await mountStreaming({
|
|
270
|
+
features: { showToolCalls: true },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
injectTool(controller, "tool-1", "running", seq(1));
|
|
274
|
+
expect(mount.querySelector(".persona-tool-bubble")).not.toBeNull();
|
|
275
|
+
expect(typingIndicator(mount)).toBeNull();
|
|
276
|
+
|
|
277
|
+
controller.destroy();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("keeps the dots while hidden reasoning streams", async () => {
|
|
281
|
+
const { mount, controller } = await mountStreaming({
|
|
282
|
+
features: { showReasoning: false },
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
injectReasoning(controller, "reason-1", "streaming", seq(1));
|
|
286
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
287
|
+
|
|
288
|
+
injectReasoning(controller, "reason-1", "complete", seq(1));
|
|
289
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
290
|
+
|
|
291
|
+
controller.destroy();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("keeps the dots after a visible tool call is removed by completedVisibility", async () => {
|
|
295
|
+
const { mount, controller } = await mountStreaming({
|
|
296
|
+
features: {
|
|
297
|
+
showToolCalls: true,
|
|
298
|
+
toolCallDisplay: { completedVisibility: "removed" },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
injectTool(controller, "tool-1", "running", seq(1));
|
|
303
|
+
expect(mount.querySelector(".persona-tool-bubble")).not.toBeNull();
|
|
304
|
+
expect(typingIndicator(mount)).toBeNull();
|
|
305
|
+
|
|
306
|
+
injectTool(controller, "tool-1", "complete", seq(1));
|
|
307
|
+
expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
|
|
308
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
309
|
+
|
|
310
|
+
controller.destroy();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("keeps the dots after visible reasoning is removed by completedVisibility", async () => {
|
|
314
|
+
const { mount, controller } = await mountStreaming({
|
|
315
|
+
features: {
|
|
316
|
+
showReasoning: true,
|
|
317
|
+
reasoningDisplay: { completedVisibility: "removed" },
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
injectReasoning(controller, "reason-1", "streaming", seq(1));
|
|
322
|
+
expect(typingIndicator(mount)).toBeNull();
|
|
323
|
+
|
|
324
|
+
injectReasoning(controller, "reason-1", "complete", seq(1));
|
|
325
|
+
expect(typingIndicator(mount)).not.toBeNull();
|
|
326
|
+
|
|
327
|
+
controller.destroy();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("hides the dots once a real assistant message is streaming", async () => {
|
|
331
|
+
const { mount, controller } = await mountStreaming({
|
|
332
|
+
features: { showToolCalls: false },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
injectTool(controller, "tool-1", "complete", seq(1));
|
|
336
|
+
controller.injectTestMessage({
|
|
337
|
+
type: "message",
|
|
338
|
+
message: {
|
|
339
|
+
id: "text-1",
|
|
340
|
+
role: "assistant",
|
|
341
|
+
content: "Here you go",
|
|
342
|
+
createdAt: at(2),
|
|
343
|
+
sequence: seq(2),
|
|
344
|
+
streaming: true,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
expect(typingIndicator(mount)).toBeNull();
|
|
348
|
+
|
|
349
|
+
controller.destroy();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { createAgentExperience } from "./ui";
|
|
5
|
+
import type { VoiceProvider, VoiceStatus } from "./types";
|
|
6
|
+
|
|
7
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
8
|
+
beforeEach(() => { window.scrollTo = vi.fn(); });
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const controller of controllers.splice(0)) controller.destroy();
|
|
11
|
+
document.body.innerHTML = "";
|
|
12
|
+
window.localStorage.clear();
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function fixture(mode: "none" | "cancel" | "barge-in" = "cancel") {
|
|
17
|
+
let statusCallback: (status: VoiceStatus) => void = () => {};
|
|
18
|
+
let transcriptCallback: NonNullable<Parameters<NonNullable<VoiceProvider["onTranscript"]>>[0]> = () => {};
|
|
19
|
+
let active = false;
|
|
20
|
+
const provider: VoiceProvider = {
|
|
21
|
+
type: "runtype",
|
|
22
|
+
connect: vi.fn(async () => {}),
|
|
23
|
+
disconnect: vi.fn(async () => { active = false; }),
|
|
24
|
+
startListening: vi.fn(async () => { active = true; statusCallback("listening"); }),
|
|
25
|
+
stopListening: vi.fn(async () => { active = false; statusCallback("idle"); }),
|
|
26
|
+
onResult: vi.fn(),
|
|
27
|
+
onError: vi.fn(),
|
|
28
|
+
onStatusChange: callback => { statusCallback = callback; },
|
|
29
|
+
onTranscript: callback => { transcriptCallback = callback; },
|
|
30
|
+
getInterruptionMode: () => mode,
|
|
31
|
+
isBargeInActive: () => active,
|
|
32
|
+
stopPlayback: vi.fn(() => { statusCallback("listening"); }),
|
|
33
|
+
deactivateBargeIn: vi.fn(async () => { active = false; statusCallback("idle"); }),
|
|
34
|
+
};
|
|
35
|
+
const recognition = vi.fn();
|
|
36
|
+
vi.stubGlobal("SpeechRecognition", recognition);
|
|
37
|
+
const mount = document.createElement("div");
|
|
38
|
+
document.body.appendChild(mount);
|
|
39
|
+
const controller = createAgentExperience(mount, {
|
|
40
|
+
apiUrl: "https://api.example.test/chat",
|
|
41
|
+
launcher: { enabled: false }, persistState: false, suggestionChips: [],
|
|
42
|
+
voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } },
|
|
43
|
+
});
|
|
44
|
+
controllers.push(controller);
|
|
45
|
+
const mic = () => mount.querySelector<HTMLButtonElement>("[data-persona-composer-mic]")!;
|
|
46
|
+
return { provider, recognition, mic, controller, emit: (status: VoiceStatus) => statusCallback(status),
|
|
47
|
+
transcript: (role: "user" | "assistant", text: string, final = true, turnId?: string) => transcriptCallback(role, text, final, { turnId }) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it("starts the custom provider from the microphone and cancels playback without hanging up", async () => {
|
|
51
|
+
const { provider, recognition, mic, emit } = fixture();
|
|
52
|
+
mic().click();
|
|
53
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
54
|
+
expect(recognition).not.toHaveBeenCalled();
|
|
55
|
+
emit("speaking");
|
|
56
|
+
expect(mic().getAttribute("aria-label")).toBe("Stop playback and re-record");
|
|
57
|
+
mic().click();
|
|
58
|
+
expect(provider.stopPlayback).toHaveBeenCalledOnce();
|
|
59
|
+
expect(provider.stopListening).not.toHaveBeenCalled();
|
|
60
|
+
expect(provider.deactivateBargeIn).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("respects none mode and cleans up the custom provider on destroy", async () => {
|
|
64
|
+
const { provider, mic, emit, controller } = fixture("none");
|
|
65
|
+
mic().click();
|
|
66
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
67
|
+
emit("speaking");
|
|
68
|
+
mic().click();
|
|
69
|
+
expect(provider.stopPlayback).not.toHaveBeenCalled();
|
|
70
|
+
expect(provider.stopListening).not.toHaveBeenCalled();
|
|
71
|
+
controller.destroy();
|
|
72
|
+
controllers.splice(controllers.indexOf(controller), 1);
|
|
73
|
+
expect(provider.disconnect).toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("hangs up a barge-in custom call through the microphone control", async () => {
|
|
77
|
+
const { provider, mic, emit } = fixture("barge-in");
|
|
78
|
+
mic().click();
|
|
79
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
80
|
+
emit("speaking");
|
|
81
|
+
mic().click();
|
|
82
|
+
await vi.waitFor(() => expect(provider.deactivateBargeIn).toHaveBeenCalledOnce());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("routes controller start and stop to the custom provider after a composer update", async () => {
|
|
86
|
+
const { provider, recognition, controller, mic, emit } = fixture();
|
|
87
|
+
controller.update({ voiceRecognition: { enabled: false } });
|
|
88
|
+
controller.update({ voiceRecognition: { enabled: true } });
|
|
89
|
+
expect(mic()).not.toBeNull();
|
|
90
|
+
await controller.startVoiceRecognition();
|
|
91
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
92
|
+
emit("speaking");
|
|
93
|
+
await controller.stopVoiceRecognition();
|
|
94
|
+
expect(provider.deactivateBargeIn).toHaveBeenCalledOnce();
|
|
95
|
+
expect(recognition).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each(["cancel", "barge-in"] as const)("settles a pending assistant placeholder after %s during thinking", async (mode) => {
|
|
99
|
+
const { provider, mic, emit, transcript, controller } = fixture(mode);
|
|
100
|
+
mic().click();
|
|
101
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
102
|
+
emit("processing");
|
|
103
|
+
transcript("user", "Tell me a story");
|
|
104
|
+
expect(controller.getMessages().some(message => message.streaming)).toBe(true);
|
|
105
|
+
if (mode === "cancel") mic().click();
|
|
106
|
+
else emit("listening");
|
|
107
|
+
expect(controller.getMessages().map(message => message.content)).toEqual(["Tell me a story"]);
|
|
108
|
+
expect(controller.getMessages().some(message => message.streaming || message.voiceProcessing)).toBe(false);
|
|
109
|
+
transcript("user", "Next question");
|
|
110
|
+
transcript("assistant", "Next answer");
|
|
111
|
+
expect(controller.getMessages().map(message => message.content)).toEqual(["Tell me a story", "Next question", "Next answer"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("preserves partial voice content while clearing its streaming flags on hangup", async () => {
|
|
115
|
+
const { provider, mic, emit, transcript, controller } = fixture("barge-in");
|
|
116
|
+
mic().click();
|
|
117
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
118
|
+
transcript("user", "Explain");
|
|
119
|
+
transcript("assistant", "A partial answer", false);
|
|
120
|
+
emit("speaking");
|
|
121
|
+
mic().click();
|
|
122
|
+
expect(controller.getMessages().at(-1)).toMatchObject({ content: "A partial answer", streaming: false, voiceProcessing: false });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps the current user partial in one bubble when playback returns to listening", async () => {
|
|
126
|
+
const { provider, mic, emit, transcript, controller } = fixture();
|
|
127
|
+
mic().click();
|
|
128
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
129
|
+
transcript("user", "Hello");
|
|
130
|
+
transcript("assistant", "Hi");
|
|
131
|
+
transcript("user", "Next", false);
|
|
132
|
+
emit("listening");
|
|
133
|
+
transcript("user", "Next question");
|
|
134
|
+
transcript("assistant", "Next answer");
|
|
135
|
+
expect(controller.getMessages().filter(message => message.role === "user").map(message => message.content)).toEqual(["Hello", "Next question"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("drops a cancelled reply arriving after a newer identified turn", async () => {
|
|
139
|
+
const { provider, mic, emit, transcript, controller } = fixture();
|
|
140
|
+
mic().click();
|
|
141
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
142
|
+
transcript("user", "Old question", true, "old");
|
|
143
|
+
emit("processing");
|
|
144
|
+
mic().click();
|
|
145
|
+
transcript("assistant", "Old partial", false, "old");
|
|
146
|
+
transcript("user", "New question", true, "new");
|
|
147
|
+
transcript("assistant", "Cancelled old answer", true, "old");
|
|
148
|
+
transcript("assistant", "New answer", true, "new");
|
|
149
|
+
expect(controller.getMessages().map(message => message.content)).toEqual(["Old question", "New question", "New answer"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("ignores untagged cancelled output until the next user final without losing that next answer", async () => {
|
|
153
|
+
const { provider, mic, emit, transcript, controller } = fixture();
|
|
154
|
+
mic().click();
|
|
155
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
156
|
+
transcript("user", "Old question");
|
|
157
|
+
emit("processing");
|
|
158
|
+
mic().click();
|
|
159
|
+
transcript("assistant", "Cancelled answer");
|
|
160
|
+
transcript("user", "New question");
|
|
161
|
+
transcript("assistant", "New answer");
|
|
162
|
+
expect(controller.getMessages().map(message => message.content)).toEqual(["Old question", "New question", "New answer"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("disconnects the old provider before constructing its replacement and ignores its stale callbacks", async () => {
|
|
166
|
+
const { provider, mic, controller, transcript, emit } = fixture();
|
|
167
|
+
mic().click();
|
|
168
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
169
|
+
let finishDisconnect!: () => void;
|
|
170
|
+
vi.mocked(provider.disconnect).mockImplementation(() => new Promise<void>(resolve => { finishDisconnect = resolve; }));
|
|
171
|
+
const replacement = { ...provider, connect: vi.fn(async () => {}), startListening: vi.fn(async () => {}), disconnect: vi.fn(async () => {}) };
|
|
172
|
+
const factory = vi.fn(() => replacement);
|
|
173
|
+
controller.update({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: factory } } });
|
|
174
|
+
expect(provider.disconnect).toHaveBeenCalledOnce();
|
|
175
|
+
expect(controller.isVoiceActive()).toBe(false);
|
|
176
|
+
expect(factory).not.toHaveBeenCalled();
|
|
177
|
+
transcript("assistant", "Stale reply");
|
|
178
|
+
emit("speaking");
|
|
179
|
+
expect(controller.getMessages()).toEqual([]);
|
|
180
|
+
finishDisconnect();
|
|
181
|
+
await controller.startVoiceRecognition();
|
|
182
|
+
await vi.waitFor(() => expect(replacement.startListening).toHaveBeenCalledOnce());
|
|
183
|
+
expect(factory).toHaveBeenCalledOnce();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("disconnects a live call when disabled and reinstalls the provider on reenable", async () => {
|
|
187
|
+
const { provider, mic, controller } = fixture();
|
|
188
|
+
mic().click();
|
|
189
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
190
|
+
controller.update({ voiceRecognition: { enabled: false } });
|
|
191
|
+
expect(provider.disconnect).toHaveBeenCalledOnce();
|
|
192
|
+
expect(controller.isVoiceActive()).toBe(false);
|
|
193
|
+
controller.update({ voiceRecognition: { enabled: true } });
|
|
194
|
+
await controller.startVoiceRecognition();
|
|
195
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledTimes(2));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("sets up custom voice after starting with browser dictation and preserves it for cosmetic updates", async () => {
|
|
199
|
+
const { provider, controller } = fixture();
|
|
200
|
+
await controller.startVoiceRecognition();
|
|
201
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
202
|
+
controller.update({ voiceRecognition: { provider: { type: "browser" } } });
|
|
203
|
+
expect(provider.disconnect).toHaveBeenCalledOnce();
|
|
204
|
+
expect(controller.isVoiceActive()).toBe(false);
|
|
205
|
+
vi.mocked(provider.startListening).mockClear();
|
|
206
|
+
controller.update({ voiceRecognition: { provider: { type: "custom", custom: () => provider } } });
|
|
207
|
+
await controller.startVoiceRecognition();
|
|
208
|
+
await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
|
|
209
|
+
controller.update({ voiceRecognition: { iconName: "mic" }, colorScheme: "dark" });
|
|
210
|
+
expect(provider.disconnect).toHaveBeenCalledOnce();
|
|
211
|
+
});
|
|
@@ -37,7 +37,7 @@ describe("EventStreamBuffer", () => {
|
|
|
37
37
|
|
|
38
38
|
it("should push and retrieve events", () => {
|
|
39
39
|
const buf = new EventStreamBuffer(10);
|
|
40
|
-
const evt = makeEvent("
|
|
40
|
+
const evt = makeEvent("text_delta", 1);
|
|
41
41
|
buf.push(evt);
|
|
42
42
|
expect(buf.getSize()).toBe(1);
|
|
43
43
|
expect(buf.getAll()).toEqual([evt]);
|
|
@@ -85,30 +85,30 @@ describe("EventStreamBuffer", () => {
|
|
|
85
85
|
|
|
86
86
|
it("should track unique event types", () => {
|
|
87
87
|
const buf = new EventStreamBuffer(10);
|
|
88
|
-
buf.push(makeEvent("
|
|
89
|
-
buf.push(makeEvent("
|
|
90
|
-
buf.push(makeEvent("
|
|
88
|
+
buf.push(makeEvent("text_delta", 1));
|
|
89
|
+
buf.push(makeEvent("execution_complete", 2));
|
|
90
|
+
buf.push(makeEvent("text_delta", 3));
|
|
91
91
|
const types = buf.getEventTypes();
|
|
92
|
-
expect(types).toContain("
|
|
93
|
-
expect(types).toContain("
|
|
92
|
+
expect(types).toContain("text_delta");
|
|
93
|
+
expect(types).toContain("execution_complete");
|
|
94
94
|
expect(types).toHaveLength(2);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it("should preserve event types after eviction", () => {
|
|
98
98
|
const buf = new EventStreamBuffer(3);
|
|
99
|
-
buf.push(makeEvent("
|
|
99
|
+
buf.push(makeEvent("text_delta", 1));
|
|
100
100
|
buf.push(makeEvent("tool_start", 2));
|
|
101
|
-
buf.push(makeEvent("
|
|
102
|
-
// Evict the
|
|
101
|
+
buf.push(makeEvent("execution_complete", 3));
|
|
102
|
+
// Evict the text_delta event
|
|
103
103
|
buf.push(makeEvent("tool_end", 4));
|
|
104
104
|
buf.push(makeEvent("tool_end", 5));
|
|
105
|
-
//
|
|
105
|
+
// text_delta is evicted from the buffer but still tracked in types
|
|
106
106
|
const all = buf.getAll();
|
|
107
|
-
expect(all.every(e => e.type !== "
|
|
107
|
+
expect(all.every(e => e.type !== "text_delta")).toBe(true);
|
|
108
108
|
const types = buf.getEventTypes();
|
|
109
|
-
expect(types).toContain("
|
|
109
|
+
expect(types).toContain("text_delta");
|
|
110
110
|
expect(types).toContain("tool_start");
|
|
111
|
-
expect(types).toContain("
|
|
111
|
+
expect(types).toContain("execution_complete");
|
|
112
112
|
expect(types).toContain("tool_end");
|
|
113
113
|
expect(types).toHaveLength(4);
|
|
114
114
|
});
|
|
@@ -45,10 +45,10 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it("should fire onSSEEvent for
|
|
48
|
+
it("should fire onSSEEvent for text_delta events", async () => {
|
|
49
49
|
global.fetch = createMockFetch([
|
|
50
50
|
sseData({
|
|
51
|
-
type: "
|
|
51
|
+
type: "text_delta",
|
|
52
52
|
id: "step_1",
|
|
53
53
|
name: "Prompt 1",
|
|
54
54
|
executionType: "prompt",
|
|
@@ -56,7 +56,7 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
56
56
|
text: "Hello world"
|
|
57
57
|
}),
|
|
58
58
|
sseData({
|
|
59
|
-
type: "
|
|
59
|
+
type: "execution_complete",
|
|
60
60
|
flowId: "flow_1",
|
|
61
61
|
success: true,
|
|
62
62
|
duration: 100
|
|
@@ -79,11 +79,11 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
79
79
|
|
|
80
80
|
// Should capture both events
|
|
81
81
|
const stepChunkEvents = capturedSSEEvents.filter(
|
|
82
|
-
(e) => e.eventType === "
|
|
82
|
+
(e) => e.eventType === "text_delta"
|
|
83
83
|
);
|
|
84
84
|
expect(stepChunkEvents).toHaveLength(1);
|
|
85
85
|
expect(stepChunkEvents[0].payload).toMatchObject({
|
|
86
|
-
type: "
|
|
86
|
+
type: "text_delta",
|
|
87
87
|
text: "Hello world"
|
|
88
88
|
});
|
|
89
89
|
});
|
|
@@ -106,7 +106,7 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
106
106
|
completedAt: "2025-01-01T00:00:01.000Z"
|
|
107
107
|
}),
|
|
108
108
|
sseData({
|
|
109
|
-
type: "
|
|
109
|
+
type: "execution_complete",
|
|
110
110
|
flowId: "flow_1",
|
|
111
111
|
success: true,
|
|
112
112
|
duration: 200
|
|
@@ -148,10 +148,10 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
it("should fire onSSEEvent for
|
|
151
|
+
it("should fire onSSEEvent for execution_complete events", async () => {
|
|
152
152
|
global.fetch = createMockFetch([
|
|
153
153
|
sseData({
|
|
154
|
-
type: "
|
|
154
|
+
type: "execution_complete",
|
|
155
155
|
flowId: "flow_1",
|
|
156
156
|
success: true,
|
|
157
157
|
duration: 500,
|
|
@@ -175,11 +175,11 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
175
175
|
);
|
|
176
176
|
|
|
177
177
|
const flowCompleteEvents = capturedSSEEvents.filter(
|
|
178
|
-
(e) => e.eventType === "
|
|
178
|
+
(e) => e.eventType === "execution_complete"
|
|
179
179
|
);
|
|
180
180
|
expect(flowCompleteEvents).toHaveLength(1);
|
|
181
181
|
expect(flowCompleteEvents[0].payload).toMatchObject({
|
|
182
|
-
type: "
|
|
182
|
+
type: "execution_complete",
|
|
183
183
|
flowId: "flow_1",
|
|
184
184
|
success: true,
|
|
185
185
|
duration: 500
|
|
@@ -189,7 +189,7 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
189
189
|
it("should fire onSSEEvent for every event in a multi-event stream", async () => {
|
|
190
190
|
global.fetch = createMockFetch([
|
|
191
191
|
sseData({
|
|
192
|
-
type: "
|
|
192
|
+
type: "text_delta",
|
|
193
193
|
id: "step_1",
|
|
194
194
|
name: "Prompt 1",
|
|
195
195
|
executionType: "prompt",
|
|
@@ -197,7 +197,7 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
197
197
|
text: "Hello"
|
|
198
198
|
}),
|
|
199
199
|
sseData({
|
|
200
|
-
type: "
|
|
200
|
+
type: "text_delta",
|
|
201
201
|
id: "step_1",
|
|
202
202
|
name: "Prompt 1",
|
|
203
203
|
executionType: "prompt",
|
|
@@ -218,7 +218,7 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
218
218
|
duration: 50
|
|
219
219
|
}),
|
|
220
220
|
sseData({
|
|
221
|
-
type: "
|
|
221
|
+
type: "execution_complete",
|
|
222
222
|
flowId: "flow_1",
|
|
223
223
|
success: true,
|
|
224
224
|
duration: 300
|
|
@@ -244,11 +244,11 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
244
244
|
|
|
245
245
|
const types = capturedSSEEvents.map((e) => e.eventType);
|
|
246
246
|
expect(types).toEqual([
|
|
247
|
-
"
|
|
248
|
-
"
|
|
247
|
+
"text_delta",
|
|
248
|
+
"text_delta",
|
|
249
249
|
"tool_start",
|
|
250
250
|
"tool_complete",
|
|
251
|
-
"
|
|
251
|
+
"execution_complete"
|
|
252
252
|
]);
|
|
253
253
|
});
|
|
254
254
|
|
|
@@ -257,11 +257,11 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
257
257
|
// When SSE has no event prefix, payloadType = payload.type
|
|
258
258
|
global.fetch = createMockFetch([
|
|
259
259
|
sseData({
|
|
260
|
-
type: "
|
|
260
|
+
type: "text_delta",
|
|
261
261
|
text: "chunk1"
|
|
262
262
|
}),
|
|
263
263
|
sseData({
|
|
264
|
-
type: "
|
|
264
|
+
type: "execution_complete",
|
|
265
265
|
success: true
|
|
266
266
|
})
|
|
267
267
|
]);
|
|
@@ -281,8 +281,8 @@ describe("Event Capture Pipeline - onSSEEvent callback", () => {
|
|
|
281
281
|
);
|
|
282
282
|
|
|
283
283
|
// Event types should be resolved from payload.type
|
|
284
|
-
expect(capturedSSEEvents[0].eventType).toBe("
|
|
285
|
-
expect(capturedSSEEvents[1].eventType).toBe("
|
|
284
|
+
expect(capturedSSEEvents[0].eventType).toBe("text_delta");
|
|
285
|
+
expect(capturedSSEEvents[1].eventType).toBe("execution_complete");
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
|