@guava-ai/guava-sdk 0.17.0 → 0.19.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.
Files changed (100) hide show
  1. package/dist/examples/example.test.d.ts +5 -0
  2. package/dist/examples/example.test.js +46 -0
  3. package/dist/examples/example.test.js.map +1 -0
  4. package/dist/examples/help-desk.d.ts +3 -1
  5. package/dist/examples/help-desk.js +25 -9
  6. package/dist/examples/help-desk.js.map +1 -1
  7. package/dist/examples/property-insurance.js +4 -1
  8. package/dist/examples/property-insurance.js.map +1 -1
  9. package/dist/examples/restaurant-waitlist.js +4 -1
  10. package/dist/examples/restaurant-waitlist.js.map +1 -1
  11. package/dist/examples/scheduling-outbound.js +6 -0
  12. package/dist/examples/scheduling-outbound.js.map +1 -1
  13. package/dist/src/action-item.d.ts +4 -4
  14. package/dist/src/agent.d.ts +81 -16
  15. package/dist/src/agent.js +394 -127
  16. package/dist/src/agent.js.map +1 -1
  17. package/dist/src/auth.d.ts +27 -0
  18. package/dist/src/auth.js +127 -0
  19. package/dist/src/auth.js.map +1 -0
  20. package/dist/src/call.d.ts +1 -1
  21. package/dist/src/call.js +2 -2
  22. package/dist/src/call.js.map +1 -1
  23. package/dist/src/client.d.ts +4 -11
  24. package/dist/src/client.js +22 -14
  25. package/dist/src/client.js.map +1 -1
  26. package/dist/src/commands.d.ts +3 -3
  27. package/dist/src/events.d.ts +22 -0
  28. package/dist/src/events.js +19 -5
  29. package/dist/src/events.js.map +1 -1
  30. package/dist/src/helpers/llm.d.ts +2 -0
  31. package/dist/src/helpers/llm.js +17 -0
  32. package/dist/src/helpers/llm.js.map +1 -0
  33. package/dist/src/index.d.ts +2 -0
  34. package/dist/src/index.js +5 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logging.js +16 -11
  37. package/dist/src/logging.js.map +1 -1
  38. package/dist/src/socket/call-info.d.ts +35 -0
  39. package/dist/src/socket/call-info.js +59 -0
  40. package/dist/src/socket/call-info.js.map +1 -0
  41. package/dist/src/socket/client.d.ts +51 -0
  42. package/dist/src/socket/client.js +455 -0
  43. package/dist/src/socket/client.js.map +1 -0
  44. package/dist/src/socket/listen-inbound.d.ts +83 -0
  45. package/dist/src/socket/listen-inbound.js +82 -0
  46. package/dist/src/socket/listen-inbound.js.map +1 -0
  47. package/dist/src/socket/protocol.d.ts +127 -0
  48. package/dist/src/socket/protocol.js +69 -0
  49. package/dist/src/socket/protocol.js.map +1 -0
  50. package/dist/src/socket/utils.d.ts +8 -0
  51. package/dist/src/socket/utils.js +26 -0
  52. package/dist/src/socket/utils.js.map +1 -0
  53. package/dist/src/telemetry.d.ts +3 -3
  54. package/dist/src/telemetry.js +9 -7
  55. package/dist/src/telemetry.js.map +1 -1
  56. package/dist/src/testing/chat.d.ts +2 -0
  57. package/dist/src/testing/chat.js +181 -0
  58. package/dist/src/testing/chat.js.map +1 -0
  59. package/dist/src/testing/mocks.d.ts +6 -0
  60. package/dist/src/testing/mocks.js +14 -0
  61. package/dist/src/testing/mocks.js.map +1 -0
  62. package/dist/src/testing/protocol.d.ts +46 -0
  63. package/dist/src/testing/protocol.js +61 -0
  64. package/dist/src/testing/protocol.js.map +1 -0
  65. package/dist/src/testing/session.d.ts +26 -0
  66. package/dist/src/testing/session.js +219 -0
  67. package/dist/src/testing/session.js.map +1 -0
  68. package/dist/src/utils.d.ts +1 -0
  69. package/dist/src/utils.js +15 -1
  70. package/dist/src/utils.js.map +1 -1
  71. package/dist/src/version.d.ts +1 -1
  72. package/dist/src/version.js +1 -1
  73. package/dist/src/webrtc-helper.js +11 -11
  74. package/examples/example.test.ts +58 -0
  75. package/examples/help-desk.ts +14 -3
  76. package/examples/property-insurance.ts +3 -1
  77. package/examples/restaurant-waitlist.ts +3 -1
  78. package/examples/scheduling-outbound.ts +7 -0
  79. package/package.json +9 -1
  80. package/src/agent.ts +372 -162
  81. package/src/auth.ts +109 -0
  82. package/src/call.ts +3 -3
  83. package/src/client.ts +32 -15
  84. package/src/events.ts +24 -10
  85. package/src/helpers/llm.ts +20 -0
  86. package/src/index.ts +2 -0
  87. package/src/logging.ts +21 -13
  88. package/src/socket/call-info.ts +30 -0
  89. package/src/socket/client.ts +433 -0
  90. package/src/socket/listen-inbound.ts +62 -0
  91. package/src/socket/protocol.ts +89 -0
  92. package/src/socket/utils.ts +25 -0
  93. package/src/telemetry.ts +11 -8
  94. package/src/testing/chat.ts +196 -0
  95. package/src/testing/mocks.ts +12 -0
  96. package/src/testing/protocol.ts +40 -0
  97. package/src/testing/session.ts +218 -0
  98. package/src/utils.ts +15 -1
  99. package/src/version.ts +1 -1
  100. package/src/webrtc-helper.ts +11 -11
