mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-10-26 05:14:31 +00:00
* 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>
123 lines
3.6 KiB
JavaScript
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]);
|
|
});
|