Skip to content

Commit fdaabc0

Browse files
committed
feat(call): surface tool calls, handoffs, and status events in TTY
Previously `handleControlMessage` in `src/call.ts` handled only `transcript`, `speech-update`, and `call-ended`, silently dropping every other WebSocket control message via `default: break;`. Tool invocations, squad handoffs, tool errors, and call-status transitions were all invisible, forcing developers to switch to the Vapi dashboard to debug KB routing, handoff decisions, or tool failures during a `npm run call` session. Add targeted formatters and cases for the high-signal event types while keeping the default output clean: - `tool-calls` → `🔧 Tool call: <name>(<args-preview>)` for regular tools, or `🔀 Handoff → <Target Name>` when the function name matches the `handoff_to_<Target_Name>` convention used by squad handoff tools. Underscores in the captured target are converted back to spaces so `handoff_to_FAQ_Specialist` renders as `🔀 Handoff → FAQ Specialist`. Handles both the flat `toolCallList[]` shape and the nested `toolWithToolCallList[].toolCall` shape, and tolerates both top-level `name`/`arguments` and the older `function.name`/`function.arguments` nesting. - `tool-call-result` → `✅ Tool result: <name> → <preview>` for successes and `❌ Tool failed: <name> → <preview>` for errors, truncating long payloads to 200 characters with a `[truncated, N chars]` suffix so KB lookups don't overwhelm the terminal. - `status-update` → `📞 Status: <state>[+reason]` for the useful lifecycle transitions (`in-progress`, `forwarding`, `ended`). `queued`, `ringing`, and `scheduled` are dropped because they never apply to a WebSocket-transport developer call. - `hang` → `⚠️ Hang warning` so impending silence-timeout termination is visible while still-audible content is still on screen. - `transfer-update` → `🔀 Transfer → <assistantName|number|sipUri>` so phone / SIP / cross-assistant transfers register in the log. High-frequency events (`conversation-update`, `model-output`, `function-call`, `user-interrupted`) are still silently dropped because they fire several times per second during a normal call and would drown out real transcript content. Developers who want to enumerate unknown events (for future workstream additions) can set `VAPI_CALL_DEBUG=1` in the environment to emit a compact `🔍 [debug] <type>: <json-preview>` line for each otherwise-ignored message. All formatters are defensive: every JSON preview goes through a try/catch that falls back to `<unserializable>` on circular refs or non-serializable values, arguments supplied as JSON strings are re-parsed so the preview shows structured content instead of escape-riddled single-line strings, and the `tool-calls` / `tool-call-result` cases swallow formatter exceptions so an unfamiliar payload shape never crashes the CLI. Each event flows through a new `printEvent` helper that flushes any pending coalesced final buffer and clears the partial-transcript overwrite region before writing, keeping the TTY output coherent when tool calls arrive mid-utterance. No runtime behavior change for the voice agent itself — only surfacing WebSocket events that were already being sent but dropped in the CLI.
1 parent ff61aac commit fdaabc0

1 file changed

Lines changed: 223 additions & 1 deletion

File tree

src/call.ts

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,64 @@ interface CallEndedMessage {
401401
reason?: string;
402402
}
403403

404+
// Individual tool call entries in a `tool-calls` event. Vapi can deliver
405+
// the function name and arguments either at the top level (recent shape)
406+
// or nested under a `function` object (older / server-url shape). We
407+
// accept both and fall back to `<unknown>` so an unfamiliar payload
408+
// never crashes the CLI.
409+
interface ToolCallItem {
410+
id?: string;
411+
name?: string;
412+
arguments?: unknown;
413+
function?: {
414+
name?: string;
415+
arguments?: unknown;
416+
};
417+
}
418+
419+
interface ToolCallsMessage {
420+
type: "tool-calls";
421+
toolCallList?: ToolCallItem[];
422+
toolWithToolCallList?: Array<{ toolCall?: ToolCallItem }>;
423+
}
424+
425+
interface ToolCallResultMessage {
426+
type: "tool-call-result";
427+
toolCallId?: string;
428+
name?: string;
429+
result?: unknown;
430+
error?: unknown;
431+
}
432+
433+
interface StatusUpdateMessage {
434+
type: "status-update";
435+
status?: string;
436+
endedReason?: string;
437+
}
438+
439+
interface HangMessage {
440+
type: "hang";
441+
}
442+
443+
interface TransferUpdateMessage {
444+
type: "transfer-update";
445+
destination?: {
446+
type?: string;
447+
assistantName?: string;
448+
number?: string;
449+
sipUri?: string;
450+
};
451+
}
452+
404453
type ControlMessage =
405454
| TranscriptMessage
406455
| SpeechUpdateMessage
407456
| CallEndedMessage
457+
| ToolCallsMessage
458+
| ToolCallResultMessage
459+
| StatusUpdateMessage
460+
| HangMessage
461+
| TransferUpdateMessage
408462
| { type: string };
409463