package/src/agent.ts CHANGED
@@ -1,35 +1,31 @@
1
1
  import WebSocket from "ws";
2
2
  import { Call, Client } from "./index.ts";
3
3
  import { getDefaultLogger, type Logger } from "./logging.ts";
4
- import { getBaseUrl } from "./utils.ts";
4
+ import { getBaseUrl, fetchOrThrow } from "./utils.ts";
5
5
  import { runWebrtcHelper } from "./webrtc-helper.ts";
6
- import * as z from "zod";
7
6
  import {
8
- ListenInboundCommand,
9
- InboundTunnelCommand,
10
7
  AnswerQuestionCommand,
11
8
  ChoiceResultCommand,
12
9
  RegisteredHooksCommand,
13
- AcceptInboundCallCommand,
14
- RejectInboundCallCommand,
15
- StartOutboundCallCommand,
16
10
  ActionSuggestionCommand,
11
+ type Command,
17
12
  } from "./commands.ts";
18
13
  import {
19
14
  type GuavaEvent,
20
15
  type CallerSpeechEvent,
21
16
  type AgentSpeechEvent,
22
- InboundTunnelEvent,
23
- ErrorEvent,
24
- SessionStartedEvent,
25
- decodeEvent,
17
+ decodeEventDict,
26
18
  } from "./events.ts";
27
19
  import { telemetryClient } from "./telemetry.ts";
20
+ import { GuavaSocket, GuavaSocketClosedError } from "./socket/client.ts";
21
+ import * as ListenInbound from "./socket/listen-inbound.ts";
22
+ import type { CallInfo } from "./socket/call-info.ts";
23
+ import { TestSession } from "./testing/session.ts";
24
+ import { SessionStarted } from "./testing/protocol.ts";
25
+ import { runChat } from "./testing/chat.ts";
26
+ import { _generate } from "./helpers/llm.ts";
28
27
 
29
- export interface CallInfo {
30
- caller_number: string | null;
31
- agent_number: string | null;
32
- }
28
+ export type { CallInfo } from "./socket/call-info.ts";
33
29
 
34
30
  export type IncomingCallAction = { action: "accept" } | { action: "decline" };
35
31
 
@@ -38,13 +34,6 @@ export interface SuggestedAction {
38
34
  description?: string;
39
35
  }
40
36
 
41
- /**
42
- * @description convenience function for stringifying data according to a schema
43
- */
44
- function stringifyZod<Schema extends z.ZodType>(schema: Schema, data: z.input<Schema>): string {
45
- return JSON.stringify(schema.parse(data));
46
- }
47
-
48
37
  export type InboundConnection = { agent_number: string } | { webrtc_code: string };
49
38
 
50
39
  @telemetryClient.trackClass()
@@ -153,6 +142,52 @@ export class Agent {
153
142
  this._onSessionEnd = callback;
154
143
  }
155
144
 
