From f29aa0faef38ceae74b9ad05f1e90e5059ec05d5 Mon Sep 17 00:00:00 2001 From: Rehuz Date: Sat, 5 Sep 2026 16:02:26 +0530 Subject: [PATCH] fix(tts): send stream_resumed to the same hook path as the other stream events (#1585) _onTtsStreamingResume asked for "streaming-event" while stream_open, stream_paused, stream_closed and user_interruption all ask for "/streaming-event". That one missing slash decides whether the event reaches an HTTP application at all. HttpRequestor joins a hook path to the app's baseUrl only when it is relative, and relative means starting with a slash: _isRelativeUrl(u) { return typeof u === 'string' && u.startsWith('/'); } const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url; "streaming-event" is neither relative nor absolute, so it is passed through unjoined and never lands on the application. The call site catches and logs the failure rather than raising it, so an application simply sees stream_paused with no matching stream_resumed and nothing anywhere says why. Present since the verb was introduced in #994. Found by reading the file, so there is no issue to close. Tests: the two new cases fail against the unpatched line and pass with it. Co-authored-by: Claude Opus 5 --- lib/session/call-session.js | 2 +- test/unit/tts-streaming-events.test.js | 64 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 test/unit/tts-streaming-events.test.js diff --git a/lib/session/call-session.js b/lib/session/call-session.js index bca9c5b0..752f9d42 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -3285,7 +3285,7 @@ Duration=${duration} ` } _onTtsStreamingResume() { - this.requestor?.request('tts:streaming-event', 'streaming-event', {event_type: 'stream_resumed'}) + this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_resumed'}) .catch((err) => this.logger.info({err}, 'CallSession:_onTtsStreamingResume - Error sending')); } diff --git a/test/unit/tts-streaming-events.test.js b/test/unit/tts-streaming-events.test.js new file mode 100644 index 00000000..5de978f6 --- /dev/null +++ b/test/unit/tts-streaming-events.test.js @@ -0,0 +1,64 @@ +const test = require('node:test'); +const assert = require('node:assert'); + +/* call-session decrypts credentials at require time, so it needs a secret present */ +process.env.ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'foobar'; +process.env.JAMBONES_LOGLEVEL = process.env.JAMBONES_LOGLEVEL || 'error'; + +const CallSession = require('../../lib/session/call-session'); + +/* Only the requestor and logger are touched by these handlers, so the rest of a + CallSession is deliberately left unbuilt. */ +const makeSession = () => { + const sent = []; + const session = Object.create(CallSession.prototype); + + Object.assign(session, { + application: { + requestor: { + request: async (type, hook, payload) => { + sent.push({type, hook, payload}); + } + } + }, + logger: {info: () => {}, debug: () => {}, error: () => {}} + }); + + return {session, sent}; +}; + +test('the stream_resumed event is sent to the same hook path as every other one', async () => { + /* A hook path only gets joined to the application's baseUrl when it starts with + a slash (HttpRequestor: `_isRelativeUrl(url) ? baseUrl + url : url`). Sending + "streaming-event" instead of "/streaming-event" is neither relative nor + absolute, so it never reaches an HTTP application at all — and the failure is + swallowed by the .catch on the call site, so nothing surfaces. */ + const {session, sent} = makeSession(); + + session._onTtsStreamingResume(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].hook, '/streaming-event', + 'stream_resumed must use an absolute-from-base hook path'); + assert.strictEqual(sent[0].payload.event_type, 'stream_resumed'); +}); + +test('pause and resume agree on the hook path', async () => { + /* These two are a pair; if they ever diverge again, an application would get + one half of the pause/resume cycle and silently lose the other. */ + const {session, sent} = makeSession(); + + session._onTtsStreamingPause(); + session._onTtsStreamingResume(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepStrictEqual( + sent.map(({hook}) => hook), + ['/streaming-event', '/streaming-event'] + ); + assert.deepStrictEqual( + sent.map(({payload}) => payload.event_type), + ['stream_paused', 'stream_resumed'] + ); +});