410464
// Cartesia Sonic and other chunked TTS providers stream each final
@@ -451,6 +505,87 @@ function flushFinalBuffer(state: CallDisplayState): void {
451505
console.log(`${prefix}: ${merged}`);
452506
}
453507

508+
// Print an out-of-band event (tool call, handoff, status change, etc.)
509+
// while respecting the in-flight transcript buffer and any partial line
510+
// that's currently being overwritten in the TTY. Without this wrapper,
511+
// event lines would either corrupt the partial overwrite region or
512+
// interleave awkwardly with a pending coalesced final.
513+
function printEvent(state: CallDisplayState, line: string): void {
514+
flushFinalBuffer(state);
515+
clearWrittenLine(process.stdout, state.lastTranscript);
516+
state.lastTranscript = "";
517+
console.log(line);
518+
}
519+
520+
function truncate(text: string, maxLen: number): string {
521+
if (text.length <= maxLen) return text;
522+
return `${text.slice(0, maxLen)}... [truncated, ${text.length} chars]`;
523+
}
524+
525+
function previewJson(value: unknown, maxLen: number): string {
526+
try {
527+
// Tool call arguments occasionally arrive as a JSON-encoded string
528+
// instead of an object. Re-parse so the preview shows structured
529+
// content rather than an escape-riddled single-line string.
530+
let v: unknown = value;
531+
if (typeof v === "string") {
532+
const asString: string = v;
533+
try {
534+
v = JSON.parse(asString);
535+
} catch {
536+
return truncate(asString, maxLen);
537+
}
538+
}
539+
const json = JSON.stringify(v);
540+
if (typeof json !== "string") return "<unserializable>";
541+
return truncate(json, maxLen);
542+
} catch {
543+
return "<unserializable>";
544+
}
545+
}
546+
547+
// Squad handoffs are delivered as `tool-calls` events where the function
548+
// name follows the `handoff_to_<Target_Name>` convention (underscores
549+
// replacing spaces in the target assistant's display name). Extract the
550+
// target name when this naming pattern applies so we can render a
551+
// distinct `🔀 Handoff → Target Name` line instead of a generic tool
552+
// call. Returns null when the name doesn't match the handoff pattern.
553+
function handoffTargetFromName(name: string): string | null {
554+
const match = /^handoff_to_(.+)$/.exec(name);
555+
const captured = match?.[1];
556+
if (!captured) return null;
557+
return captured.replace(/_/g, " ").trim();
558+
}
559+
560+
function formatToolCall(call: ToolCallItem): string {
561+
const name = call.name ?? call.function?.name ?? "<unknown>";
562+
const args = call.arguments ?? call.function?.arguments;
563+
const target = handoffTargetFromName(name);
564+
if (target) {
565+
return `🔀 Handoff → ${target}`;
566+
}
567+
if (args === undefined || args === null) {
568+
return `🔧 Tool call: ${name}()`;
569+
}
570+
return `🔧 Tool call: ${name}(${previewJson(args, 160)})`;
571+
}
572+
573+
function formatTransferDestination(
574+
dest: TransferUpdateMessage["destination"],
575+
): string {
576+
if (!dest) return "";
577+
if (dest.type === "assistant" && dest.assistantName) {
578+
return ` → ${dest.assistantName}`;
579+
}
580+
if (dest.type === "number" && dest.number) {
581+
return ` → ${dest.number}`;
582+
}
583+
if (dest.type === "sip" && dest.sipUri) {
584+
return ` → ${dest.sipUri}`;
585+
}
586+
return dest.type ? ` (${dest.type})` : "";
587+
}
588+
454589
async function connectWebSocket(
455590
websocketUrl: string,
456591
config: CallConfig,
@@ -704,8 +839,95 @@ function handleControlMessage(
704839
console.log(`📞 Call ended: ${label}`);
705840
break;
706841
}
842+
case "tool-calls": {
843+
const tm = message as ToolCallsMessage;
844+
// Some payloads deliver tool calls directly in `toolCallList`;
845+
// others nest them in `toolWithToolCallList[].toolCall` (the
846+
// "tool + toolCall" pair shape Vapi uses when it also wants to
847+
// include the registered tool definition). Normalize both.
848+
const calls: ToolCallItem[] = [
849+
...(tm.toolCallList ?? []),
850+
...(tm.toolWithToolCallList
851+
?.map((e) => e.toolCall)
852+
.filter((c): c is ToolCallItem => Boolean(c)) ?? []),
853+
];
854+
for (const call of calls) {
855+
try {
856+
printEvent(state, formatToolCall(call));
857+
} catch {
858+
// Never let an unexpected tool call shape crash the CLI —
859+
// just fall through and drop the formatter output.
860+
}
861+
}
862+
break;
863+
}
864+
case "tool-call-result": {
865+
const tm = message as ToolCallResultMessage;
866+
const name = tm.name ?? "<tool>";
867+
try {
868+
if (tm.error !== undefined && tm.error !== null && tm.error !== "") {
869+
printEvent(
870+
state,
871+
`❌ Tool failed: ${name}${previewJson(tm.error, 200)}`,
872+
);
873+
} else if (tm.result !== undefined) {
874+
printEvent(
875+
state,
876+
`✅ Tool result: ${name}${previewJson(tm.result, 200)}`,
877+
);
878+
}
879+
} catch {
880+
// Preserve CLI stability on unexpected payload shapes.
881+
}
882+
break;
883+
}
884+
case "status-update": {
885+
const sm = message as StatusUpdateMessage;
886+
if (!sm.status) break;
887+
// Drop `queued`, `ringing`, `scheduled` — they're irrelevant for a
888+
// WebSocket-transport developer call (there's no phone leg) and
889+
// just add noise. Surface the useful lifecycle transitions.
890+
if (
891+
sm.status === "in-progress" ||
892+
sm.status === "forwarding" ||
893+
sm.status === "ended"
894+
) {
895+
const suffix =
896+
sm.status === "ended" && sm.endedReason
897+
? ` (reason: ${sm.endedReason})`
898+
: "";
899+
printEvent(state, `📞 Status: ${sm.status}${suffix}`);
900+
}
901+
break;
902+
}
903+
case "hang": {
904+
// `hang` is a heads-up that the call is about to terminate (e.g.
905+
// silence timeout is imminent). Useful for debugging hang-reason
906+
// issues; payload fields vary so we just surface the signal.
907+
printEvent(state, `⚠️ Hang warning`);
908+
break;
909+
}
910+
case "transfer-update": {
911+
const tm = message as TransferUpdateMessage;
912+
printEvent(state, `🔀 Transfer${formatTransferDestination(tm.destination)}`);
913+
break;
914+
}
707915
default:
708-
// Ignore other message types
916+
// Silently ignore other message types (conversation-update,
917+
// model-output, function-call, user-interrupted, etc.). These
918+
// fire frequently and would drown out real transcript content.
919+
// Set `VAPI_CALL_DEBUG=1` in the environment to log unknown
920+
// message types for discovery work.
921+
if (process.env.VAPI_CALL_DEBUG === "1") {
922+
try {
923+
const m = message as Record<string, unknown>;
924+
const typeStr = typeof m.type === "string" ? m.type : "<untyped>";
925+
const preview = previewJson(m, 200);
926+
printEvent(state, `🔍 [debug] ${typeStr}: ${preview}`);
927+
} catch {
928+
// Debug output is best-effort; never crash from it.
929+
}
930+
}
709931
break;
710932
}
711933
}

0 commit comments

Comments
 (0)