@vellumai/assistant 0.12.2-staging.4 → 0.12.2-staging.5
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/package.json +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +1 -1
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -0
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +25 -0
- package/src/live-voice/live-voice-session.ts +210 -12
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +55 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +10 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/skills/managed-store.ts +56 -0
- package/src/tools/skills/find-similar-skills.test.ts +211 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
package/package.json
CHANGED
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
"name": "find_similar_skills",
|
|
121
|
-
"description": "Find the existing skills most similar to a goal. Scores the goal against the skill catalog's capability pages and returns a ranked shortlist of nearest skills, each with its name, description, source (bundled, managed, plugin, workspace, or extra), and similarity score. For a managed hit it also returns `author
|
|
121
|
+
"description": "Find the existing skills most similar to a goal. Scores the goal against the skill catalog's capability pages and returns a ranked shortlist of nearest skills, each with its name, description, source (bundled, managed, plugin, workspace, or extra), and similarity score. For a managed hit it also returns `author`: \"assistant\" if the assistant authored that skill (so it may be overwritten) or \"user\" if a person did (off-limits); `author` is omitted for non-managed sources and for managed skills with no recorded author. For a background memory pass, a hit it may refine (managed, assistant-authored) also carries `current`: the skill as it is now, in scaffold_managed_skill's own argument names (name, description, emoji, category, includes, activation_hints, avoid_when, body_markdown), so a refinement can be written from the real skill and carry forward what it is not changing. Read-only: it never creates, edits, or deletes anything.",
|
|
122
122
|
"category": "skills",
|
|
123
123
|
"risk": "low",
|
|
124
124
|
"input_schema": {
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering a look (`[LOOK:SCREEN]`, `[LOOK:CAMERA]`) on a turn of its own.
|
|
3
|
+
*
|
|
4
|
+
* The reply that asks for a look only acknowledges it. A client that declared
|
|
5
|
+
* `lookFrames` sends a fresh frame once it has carried the look out, and the
|
|
6
|
+
* session answers from that frame without waiting for the user to speak again.
|
|
7
|
+
* Without the declaration, or without the frame, nothing extra runs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createMockProvider,
|
|
14
|
+
textResponse,
|
|
15
|
+
} from "../../__tests__/helpers/mock-provider.js";
|
|
16
|
+
import { setConfig } from "../../__tests__/helpers/set-config.js";
|
|
17
|
+
import { waitFor } from "../../__tests__/helpers/wait-for.js";
|
|
18
|
+
|
|
19
|
+
setConfig("memory", { enabled: false });
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
VoiceTurnCallbacks,
|
|
23
|
+
VoiceTurnOptions,
|
|
24
|
+
} from "../../calls/voice-session-bridge.js";
|
|
25
|
+
import { Conversation } from "../../daemon/conversation.js";
|
|
26
|
+
import {
|
|
27
|
+
deleteConversation,
|
|
28
|
+
setConversation,
|
|
29
|
+
} from "../../daemon/conversation-registry.js";
|
|
30
|
+
import { uploadAttachment } from "../../persistence/attachments-store.js";
|
|
31
|
+
import {
|
|
32
|
+
createConversation,
|
|
33
|
+
getMessages,
|
|
34
|
+
} from "../../persistence/conversation-crud.js";
|
|
35
|
+
import { initializeDb } from "../../persistence/db-init.js";
|
|
36
|
+
import type {
|
|
37
|
+
StreamingTranscriber,
|
|
38
|
+
SttStreamServerEvent,
|
|
39
|
+
} from "../../stt/types.js";
|
|
40
|
+
import {
|
|
41
|
+
LiveVoiceSession,
|
|
42
|
+
type LiveVoiceTtsStreamer,
|
|
43
|
+
} from "../live-voice-session.js";
|
|
44
|
+
import type { LiveVoiceSessionFactoryContext } from "../live-voice-session-manager.js";
|
|
45
|
+
import type { LiveVoiceTtsOptions } from "../live-voice-tts.js";
|
|
46
|
+
import {
|
|
47
|
+
createLiveVoiceServerFrameSequencer,
|
|
48
|
+
type LiveVoiceServerFrame,
|
|
49
|
+
} from "../protocol.js";
|
|
50
|
+
import {
|
|
51
|
+
LOOK_FOLLOW_UP_CONTENT,
|
|
52
|
+
LOOK_FRAME_REASON,
|
|
53
|
+
} from "../session-controls.js";
|
|
54
|
+
|
|
55
|
+
await initializeDb();
|
|
56
|
+
|
|
57
|
+
const IMAGE_BASE64 =
|
|
58
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk";
|
|
59
|
+
|
|
60
|
+
/** Hands over one spoken sentence each time a capture is released. */
|
|
61
|
+
class MockStreamingTranscriber implements StreamingTranscriber {
|
|
62
|
+
readonly providerId = "deepgram" as const;
|
|
63
|
+
readonly boundaryId = "daemon-streaming" as const;
|
|
64
|
+
private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
|
|
65
|
+
|
|
66
|
+
async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
|
|
67
|
+
this.onEvent = onEvent;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sendAudio(): void {}
|
|
71
|
+
|
|
72
|
+
stop(): void {
|
|
73
|
+
this.onEvent?.({ type: "final", text: "look at my screen" });
|
|
74
|
+
this.onEvent?.({ type: "closed" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createHarness(options: { lookFrames: boolean }) {
|
|
79
|
+
const conversation = createConversation("Look follow-up");
|
|
80
|
+
const { provider } = createMockProvider([textResponse("")]);
|
|
81
|
+
const activeConversation = new Conversation(
|
|
82
|
+
conversation.id,
|
|
83
|
+
provider,
|
|
84
|
+
"system prompt",
|
|
85
|
+
() => {},
|
|
86
|
+
"/tmp",
|
|
87
|
+
{ maxTokens: 4096 },
|
|
88
|
+
);
|
|
89
|
+
activeConversation.setTrustContext({
|
|
90
|
+
trustClass: "guardian",
|
|
91
|
+
sourceChannel: "vellum",
|
|
92
|
+
});
|
|
93
|
+
setConversation(conversation.id, activeConversation);
|
|
94
|
+
|
|
95
|
+
const sequencer = createLiveVoiceServerFrameSequencer();
|
|
96
|
+
const frames: LiveVoiceServerFrame[] = [];
|
|
97
|
+
const context: LiveVoiceSessionFactoryContext = {
|
|
98
|
+
sessionId: "session-look",
|
|
99
|
+
startFrame: {
|
|
100
|
+
type: "start",
|
|
101
|
+
conversationId: conversation.id,
|
|
102
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24_000, channels: 1 },
|
|
103
|
+
textInput: true,
|
|
104
|
+
sessionControls: ["look_screen", "look_camera", "look_stop"],
|
|
105
|
+
...(options.lookFrames ? { lookFrames: true } : {}),
|
|
106
|
+
},
|
|
107
|
+
sendFrame: mock(async (payload) => {
|
|
108
|
+
const frame = sequencer.next(payload);
|
|
109
|
+
frames.push(frame);
|
|
110
|
+
return frame;
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const turns: VoiceTurnOptions[] = [];
|
|
115
|
+
const startVoiceTurn = mock(async (turnOptions: VoiceTurnOptions) => {
|
|
116
|
+
turns.push(turnOptions);
|
|
117
|
+
return { turnId: `bridge-turn-${turns.length}`, abort: mock() };
|
|
118
|
+
});
|
|
119
|
+
const streamTtsAudio: LiveVoiceTtsStreamer = mock(
|
|
120
|
+
async (ttsOptions: LiveVoiceTtsOptions) => ({
|
|
121
|
+
provider: "fish-audio" as const,
|
|
122
|
+
contentType: "audio/pcm",
|
|
123
|
+
sampleRate: 24_000,
|
|
124
|
+
chunks: 1,
|
|
125
|
+
bytes: Buffer.byteLength(ttsOptions.text),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
let turnCount = 0;
|
|
130
|
+
const session = new LiveVoiceSession(context, {
|
|
131
|
+
resolveTranscriber: mock(async () => new MockStreamingTranscriber()),
|
|
132
|
+
startVoiceTurn,
|
|
133
|
+
streamTtsAudio,
|
|
134
|
+
createTurnId: () => `live-turn-${++turnCount}`,
|
|
135
|
+
emitMetrics: false,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const callbacks = (index: number): VoiceTurnCallbacks | undefined =>
|
|
139
|
+
turns[index]?.callbacks;
|
|
140
|
+
|
|
141
|
+
/** Finish turn `index` with `text` and wait for its speech to drain. */
|
|
142
|
+
const reply = async (index: number, text: string): Promise<void> => {
|
|
143
|
+
const turnCallbacks = callbacks(index);
|
|
144
|
+
turnCallbacks?.assistant_text_delta?.({
|
|
145
|
+
type: "assistant_text_delta",
|
|
146
|
+
text,
|
|
147
|
+
conversationId: conversation.id,
|
|
148
|
+
});
|
|
149
|
+
turnCallbacks?.message_complete?.({
|
|
150
|
+
type: "message_complete",
|
|
151
|
+
conversationId: conversation.id,
|
|
152
|
+
messageId: `assistant-message-${index}`,
|
|
153
|
+
});
|
|
154
|
+
const doneCount = index + 1;
|
|
155
|
+
await waitFor(
|
|
156
|
+
() =>
|
|
157
|
+
frames.filter((frame) => frame.type === "tts_done").length >= doneCount,
|
|
158
|
+
{ message: `Timed out waiting for turn ${index} to drain` },
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Ask for a look, and wait for the control to reach the client. */
|
|
163
|
+
const askForLook = async (): Promise<void> => {
|
|
164
|
+
await session.start();
|
|
165
|
+
await session.handleClientFrame({ type: "ptt_release" });
|
|
166
|
+
await waitFor(() => turns.length === 1, {
|
|
167
|
+
message: "Timed out waiting for the spoken turn",
|
|
168
|
+
});
|
|
169
|
+
await reply(0, "Taking a look. [LOOK:SCREEN]");
|
|
170
|
+
await waitFor(
|
|
171
|
+
() => frames.some((frame) => frame.type === "session_control"),
|
|
172
|
+
{ message: "Timed out waiting for the look control" },
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Send a real kept frame with the given timing reason. */
|
|
177
|
+
const sendFrame = async (reason: string): Promise<void> => {
|
|
178
|
+
const attachment = await uploadAttachment(
|
|
179
|
+
"frame.png",
|
|
180
|
+
"image/png",
|
|
181
|
+
IMAGE_BASE64,
|
|
182
|
+
);
|
|
183
|
+
const before = getMessages(conversation.id).length;
|
|
184
|
+
await session.handleClientFrame({
|
|
185
|
+
type: "sight_frame",
|
|
186
|
+
attachmentId: attachment.id,
|
|
187
|
+
timing: {
|
|
188
|
+
reason,
|
|
189
|
+
keepToEncodedMs: 1,
|
|
190
|
+
encodedToUploadedMs: 1,
|
|
191
|
+
uploadedToSentMs: 0,
|
|
192
|
+
bytes: 1,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
await waitFor(() => getMessages(conversation.id).length > before, {
|
|
196
|
+
message: "Timed out waiting for the frame to persist",
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
frames,
|
|
202
|
+
session,
|
|
203
|
+
turns,
|
|
204
|
+
reply,
|
|
205
|
+
askForLook,
|
|
206
|
+
sendFrame,
|
|
207
|
+
dispose: async () => {
|
|
208
|
+
await session.close("client_end");
|
|
209
|
+
deleteConversation(conversation.id);
|
|
210
|
+
activeConversation.dispose();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Long enough for a follow-up that was going to start to have started. */
|
|
216
|
+
async function settle(): Promise<void> {
|
|
217
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
describe("live-voice look follow-up", () => {
|
|
221
|
+
test("answers the look from its frame on a hidden turn of its own", async () => {
|
|
222
|
+
const harness = createHarness({ lookFrames: true });
|
|
223
|
+
try {
|
|
224
|
+
await harness.askForLook();
|
|
225
|
+
expect(harness.turns).toHaveLength(1);
|
|
226
|
+
|
|
227
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
228
|
+
await waitFor(() => harness.turns.length === 2, {
|
|
229
|
+
message: "Timed out waiting for the look to be answered",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const followUp = harness.turns[1];
|
|
233
|
+
expect(followUp?.content).toBe(LOOK_FOLLOW_UP_CONTENT);
|
|
234
|
+
expect(followUp?.hiddenSyntheticPrompt).toBe(true);
|
|
235
|
+
expect(followUp?.voiceControlPrompt).toContain(
|
|
236
|
+
"You just took a fresh look at their screen",
|
|
237
|
+
);
|
|
238
|
+
} finally {
|
|
239
|
+
await harness.dispose();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("the reply that asks for a look is taught to only acknowledge it", async () => {
|
|
244
|
+
const harness = createHarness({ lookFrames: true });
|
|
245
|
+
try {
|
|
246
|
+
await harness.askForLook();
|
|
247
|
+
expect(harness.turns[0]?.voiceControlPrompt).toContain(
|
|
248
|
+
"Use it even when their screen is already shared with you",
|
|
249
|
+
);
|
|
250
|
+
} finally {
|
|
251
|
+
await harness.dispose();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a client that did not declare lookFrames keeps the old look", async () => {
|
|
256
|
+
const harness = createHarness({ lookFrames: false });
|
|
257
|
+
try {
|
|
258
|
+
await harness.askForLook();
|
|
259
|
+
expect(harness.turns[0]?.voiceControlPrompt).toContain(
|
|
260
|
+
"say you will take it from their next words",
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
264
|
+
await settle();
|
|
265
|
+
expect(harness.turns).toHaveLength(1);
|
|
266
|
+
} finally {
|
|
267
|
+
await harness.dispose();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("an ambient frame does not answer the look", async () => {
|
|
272
|
+
const harness = createHarness({ lookFrames: true });
|
|
273
|
+
try {
|
|
274
|
+
await harness.askForLook();
|
|
275
|
+
await harness.sendFrame("heartbeat");
|
|
276
|
+
await settle();
|
|
277
|
+
expect(harness.turns).toHaveLength(1);
|
|
278
|
+
} finally {
|
|
279
|
+
await harness.dispose();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// The turn started before the frame was in the conversation, so it could
|
|
284
|
+
// not have read it: the look is still owed an answer once that turn is done.
|
|
285
|
+
test("a turn that started before the frame landed does not answer the look", async () => {
|
|
286
|
+
const harness = createHarness({ lookFrames: true });
|
|
287
|
+
try {
|
|
288
|
+
await harness.askForLook();
|
|
289
|
+
await harness.session.handleClientFrame({
|
|
290
|
+
type: "text",
|
|
291
|
+
text: "the second dropdown",
|
|
292
|
+
});
|
|
293
|
+
await waitFor(() => harness.turns.length === 2, {
|
|
294
|
+
message: "Timed out waiting for the typed turn",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
298
|
+
await harness.reply(1, "Which one do you mean?");
|
|
299
|
+
await waitFor(() => harness.turns.length === 3, {
|
|
300
|
+
timeoutMs: 2_000,
|
|
301
|
+
message: "Timed out waiting for the look to be answered",
|
|
302
|
+
});
|
|
303
|
+
expect(harness.turns[2]?.content).toBe(LOOK_FOLLOW_UP_CONTENT);
|
|
304
|
+
} finally {
|
|
305
|
+
await harness.dispose();
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("a turn that starts after the frame landed is not answered twice", async () => {
|
|
310
|
+
const harness = createHarness({ lookFrames: true });
|
|
311
|
+
try {
|
|
312
|
+
await harness.askForLook();
|
|
313
|
+
await harness.session.handleClientFrame({
|
|
314
|
+
type: "text",
|
|
315
|
+
text: "the second dropdown",
|
|
316
|
+
});
|
|
317
|
+
await waitFor(() => harness.turns.length === 2, {
|
|
318
|
+
message: "Timed out waiting for the typed turn",
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Lands while the typed turn holds the floor, so the look waits.
|
|
322
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
323
|
+
await harness.reply(1, "Which one do you mean?");
|
|
324
|
+
// The user's next turn starts before the look's wait checks again, and
|
|
325
|
+
// it reads the frame that is now in the conversation.
|
|
326
|
+
await harness.session.handleClientFrame({
|
|
327
|
+
type: "text",
|
|
328
|
+
text: "the plan picker",
|
|
329
|
+
});
|
|
330
|
+
await waitFor(() => harness.turns.length === 3, {
|
|
331
|
+
message: "Timed out waiting for the second typed turn",
|
|
332
|
+
});
|
|
333
|
+
await harness.reply(2, "That one sets the billing plan.");
|
|
334
|
+
await settle();
|
|
335
|
+
|
|
336
|
+
expect(harness.turns).toHaveLength(3);
|
|
337
|
+
expect(harness.turns[2]?.content).toBe("the plan picker");
|
|
338
|
+
} finally {
|
|
339
|
+
await harness.dispose();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("an interrupt drops a look still waiting on its frame", async () => {
|
|
344
|
+
const harness = createHarness({ lookFrames: true });
|
|
345
|
+
try {
|
|
346
|
+
await harness.askForLook();
|
|
347
|
+
await harness.session.handleClientFrame({ type: "interrupt" });
|
|
348
|
+
|
|
349
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
350
|
+
await settle();
|
|
351
|
+
expect(harness.turns).toHaveLength(1);
|
|
352
|
+
} finally {
|
|
353
|
+
await harness.dispose();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("the turn that answers a look cannot chain another look", async () => {
|
|
358
|
+
const harness = createHarness({ lookFrames: true });
|
|
359
|
+
try {
|
|
360
|
+
await harness.askForLook();
|
|
361
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
362
|
+
await waitFor(() => harness.turns.length === 2, {
|
|
363
|
+
message: "Timed out waiting for the look to be answered",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await harness.reply(1, "It is the plan picker. [LOOK:SCREEN]");
|
|
367
|
+
await harness.sendFrame(LOOK_FRAME_REASON);
|
|
368
|
+
await settle();
|
|
369
|
+
expect(harness.turns).toHaveLength(2);
|
|
370
|
+
} finally {
|
|
371
|
+
await harness.dispose();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -1873,6 +1873,40 @@ describe("LiveVoiceSession server VAD", () => {
|
|
|
1873
1873
|
expect(announcement?.voiceControlPrompt).toContain("first question");
|
|
1874
1874
|
});
|
|
1875
1875
|
|
|
1876
|
+
// Deepgram Flux sends interim updates through silence, each an empty
|
|
1877
|
+
// partial. A call nobody is talking on is still idle.
|
|
1878
|
+
test("an empty partial on an idle call does not hold the announcement back", async () => {
|
|
1879
|
+
const continuation = makeControlledContinuation();
|
|
1880
|
+
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
1881
|
+
const { frames, session, transcribers } = createHarness({
|
|
1882
|
+
finals: ["first question", ""],
|
|
1883
|
+
startVoiceTurn,
|
|
1884
|
+
streamTtsAudio: makeImmediateTts(),
|
|
1885
|
+
spawnBackgroundContinuation: continuation.spawnBackgroundContinuation,
|
|
1886
|
+
continuationAnnounceSilenceMs: 20,
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
await session.start();
|
|
1890
|
+
await session.handleBinaryAudio(LOUD_CHUNK);
|
|
1891
|
+
await waitFor(() => frames.some((frame) => frame.type === "thinking"));
|
|
1892
|
+
await session.handleBinaryAudio(SUSTAINED_LOUD_CHUNK);
|
|
1893
|
+
await waitFor(
|
|
1894
|
+
() => continuation.spawnBackgroundContinuation.mock.calls.length === 1,
|
|
1895
|
+
);
|
|
1896
|
+
await waitFor(() =>
|
|
1897
|
+
frames.some((frame) => frame.type === "utterance_discarded"),
|
|
1898
|
+
);
|
|
1899
|
+
await waitFor(() => transcribers.some((t) => !t.stopped));
|
|
1900
|
+
for (const transcriber of transcribers) {
|
|
1901
|
+
if (!transcriber.stopped) {
|
|
1902
|
+
transcriber.emit({ type: "partial", text: "" });
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
continuation.finish("THE_RESULT");
|
|
1907
|
+
await waitFor(() => announcementOf(calls) !== undefined);
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1876
1910
|
test("an announcement persists hidden and is never delivered twice", async () => {
|
|
1877
1911
|
const continuation = makeControlledContinuation();
|
|
1878
1912
|
const { startVoiceTurn, calls } = makeResurfaceTurnStarter();
|
|
@@ -800,6 +800,54 @@ describe("parseLiveVoiceClientTextFrame", () => {
|
|
|
800
800
|
});
|
|
801
801
|
});
|
|
802
802
|
|
|
803
|
+
test("parses the lookFrames capability on the start frame", () => {
|
|
804
|
+
const result = validateLiveVoiceClientFrame({
|
|
805
|
+
type: "start",
|
|
806
|
+
lookFrames: true,
|
|
807
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
expect(result.ok).toBe(true);
|
|
811
|
+
if (!result.ok) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
expect(result.frame).toMatchObject({ type: "start", lookFrames: true });
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("omits lookFrames from the start frame when false", () => {
|
|
818
|
+
// False and absent mean the same thing: no look frame is coming, so the
|
|
819
|
+
// session must not wait for one.
|
|
820
|
+
const result = validateLiveVoiceClientFrame({
|
|
821
|
+
type: "start",
|
|
822
|
+
lookFrames: false,
|
|
823
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
expect(result.ok).toBe(true);
|
|
827
|
+
if (!result.ok) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
expect("lookFrames" in result.frame).toBe(false);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("returns a typed protocol error for a non-boolean lookFrames", () => {
|
|
834
|
+
const result = validateLiveVoiceClientFrame({
|
|
835
|
+
type: "start",
|
|
836
|
+
lookFrames: 1,
|
|
837
|
+
audio: { mimeType: "audio/pcm", sampleRate: 24000, channels: 1 },
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
expect(result.ok).toBe(false);
|
|
841
|
+
if (result.ok) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
expect(result.error).toMatchObject({
|
|
845
|
+
code: "invalid_field",
|
|
846
|
+
field: "lookFrames",
|
|
847
|
+
frameType: "start",
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
803
851
|
test("returns typed protocol errors for missing audio configuration fields", () => {
|
|
804
852
|
const result = validateLiveVoiceClientFrame({
|
|
805
853
|
type: "start",
|
|
@@ -105,6 +105,31 @@ describe("sessionControlTeaching", () => {
|
|
|
105
105
|
);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// A client that sends a fresh frame for every look lets the session answer
|
|
109
|
+
// it, so the reply asking for the look only acknowledges, and asking again is
|
|
110
|
+
// how a share already running gets looked at as it is now.
|
|
111
|
+
test("a client that sends look frames is taught the look is answered for it", () => {
|
|
112
|
+
const teaching = sessionControlTeaching(
|
|
113
|
+
["look_screen", "look_camera"],
|
|
114
|
+
{},
|
|
115
|
+
{ lookFrames: true },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(teaching).toContain("[LOOK:SCREEN]");
|
|
119
|
+
expect(teaching).toContain(
|
|
120
|
+
"Use it even when their screen is already shared with you",
|
|
121
|
+
);
|
|
122
|
+
expect(teaching).toContain("Use it even when the camera is already on");
|
|
123
|
+
expect(teaching).not.toContain("take it from their next words");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a client that sends no look frames keeps the next-words look", () => {
|
|
127
|
+
const teaching = sessionControlTeaching(["look_screen"], {});
|
|
128
|
+
|
|
129
|
+
expect(teaching).toContain("take it from their next words");
|
|
130
|
+
expect(teaching).not.toContain("Use it even when");
|
|
131
|
+
});
|
|
132
|
+
|
|
108
133
|
test("the front-door leg keeps its verdict tokens", () => {
|
|
109
134
|
expect(sessionControlTeaching(["end"], { frontDoor: true })).not.toContain(
|
|
110
135
|
"Never emit any other bracketed marker.",
|