145
+ get handlers() {
146
+ return {
147
+ onCallReceived: (callInfo: CallInfo) => this._onCallReceived(callInfo),
148
+ onCallStart: (call: Call) => {
149
+ if (!this._onCallStart) throw new Error("No onCallStart handler registered.");
150
+ return this._onCallStart(call);
151
+ },
152
+ onCallerSpeech: (call: Call, event: CallerSpeechEvent) => {
153
+ if (!this._onCallerSpeech) throw new Error("No onCallerSpeech handler registered.");
154
+ return this._onCallerSpeech(call, event);
155
+ },
156
+ onAgentSpeech: (call: Call, event: AgentSpeechEvent) => {
157
+ if (!this._onAgentSpeech) throw new Error("No onAgentSpeech handler registered.");
158
+ return this._onAgentSpeech(call, event);
159
+ },
160
+ onQuestion: (call: Call, question: string) => {
161
+ if (!this._onQuestion) throw new Error("No onQuestion handler registered.");
162
+ return this._onQuestion(call, question);
163
+ },
164
+ onTaskComplete: (taskId: string, call: Call) => {
165
+ if (this._onTaskCompleteGeneric) return this._onTaskCompleteGeneric(call, taskId);
166
+ if (taskId in this._onTaskCompleteHandlers)
167
+ return this._onTaskCompleteHandlers[taskId](call);
168
+ throw new Error(`No onTaskComplete handler registered for task '${taskId}'.`);
169
+ },
170
+ onSearchQuery: (fieldKey: string, call: Call, query: string) => {
171
+ if (!(fieldKey in this._searchQueryHandlers))
172
+ throw new Error(`No onSearchQuery handler registered for field '${fieldKey}'.`);
173
+ return this._searchQueryHandlers[fieldKey](call, query);
174
+ },
175
+ onActionRequest: (call: Call, intentSummary: string) => {
176
+ if (!this._onActionRequested) throw new Error("No onActionRequest handler registered.");
177
+ return this._onActionRequested(call, intentSummary);
178
+ },
179
+ onAction: (actionKey: string, call: Call) => {
180
+ if (this._onActionGeneric) return this._onActionGeneric(call, actionKey);
181
+ if (actionKey in this._onActionHandlers) return this._onActionHandlers[actionKey](call);
182
+ throw new Error(`No onAction handler registered for action '${actionKey}'.`);
183
+ },
184
+ onSessionEnd: (call: Call) => {
185
+ if (!this._onSessionEnd) throw new Error("No onSessionEnd handler registered.");
186
+ return this._onSessionEnd(call);
187
+ },
188
+ };
189
+ }
190
+
156
191
  onReachPerson(callback: (call: Call, availability: string) => Promise<void>): void {
157
192
  this.onTaskComplete("reach_person", async (call) => {
158
193
  const availability = (await call.getField("contact_availability")) as string;
@@ -160,13 +195,11 @@ export class Agent {
160
195
  });
161
196
  }
162
197
 
163
- listenPhone(phoneNumber: string): InboundListener {
164
- return this._listenInbound({
165
- agent_number: phoneNumber,
166
- });
198
+ async listenPhone(phoneNumber: string): Promise<void> {
199
+ return this._listenInbound({ agent_number: phoneNumber });
167
200
  }
168
201
 
169
- async listenWebrtc(webrtcCode?: string): Promise<InboundListener> {
202
+ async listenWebrtc(webrtcCode?: string): Promise<void> {
170
203
  if (!webrtcCode) {
171
204
  this._logger.info("No WebRTC code provided. Creating a temporary one.");
172
205
  webrtcCode = await this._client.createWebrtcAgent(3600);
@@ -176,11 +209,13 @@ export class Agent {
176
209
 
177
210
  async callLocal(): Promise<void> {
178
211
  const webrtcCode = await this._client.createWebrtcAgent(300);
179
- this._listenInbound({ webrtc_code: webrtcCode });
212
+ this._listenInbound({ webrtc_code: webrtcCode }).catch((err) => {
213
+ this._logger.error("Listen loop error: %s", err);
214
+ });
180
215
  await runWebrtcHelper(webrtcCode, getBaseUrl());
181
216
  }
182
217
 
183
- private async _dispatchEvent(call: Call, event: GuavaEvent) {
218
+ private async _dispatchEvent(call: Call, event: GuavaEvent, testSession?: TestSession) {
184
219
  if (event.event_type === "caller-speech") {
185
220
  if (this._onCallerSpeech !== undefined) {
186
221
  await this._onCallerSpeech(call, event);
@@ -189,17 +224,6 @@ export class Agent {
189
224
  if (this._onAgentSpeech !== undefined) {
190
225
  await this._onAgentSpeech(call, event);
191
226
  }
192
- } else if (event.event_type === "inbound-call") {
193
- this._logger.info(`Received inbound call from ${event.caller_number ?? "unknown"}`);
194
- const action = await this._onCallReceived({
195
- caller_number: event.caller_number,
196
- agent_number: event.agent_number,
197
- });
198
- if (action.action === "accept") {
199
- call.sendCommand(AcceptInboundCallCommand, { command_type: "accept-inbound" });
200
- } else {
201
- call.sendCommand(RejectInboundCallCommand, { command_type: "reject-inbound" });
202
- }
203
227
  } else if (event.event_type === "task-done") {
204
228
  this._logger.info(`Task ${event.task_id} completed.`);
205
229
  if (this._onTaskCompleteGeneric !== undefined) {
@@ -219,7 +243,7 @@ export class Agent {
219
243
  this._logger.error(`Error occurred while answering question: ${err}`);
220
244
  answer = "An error occurred and the question could not be answered.";
221
245
  }
222
- call.sendCommand(AnswerQuestionCommand, {
246
+ await call.sendCommand(AnswerQuestionCommand, {
223
247
  command_type: "answer-question",
224
248
  question_id: event.question_id,
225
249
  answer,
@@ -228,14 +252,13 @@ export class Agent {
228
252
  this._logger.warn(
229
253
  `Received question but no onQuestion handler is registered: ${event.question}`,
230
254
  );
231
- call.sendCommand(AnswerQuestionCommand, {
255
+ await call.sendCommand(AnswerQuestionCommand, {
232
256
  command_type: "answer-question",
233
257
  question_id: event.question_id,
234
258
  answer: "I don't have an answer to that question.",
235
259
  });
236
260
  }
237
261
  } else if (event.event_type === "action-item-done") {
238
- this._logger.info(`Action item '${event.key}' completed.`);
239
262
  call._fieldValues[event.key] = event.payload;
240
263
  } else if (event.event_type === "choice-query") {
241
264
  this._logger.info(`Received search query for field '${event.field_key}': ${event.query}`);
@@ -246,7 +269,7 @@ export class Agent {
246
269
  );
247
270
  } else {
248
271
  const [matchedChoices, otherChoices] = await handler(call, event.query);
249
- call.sendCommand(ChoiceResultCommand, {
272
+ await call.sendCommand(ChoiceResultCommand, {
250
273
  command_type: "choice-query-result",
251
274
  field_key: event.field_key,
252
275
  query_id: event.query_id,
@@ -260,7 +283,7 @@ export class Agent {
260
283
  if (this._onActionRequested !== undefined) {
261
284
  suggestion = await this._onActionRequested(call, event.intent_summary);
262
285
  }
263
- call.sendCommand(ActionSuggestionCommand, {
286
+ await call.sendCommand(ActionSuggestionCommand, {
264
287
  command_type: "action-suggestion",
265
288
  intent_id: event.intent_id,
266
289
  action_key: suggestion?.key ?? null,
@@ -268,6 +291,9 @@ export class Agent {
268
291
  });
269
292
  } else if (event.event_type === "execute-action") {
270
293
  this._logger.info(`Executing action '${event.action_key}'`);
294
+ if (testSession) {
295
+ testSession.executedActions.push(event.action_key);
296
+ }
271
297
  let onActionFunc: (() => Promise<void>) | undefined;
272
298
  if (this._onActionGeneric !== undefined) {
273
299
  onActionFunc = () => this._onActionGeneric!(call, event.action_key);
@@ -280,7 +306,10 @@ export class Agent {
280
306
  this._logger.warn(`No handler registered for action '${event.action_key}'`);
281
307
  }
282
308
  } else if (event.event_type === "bot-session-ended") {
283
- this._logger.info("Session ended.");
309
+ this._logger.info(`Session ended: ${event.termination_reason}`);
310
+ if (testSession) {
311
+ testSession.terminationReason = event.termination_reason;
312
+ }
284
313
  if (this._onSessionEnd !== undefined) {
285
314
  await this._onSessionEnd(call);
286
315
  }
@@ -291,14 +320,14 @@ export class Agent {
291
320
  }
292
321
  }
293
322
 
294
- async _startCall(variables: Record<string, any> = {}): Promise<Call> {
323
+ async _initCall(variables: Record<string, any> = {}): Promise<Call> {
295
324
  const call = new Call(variables);
296
- call.setPersona({
325
+ await call.setPersona({
297
326
  agentName: this._name,
298
327
  agentPurpose: this._purpose,
299
328
  organizationName: this._organization,
300
329
  });
301
- call.sendCommand(RegisteredHooksCommand, {
330
+ await call.sendCommand(RegisteredHooksCommand, {
302
331
  command_type: "registered-hooks",
303
332
  has_on_question: this._onQuestion !== undefined,
304
333
  has_on_intent: false,
@@ -310,142 +339,334 @@ export class Agent {
310
339
  return call;
311
340
  }
312
341
 
313
- _listenInbound(conn: InboundConnection): InboundListener {
314
- const calls: Record<string, Call> = {};
315
-
316
- // return a way to *stop* listening
317
- const url = new URL("v1/listen-inbound", this._client.getWebsocketBase());
318
- const ws = new WebSocket(url, {
319
- headers: this._client.headers(),
342
+ async _attachToCall(
343
+ callId: string,
344
+ initialVariables: Record<string, any> = {},
345
+ testSession?: TestSession,
346
+ ): Promise<void> {
347
+ const call = await this._initCall(initialVariables);
348
+
349
+ const url = new URL(`v2/connect-call/${callId}`, this._client.getWebsocketBase());
350
+ await using socket = await new GuavaSocket<Command, GuavaEvent | null>(
351
+ `call-connection-${callId}`,
352
+ url.toString(),
353
+ this._client,
354
+ (cmd) => cmd as unknown as Record<string, unknown>,
355
+ (payload) => decodeEventDict(payload),
356
+ 18000,
357
+ ).connect();
358
+
359
+ await call.setDrain(async (commands) => {
360
+ for (const command of commands.splice(0)) {
361
+ socket.send(command);
362
+ }
320
363
  });
321
- let agent_number: string | undefined;
322
- let webrtc_code: string | undefined;
364
+
365
+ try {
366
+ for await (const event of socket) {
367
+ if (event === null) continue;
368
+ await this._dispatchEvent(call, event, testSession);
369
+ if (
370
+ event.event_type === "bot-session-ended" ||
371
+ event.event_type === "outbound-call-failed"
372
+ ) {
373
+ break;
374
+ }
375
+ }
376
+ } catch (e) {
377
+ if (!(e instanceof GuavaSocketClosedError)) throw e;
378
+ }
379
+ }
380
+
381
+ async _listenInbound(conn: InboundConnection): Promise<void> {
382
+ const url = new URL("v2/listen-inbound", this._client.getWebsocketBase());
323
383
  if ("agent_number" in conn) {
324
- agent_number = conn.agent_number;
384
+ url.searchParams.set("phone_number", conn.agent_number);
325
385
  } else {
326
- webrtc_code = conn.webrtc_code;
386
+ url.searchParams.set("webrtc_code", conn.webrtc_code);
327
387
  }
328
388
 
329
- this._logger.info(`Listening for calls to ${agent_number ?? webrtc_code}`);
389
+ await using socket = await new GuavaSocket<
390
+ ListenInbound.ClientMessage,
391
+ ListenInbound.ServerMessage
392
+ >(
393
+ "listen-inbound",
394
+ url.toString(),
395
+ this._client,
396
+ (msg) => msg as unknown as Record<string, unknown>,
397
+ ListenInbound.decodeServerMessage,
398
+ ).connect();
399
+
400
+ try {
401
+ for await (const msg of socket) {
402
+ switch (msg.message_type) {
403
+ case "listen-started":
404
+ if ("agent_number" in conn) {
405
+ this._logger.info(
406
+ "Listening on %s (%d other listeners).",
407
+ conn.agent_number,
408
+ msg.other_listeners,
409
+ );
410
+ } else {
411
+ this._logger.info(
412
+ "Listening on WebRTC code %s (%d other listeners).",
413
+ conn.webrtc_code,
414
+ msg.other_listeners,
415
+ );
416
+ const debugUrl = new URL(
417
+ `debug-webrtc?webrtc_code=${conn.webrtc_code}`,
418
+ this._client.getHttpBase(),
419
+ );
420
+ this._logger.info("Call your agent at: %s", debugUrl);
421
+ }
422
+ break;
423
+ case "incoming-call":
424
+ socket.send({ message_type: "claim-call", call_id: msg.call_id });
425
+ break;
426
+ case "assign-call": {
427
+ const { call_id, call_info } = msg;
428
+ this._logger.info("Received call (session ID: %s)", call_id);
429
+ this._handleAssignedCall(call_id, call_info, socket).catch((err) => {
430
+ this._logger.error("Error handling assigned call %s: %s", call_id, err);
431
+ });
432
+ break;
433
+ }
434
+ }
435
+ }
436
+ } catch (e) {
437
+ if (!(e instanceof GuavaSocketClosedError)) throw e;
438
+ }
439
+ }
330
440
 
331
- if (webrtc_code) {
332
- const debugurl = new URL(
333
- `debug-webrtc?webrtc_code=${webrtc_code}`,
334
- this._client.getHttpBase(),
335
- );
336
- this._logger.info(`Call your agent at: ${debugurl}`);
441
+ private async _handleAssignedCall(
442
+ callId: string,
443
+ callInfo: CallInfo,
444
+ socket: GuavaSocket<ListenInbound.ClientMessage, ListenInbound.ServerMessage>,
445
+ initialVariables: Record<string, any> = {},
446
+ ): Promise<void> {
447
+ const action = await this._onCallReceived(callInfo);
448
+ if (action.action === "decline") {
449
+ this._logger.info("Declining call %s.", callId);
450
+ socket.send({ message_type: "decline-call", call_id: callId });
451
+ } else {
452
+ this._logger.info("Accepting call %s.", callId);
453
+ socket.send({ message_type: "answer-call", call_id: callId });
454
+ await this._attachToCall(callId, initialVariables);
337
455
  }
456
+ }
338
457
 
339
- ws.addEventListener("open", (_ev) => {
340
- ws.send(
341
- stringifyZod(ListenInboundCommand, {
342
- command_type: "listen-inbound",
343
- agent_number: agent_number,
344
- webrtc_code: webrtc_code,
345
- }),
346
- );
458
+ /**
459
+ * @description use the Guava API to call out to a number
460
+ */
461
+ async callPhone(
462
+ fromNumber: string | undefined,
463
+ toNumber: string,
464
+ variables: Record<string, any> = {},
465
+ ): Promise<void> {
466
+ const url = new URL("v2/create-outbound", this._client.getHttpBase());
467
+ if (fromNumber) url.searchParams.set("from_number", fromNumber);
468
+ url.searchParams.set("to_number", toNumber);
469
+
470
+ const response = await fetchOrThrow(url, {
471
+ method: "POST",
472
+ headers: await this._client.headers(),
347
473
  });
474
+ const { call_id } = (await response.json()) as { call_id: string };
475
+
476
+ this._logger.info("Outbound call created with session ID: %s", call_id);
477
+ await this._attachToCall(call_id, variables);
478
+ }
348
479
 
349
- ws.addEventListener("close", (_ev) => {
350
- ws.removeAllListeners();
480
+ /**
481
+ * Run the agent against a live test session.
482
+ *
483
+ * Connects to the Guava test endpoint, starts the agent's call handling, and
484
+ * calls `callback` with a TestSession for driving the conversation
485
+ * programmatically. Returns the completed TestSession after the callback and
486
+ * call handler both finish.
487
+ *
488
+ * @example
489
+ * const session = await agent.test(async (session) => {
490
+ * await session.waitForTurn();
491
+ * session.say("Hi, I'd like to make a purchase.");
492
+ * await session.waitForEnd();
493
+ * });
494
+ * assert(session.executedActions.includes("sales"));
495
+ */
496
+ async test(
497
+ callback: (session: TestSession) => Promise<void>,
498
+ variables: Record<string, any> = {},
499
+ ): Promise<TestSession> {
500
+ const url = new URL("v1/test-agent", this._client.getWebsocketBase());
501
+ const headers = await this._client.headers();
502
+ const ws = new WebSocket(url.toString(), { headers });
503
+
504
+ await new Promise<void>((resolve, reject) => {
505
+ ws.once("open", resolve);
506
+ ws.once("error", reject);
351
507
  });
352
508
 
353
- ws.addEventListener("message", async (ev) => {
354
- this._logger.debug("Received message: %s", ev.data.toString("utf8"));
355
- const tunnel_event = InboundTunnelEvent.parse(JSON.parse(ev.data.toString("utf8")));
356
- if (!(tunnel_event.call_id in calls)) {
357
- this._logger.info(
358
- `Received tunnel event for new call ID: ${tunnel_event.call_id}. Creating call object.`,
359
- );
509
+ const rawFirst = await new Promise<string>((resolve, reject) => {
510
+ ws.once("message", (data) => resolve(data.toString()));
511
+ ws.once("error", reject);
512
+ });
360
513
 
361
- const call = await this._startCall();
362
- await call.setDrain(async (commands) => {
363
- for (const command of commands.splice(0)) {
364
- this._logger.debug(
365
- `Sending command: ${JSON.stringify(command)} for call ID: ${tunnel_event.call_id}`,
366
- );
367
- ws.send(
368
- stringifyZod(InboundTunnelCommand, {
369
- call_id: tunnel_event.call_id,
370
- command,
371
- }),
372
- );
373
- }
374
- });
375
- calls[tunnel_event.call_id] = call;
376
- }
514
+ const sessionStarted = SessionStarted.parse(JSON.parse(rawFirst));
515
+ const testSession = new TestSession(ws, this._client);
377
516
 
378
- this._dispatchEvent(calls[tunnel_event.call_id], tunnel_event.event);
517
+ const attachPromise = this._attachToCall(
518
+ sessionStarted.session_id,
519
+ variables,
520
+ testSession,
521
+ ).catch((err: unknown) => {
522
+ this._logger.error("Error in _attachToCall during test: %s", err);
379
523
  });
380
524
 
381
- return new InboundListener(ws);
525
+ try {
526
+ await callback(testSession);
527
+ } finally {
528
+ ws.close();
529
+ await attachPromise;
530
+ }
531
+
532
+ return testSession;
382
533
  }
383
534
 
384
535
  /**
385
- * @description use the Guava API to call out to a number
536
+ * Run an automated test conversation where an LLM plays the caller.
537
+ *
538
+ * Connects to the Guava test endpoint, starts the agent, then drives a
539
+ * back-and-forth conversation by repeatedly asking the Guava LLM to decide
540
+ * whether to speak or hang up based on the transcript so far.
541
+ *
542
+ * @param roleplayPrompt - Instructions for the simulated caller, e.g.
543
+ * `"You are a frustrated customer trying to cancel your subscription."`
544
+ * @param variables - Optional initial call variables.
545
+ * @returns The completed TestSession. Call `session.evaluate()` to assert
546
+ * pass/fail criteria, or `session.getTranscript()` to inspect the conversation.
547
+ *
548
+ * @example
549
+ * const session = await agent.testRoleplay(
550
+ * "You are a caller trying to buy a new table.",
551
+ * );
552
+ * assert(session.executedActions.includes("sales"));
386
553
  */
387
- async callPhone(
388
- fromNumber: string | undefined,
389
- toNumber: string,
554
+ async testRoleplay(
555
+ roleplayPrompt: string,
390
556
  variables: Record<string, any> = {},
391
- ) {
392
- const url = new URL("v1/create-outbound", this._client.getWebsocketBase());
393
- const ws = new WebSocket(url, {
394
- headers: this._client.headers(),
395
- });
557
+ ): Promise<TestSession> {
558
+ const roleplaySchema = {
559
+ type: "object",
560
+ properties: {
561
+ action: { type: "string", enum: ["speak", "hangup"] },
562
+ utterance: { type: "string" },
563
+ },
564
+ required: ["action"],
565
+ additionalProperties: false,
566
+ };
567
+
568
+ return this.test(async (session) => {
569
+ let snapshotLen = 0;
570
+ try {
571
+ while (true) {
572
+ await session.waitForTurn();
573
+
574
+ for (const event of session._events.slice(snapshotLen)) {
575
+ if (event.message_type === "bot-tts") {
576
+ this._logger.info("(Roleplay Session) [agent]: %s", event.transcript);
577
+ }
578
+ }
579
+ snapshotLen = session._events.length;
396
580
 
397
- const call = await this._startCall(variables);
398
- let socketInitialized = false;
399
-
400
- ws.addEventListener("open", async (_ev) => {
401
- ws.send(
402
- stringifyZod(StartOutboundCallCommand, {
403
- command_type: "start-outbound",
404
- to_number: toNumber,
405
- from_number: fromNumber,
406
- }),
407
- );
408
-
409
- // set the callController drain function to send all commands
410
- // through the now open websocket
411
- call.setDrain(async (commands) => {
412
- for (const command of commands.splice(0)) {
413
- this._logger.debug(`Sending command ${JSON.stringify(command)}`);
414
- ws.send(JSON.stringify(command));
415
- }
416
- });
417
- });
581
+ const transcript = session.getTranscript();
582
+ const prompt = `${roleplayPrompt}
583
+
584
+ You are roleplaying as a caller on a phone call. Decide what to do next based on the conversation so far.
585
+
586
+ Conversation:
587
+ ${transcript || "(The agent has not spoken yet)"}
418
588
 
419
- ws.addEventListener("message", (ev) => {
420
- if (socketInitialized) {
421
- const session_started = z
422
- .union([SessionStartedEvent, ErrorEvent])
423
- .parse(JSON.parse(ev.data.toString("utf8")));
589
+ Choose "speak" and provide your next utterance, or choose "hangup" if the conversation has naturally concluded.`;
424
590
 
425
- if (session_started.event_type === "error") {
426
- throw new Error(`Outbound call failed: ${session_started.content}`);
591
+ const raw = await _generate(this._client, prompt, roleplaySchema);
592
+ const action = JSON.parse(raw) as { action: string; utterance?: string };
593
+
594
+ if (action.action === "hangup") {
595
+ this._logger.info("(Roleplay Session) [caller hangs up]");
596
+ break;
597
+ }
598
+
599
+ if (action.action === "speak" && action.utterance) {
600
+ this._logger.info("(Roleplay Session) [caller]: %s", action.utterance);
601
+ session.say(action.utterance);
602
+ }
603
+ }
604
+ } catch (err) {
605
+ if ((err as Error).message === "Test session WebSocket closed") {
606
+ this._logger.info("Roleplay session ended by server.");
427
607
  } else {
428
- this._logger.info(`Started session with ID: ${session_started.session_id}`);
429
- socketInitialized = true;
608
+ throw err;
430
609
  }
431
- } else {
432
- // handle the received event
433
- const event = decodeEvent(ev.data);
434
- if (event) {
435
- this._dispatchEvent(call, event);
610
+ }
611
+
612
+ for (const event of session._events.slice(snapshotLen)) {
613
+ if (event.message_type === "bot-tts") {
614
+ this._logger.info("(Roleplay Session) [agent]: %s", event.transcript);
436
615
  }
437
616
  }
438
- });
617
+ }, variables);
618
+ }
439
619
 
440
- ws.addEventListener("close", (_ev) => {
441
- // we are closing the socket, so don't trigger any other listeners
442
- ws.removeAllListeners();
620
+ /**
621
+ * Start an interactive terminal chat session with the agent.
622
+ *
623
+ * Opens a TUI with a scrolling conversation panel and an input line.
624
+ * Agent responses appear in real time. Press Ctrl+C or let the agent
625
+ * end the session to exit.
626
+ *
627
+ * @param variables - Optional initial call variables.
628
+ *
629
+ * @example
630
+ * await agent.chat();
631
+ * // or: await agent.chat({ patient_name: "Benjamin Buttons" });
632
+ */
633
+ async chat(variables: Record<string, any> = {}): Promise<void> {
634
+ await this.test(async (session) => {
635
+ await runChat(session);
636
+ }, variables);
637
+ }
638
+
639
+ /**
640
+ * Return a shallow copy of this agent with independently overridable
641
+ * callbacks.
642
+ *
643
+ * Use in tests to register alternative handlers on the clone without
644
+ * affecting the original agent.
645
+ */
646
+ patch(): Agent {
647
+ const cloned = new Agent({
648
+ name: this._name,
649
+ organization: this._organization,
650
+ purpose: this._purpose,
443
651
  });
652
+ cloned._onCallReceived = this._onCallReceived;
653
+ cloned._onCallStart = this._onCallStart;
654
+ cloned._onCallerSpeech = this._onCallerSpeech;
655
+ cloned._onAgentSpeech = this._onAgentSpeech;
656
+ cloned._onQuestion = this._onQuestion;
657
+ cloned._onTaskCompleteGeneric = this._onTaskCompleteGeneric;
658
+ cloned._onTaskCompleteHandlers = { ...this._onTaskCompleteHandlers };
659
+ cloned._searchQueryHandlers = { ...this._searchQueryHandlers };
660
+ cloned._onActionRequested = this._onActionRequested;
661
+ cloned._onActionGeneric = this._onActionGeneric;
662
+ cloned._onActionHandlers = { ...this._onActionHandlers };
663
+ cloned._onSessionEnd = this._onSessionEnd;
664
+ return cloned;
444
665
  }
445
666
 
446
667
  /* ===== Aliases to be removed at some point. ===== */
447
668
  /** @deprecated Use {@link listenPhone} instead. */
448
- inboundPhone(phoneNumber: string): InboundListener {
669
+ async inboundPhone(phoneNumber: string): Promise<void> {
449
670
  return this.listenPhone(phoneNumber);
450
671
  }
451
672
 
@@ -458,14 +679,3 @@ export class Agent {
458
679
  return this.callPhone(fromNumber, toNumber, variables);
459
680
  }
460
681
  }
461
-
462
- class InboundListener {
463
- private ws: WebSocket;
464
- constructor(ws: WebSocket) {
465
- this.ws = ws;
466
- }
467
-
468
- close() {
469
- this.ws.close();
470
- }
471
- }