diff --git a/typescript/src/websocket/session.ts b/typescript/src/websocket/session.ts index 971fa53..8159315 100644 --- a/typescript/src/websocket/session.ts +++ b/typescript/src/websocket/session.ts @@ -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': diff --git a/typescript/test/session-hooks.test.ts b/typescript/test/session-hooks.test.ts new file mode 100644 index 0000000..5bde19f --- /dev/null +++ b/typescript/test/session-hooks.test.ts @@ -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> = []; + 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>, 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); + }); +});