Files
jambonz-feature-server/test/unit/dtmf-input.test.js
T
Hoan Luu HuuandClaude Opus 5.5 287883c148 feat(elevenlabs): forward DTMF to the agent and support llm:update (#1592)
* feat(elevenlabs): forward DTMF to the agent and support llm:update

Adds opt-in llmOptions.dtmf {enable, interDigitTimeout, finishOnKey}.
Digits are buffered until finishOnKey, the inter-digit timeout or 50
digits, then sent as a user_message with source_medium "dtmf". Each
key press also sends user_activity so the agent turn timeout does not
fire while the caller is typing.

llm:update now forwards user_message, user_activity and
contextual_update client events instead of ignoring them.

Client events escape "|" so payloads containing it are no longer
split by the api argument delimiter (this also fixes tool results).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(elevenlabs): send DTMF per digit like ultravox, drop llmOptions.dtmf

Match the ultravox adapter: DTMF is always forwarded, with no llmOptions
setting. Each digit is sent as soon as it arrives, as a user_message with
source_medium "dtmf". Digit buffering, the inter-digit timeout and
finishOnKey are left to the agent's dtmf_input_settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(elevenlabs): buffer DTMF behind llmOptions.dtmf again

Per-digit sending does not work for ElevenLabs. Over the agent
WebSocket each user_message becomes its own turn: "1234#" produced 5
turns, and the agent answered "Are you still there?". The same digits
sent as a single message produce one dtmf turn, "1234".

- restores the opt-in llmOptions.dtmf {enable, interDigitTimeout, finishOnKey}
- digits are buffered and sent as one user_message, source_medium "dtmf"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: shared DtmfInput util with gather dtmf semantics

Any llm or agent vendor can turn caller DTMF into whole entries without
copying gather or touching it.

- lib/utils/dtmf-input.js uses gather options: finishOnKey, numDigits,
  minDigits, maxDigits, interDigitTimeout, timeout (15s default, 0 = off)
- no invented defaults: no implicit "#", no 2s timeout, no 50-digit cap
- minDigits defaults to 1; in gather an unset minDigits never arms the
  inter-digit timer
- stop() removes only its own dtmf listener
- elevenlabs uses it behind llmOptions.dtmf.enable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dtmf): review fixes for DtmfInput and elevenlabs

- inter-digit timer replaces the overall timeout while typing, as in gather
- warn when no option can ever complete an entry
- no DtmfInput when the session connects after kill (listener leak)
- llm:update rejects user_message/contextual_update without string text,
  since an ElevenLabs error event ends the conversation
- unit tests use mock timers instead of wall-clock waits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:23:11 -04:00

123 lines
3.6 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert');
const Emitter = require('events');
const DtmfInput = require('../../lib/utils/dtmf-input');
const noop = () => {};
const make = (t, opts) => {
t.mock.timers.enable({apis: ['setTimeout']});
const keys = [];
const entries = [];
const warnings = [];
const logger = {error: noop, info: noop, debug: noop, warn: (...args) => warnings.push(args)};
const ep = new Emitter();
const input = new DtmfInput(logger, opts, {
onKey: (d) => keys.push(d),
onEntry: (digits) => entries.push(digits)
});
input.start(ep);
const press = (digits) => [...digits].forEach((dtmf) => ep.emit('dtmf', {dtmf}));
const tick = (secs) => t.mock.timers.tick(secs * 1000);
return {ep, input, keys, entries, warnings, press, tick};
};
test('finishOnKey completes the entry and is not included', (t) => {
const {keys, entries, press} = make(t, {finishOnKey: '#'});
press('1234#');
assert.deepStrictEqual(entries, ['1234']);
assert.deepStrictEqual(keys, ['1', '2', '3', '4', '#']);
});
test('finishOnKey on an empty buffer sends nothing', (t) => {
const {entries, press} = make(t, {finishOnKey: '#'});
press('#');
assert.deepStrictEqual(entries, []);
});
test('no finishOnKey by default: # is an ordinary digit', (t) => {
const {entries, press} = make(t, {numDigits: 3});
press('1#2');
assert.deepStrictEqual(entries, ['1#2']);
});
test('numDigits completes the entry', (t) => {
const {entries, press} = make(t, {numDigits: 2});
press('1234');
assert.deepStrictEqual(entries, ['12', '34']);
});
test('maxDigits completes the entry', (t) => {
const {entries, press} = make(t, {maxDigits: 3});
press('12345');
assert.deepStrictEqual(entries, ['123']);
});
test('each key restarts the inter-digit timer', (t) => {
const {entries, press, tick} = make(t, {interDigitTimeout: 2});
press('56');
tick(1.5);
press('7');
tick(1.5);
assert.deepStrictEqual(entries, []);
tick(0.5);
assert.deepStrictEqual(entries, ['567']);
});
test('the inter-digit timer replaces the overall timeout while typing', (t) => {
const {entries, press, tick} = make(t, {interDigitTimeout: 5, timeout: 15});
for (let i = 0; i < 20; i++) {
press(String(i % 10));
tick(1);
}
assert.deepStrictEqual(entries, []);
tick(4);
assert.deepStrictEqual(entries, ['01234567890123456789']);
});
test('interDigitTimeout waits for minDigits', (t) => {
const {entries, press, tick} = make(t, {interDigitTimeout: 1, minDigits: 3, timeout: 0});
press('12');
tick(5);
assert.deepStrictEqual(entries, []);
press('3');
tick(1);
assert.deepStrictEqual(entries, ['123']);
});
test('timeout defaults to 15s and completes the entry', (t) => {
const {entries, press, tick} = make(t, {});
press('42');
tick(14.9);
assert.deepStrictEqual(entries, []);
tick(0.1);
assert.deepStrictEqual(entries, ['42']);
});
test('timeout below minDigits drops the entry', (t) => {
const {entries, press, tick} = make(t, {timeout: 1, minDigits: 3});
press('42');
tick(1);
assert.deepStrictEqual(entries, []);
});
test('warns when no option can ever complete an entry', (t) => {
assert.strictEqual(make(t, {timeout: 0}).warnings.length, 1);
});
test('no warning when one option completes an entry', (t) => {
assert.strictEqual(make(t, {timeout: 0, finishOnKey: '#'}).warnings.length, 0);
});
test('stop removes only its own listener and discards the buffer', (t) => {
const {ep, input, entries, press, tick} = make(t, {interDigitTimeout: 1});
ep.on('dtmf', noop);
press('9');
input.stop();
tick(2);
press('8');
assert.deepStrictEqual(entries, []);
assert.deepStrictEqual(ep.listeners('dtmf'), [noop]);
});