Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion typescript/src/websocket/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,17 +527,29 @@ export class Session extends EventEmitter {
switch (type) {
case 'verb:hook':
case 'session:redirect':
case 'dial:confirm':
case 'dial:confirm': {
this.msgid = msgid;
// Notification hooks (conference/status events — they carry `data.event`)
// are observational: emit them to the app and auto-ack, so an app that
// merely watches statusHook events doesn't have to reply() to each one
// (which otherwise times out far-end). Action hooks (no `data.event`)
// still await the app's reply, which may carry verbs.
const isNotification =
type === 'verb:hook' &&
!!data && typeof data === 'object' &&
typeof (data as { event?: unknown }).event === 'string';
if (hook && this.listenerCount(hook) > 0) {
this.emit(hook, data);
if (isNotification) this.reply();
} else if (type === 'verb:hook' && this.listenerCount('verb:hook') > 0) {
this.emit('verb:hook', hook, data);
if (isNotification) this.reply();
} else {
// Auto-reply with empty verb array if no listener
this.reply();
}
break;
}

case 'llm:event':
case 'llm:tool-call':
Expand Down
63 changes: 63 additions & 0 deletions typescript/test/session-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { EventEmitter } from 'events';
import { Session } from '../src/websocket/session.js';

// Minimal WebSocket stand-in: an EventEmitter (so the Session can wire its
// 'message' handler) with readyState OPEN and a send() that captures frames.
function makeSession() {
const ws = new EventEmitter() as EventEmitter & {
readyState: number;
send: (data: string) => void;
};
ws.readyState = 1; // OPEN
const sent: Array<Record<string, unknown>> = [];
ws.send = (data: string) => sent.push(JSON.parse(data));

const logger = { debug() {}, info() {}, warn() {}, error() {} };
const msg = { type: 'session:new', msgid: 'init-1', call_sid: 'cs1', data: {} };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = new Session({ ws, msg, logger, validate: false } as any);
return { session, ws, sent };
}

const acksFor = (sent: Array<Record<string, unknown>>, msgid: string) =>
sent.filter((m) => m.type === 'ack' && m.msgid === msgid);

describe('Session verb:hook ack behavior', () => {
it('auto-acks a notification hook (data.event) after emitting it to the listener', () => {
const { session, ws, sent } = makeSession();
const seen: Array<{ event?: string }> = [];
session.on('/room-status', (data) => seen.push(data as { event?: string }));

ws.emit('message', JSON.stringify({
type: 'verb:hook', msgid: 'evt-1', hook: '/room-status',
data: { event: 'say-start', sayId: 's1', id: 'welcome' },
}));

expect(seen).toHaveLength(1);
expect(seen[0].event).toBe('say-start');
const acks = acksFor(sent, 'evt-1');
expect(acks).toHaveLength(1);
expect(acks[0].data).toEqual([]); // empty ack, no verbs
});

it('does NOT auto-ack an action hook (no data.event) — the app owns the reply', () => {
const { session, ws, sent } = makeSession();
session.on('/dial-done', () => { /* a real app would session.reply() here */ });

ws.emit('message', JSON.stringify({
type: 'verb:hook', msgid: 'evt-2', hook: '/dial-done',
data: { dial_call_status: 'completed' },
}));

expect(acksFor(sent, 'evt-2')).toHaveLength(0);
});

it('still auto-acks a hook with no listener (unchanged behavior)', () => {
const { ws, sent } = makeSession();
ws.emit('message', JSON.stringify({
type: 'verb:hook', msgid: 'evt-3', hook: '/no-listener', data: {},
}));
expect(acksFor(sent, 'evt-3')).toHaveLength(1);
});
});