Files
dualfroz affe71f68c fix(autopilot): honor allowUserBargeIn during the greeting and the idle message
The greeting and announcingIdleTimeout states accepted SPEECH_START
unconditionally, so the caller cut the first message and the idle prompt
short even with allowUserBargeIn set to false, unlike processingUserRequest
and waitingForSpeechTimeout which already guard that event.

Guarding the event alone would drop the utterance: idle only listens for
SPEECH_START, so speech produced while the assistant was talking would
never be processed. Both playing states now buffer SPEECH_RESULT without
stopping playback and hand it to waitingForSpeechTimeout when the message
finishes, and idle accepts a SPEECH_RESULT that lands right after playback
ended.

Closes #891
2026-09-11 23:06:26 +02:00

376 lines
13 KiB
TypeScript

/* eslint-disable no-invalid-this */
/*
* Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
* http://github.com/fonoster/fonoster
*
* This file is part of Fonoster
*
* Licensed under the MIT License (the "License");
* you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* https://opensource.org/licenses/MIT
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as chai from "chai";
import { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import { createSandbox } from "sinon";
import sinonChai from "sinon-chai";
import { createActor } from "xstate";
import { LanguageModel } from "../src/models";
import { Voice } from "../src/voice";
chai.use(chaiAsPromised);
chai.use(sinonChai);
const sandbox = createSandbox();
const waitFor = async (time: number) => new Promise((r) => setTimeout(r, time));
const FIRST_MESSAGE = "Welcome message";
const GOODBYE_MESSAGE = "Goodbye message";
const SYSTEM_ERROR_MESSAGE = "System error message";
const IDLE_MESSAGE = "Idle message";
const ASSISTANT_RESPONSE = "Assistant response";
const getActorInput = () => ({
languageModel: {
invoke: sandbox.stub().resolves({
type: "say",
content: ASSISTANT_RESPONSE
})
} as unknown as LanguageModel,
voice: {
sessionRef: "sessionRef",
answer: sandbox.stub().resolves(),
sgather: sandbox.stub().resolves({
stop: sandbox.stub().resolves(),
onData: sandbox.stub()
}),
stream: sandbox.stub().resolves({
stop: sandbox.stub().resolves(),
onData: sandbox.stub()
}),
playDtmf: sandbox.stub().resolves().callsFake(() => {
// Add a delay to emulate the dtmf tone
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
}),
stopSpeech: sandbox.stub().resolves(),
// Add a delay to emulate the voice response
say: sandbox.stub().resolves().callsFake((content: string) => {
return new Promise<void>((resolve) => setTimeout(() => resolve(), 4000));
}),
hangup: sandbox.stub().resolves()
} as unknown as Voice,
conversationSettings: {
firstMessage: FIRST_MESSAGE,
goodbyeMessage: GOODBYE_MESSAGE,
systemPrompt: "System template",
systemErrorMessage: SYSTEM_ERROR_MESSAGE,
maxSpeechWaitTimeout: 500,
maxSessionDuration: 30 * 60 * 1000,
initialDtmf: "1",
allowUserBargeIn: true,
idleOptions: {
message: IDLE_MESSAGE,
timeout: 30000,
maxTimeoutCount: 3
},
transferOptions: {
phoneNumber: "+1234567890",
message: "Transferring call"
},
vad: {
activationThreshold: 0.3,
deactivationThreshold: 0.2,
debounceFrames: 3
}
}
});
describe("@autopilot/machine", function () {
this.slow(30000);
afterEach(() => sandbox.restore());
it("should say the first message then set the state to 'idle'", async function () {
// Arrange
const { machine } = await import("../src/machine");
const input = getActorInput();
const actor = createActor(machine, {
input
});
// Act
actor.start();
// Wait just enough time to answer, play the dtmf (500ms) and say the
// first message (4000ms). The machine stays in "greeting" until then.
await waitFor(4600);
// Assert
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("idle");
expect(input.voice.answer).to.have.been.calledOnce;
expect(input.voice.say).to.have.been.calledOnceWith(FIRST_MESSAGE);
expect(input.voice.hangup).to.not.have.been.called;
expect(context.firstMessage).to.equal(FIRST_MESSAGE);
expect(context.goodbyeMessage).to.equal(GOODBYE_MESSAGE);
expect(context.systemErrorMessage).to.equal(SYSTEM_ERROR_MESSAGE);
expect(context.idleMessage).to.equal(IDLE_MESSAGE);
expect(context.idleTimeout).to.equal(30000);
expect(context.maxSpeechWaitTimeout).to.equal(500);
expect(context.transferMessage).to.equal("Transferring call");
expect(context.transferPhoneNumber).to.equal("+1234567890");
expect(context.maxIdleTimeoutCount).to.equal(3);
expect(context.idleTimeoutCount).to.equal(0);
expect(context.speechBuffer).to.equal("");
expect(context.allowUserBargeIn).to.equal(true);
// Cleanup
actor.stop();
}).timeout(20000);
it("should set the state to 'idle' and then 'listeningToUser'", async function () {
// Arrange
const { machine } = await import("../src/machine");
const input = getActorInput();
const actor = createActor(machine, {
input
});
// Act
actor.start();
actor.send({ type: "SPEECH_START" });
// Assert
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("listeningToUser");
expect(context.speechBuffer).to.equal("");
expect(context.idleTimeoutCount).to.equal(0);
expect(input.voice.stopSpeech).to.have.been.calledOnce;
// Cleanup
actor.stop();
}).timeout(20000);
it("does not crash when stopSpeech rejects because the call already hung up", async function () {
// Arrange: this reproduces the scenario where the caller hangs up right
// as the assistant is speaking. interruptPlayback (a fire-and-forget
// entry action) calls voice.stopSpeech(), whose underlying verb rejects
// because the session ended before it got a response. Since XState does
// not await/track an action's returned promise, an uncaught rejection
// here previously crashed the whole process.
const { machine } = await import("../src/machine");
const input = getActorInput();
input.voice.stopSpeech = sandbox
.stub()
.rejects(new Error("voice session ended before the StopSay verb completed"));
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason);
process.on("unhandledRejection", onUnhandledRejection);
const actor = createActor(machine, { input });
try {
// Act
actor.start();
actor.send({ type: "SPEECH_START" });
// Give the rejected stopSpeech() promise a chance to settle.
await waitFor(50);
// Assert
const { value: state } = actor.getSnapshot();
expect(state).to.equal("listeningToUser");
expect(input.voice.stopSpeech).to.have.been.calledOnce;
expect(unhandledRejections).to.have.lengthOf(0);
} finally {
process.removeListener("unhandledRejection", onUnhandledRejection);
actor.stop();
}
}).timeout(20000);
it("should append the speech to the buffer and set the state to 'listeningToUser'", async function () {
// Arrange
const { machine } = await import("../src/machine");
const input = getActorInput();
const actor = createActor(machine, {
input
});
// Act
actor.start();
actor.send({ type: "SPEECH_START" });
actor.send({ type: "SPEECH_RESULT", speech: "Well, I personally think that the best way to learn is by doing", responseTime: 1000 });
actor.send({ type: "SPEECH_START" });
actor.send({ type: "SPEECH_RESULT", speech: "and yeah", responseTime: 1000 });
await waitFor(50);
// Goes to "waitingForSpeechTimeout"
actor.send({ type: "SPEECH_RESULT", speech: "thats all", responseTime: 1000 });
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("waitingForSpeechTimeout");
expect(context.speechBuffer).to.equal("Well, I personally think that the best way to learn is by doing and yeah thats all");
expect(context.idleTimeoutCount).to.equal(0);
// Cleanup
actor.stop();
}).timeout(20000);
it("from 'listeningToUser' call SPEECH_RESULT process the request and goes back to 'listeningToUser'", async function () {
// Arrange
const { machine } = await import("../src/machine");
const input = getActorInput();
const actor = createActor(machine, {
input
});
// Act
actor.start();
actor.send({ type: "SPEECH_START" });
actor.send({ type: "SPEECH_RESULT", speech: "Hello", responseTime: 1000 });
// Wait just enough to get a MAX_SPEECH_WAIT_TIMEOUT and process the request
await waitFor(5500);
// Assert
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("listeningToUser");
expect(context.speechBuffer).to.equal("");
expect(input.voice.say).to.have.been.calledWith(ASSISTANT_RESPONSE);
// Cleanup
actor.stop();
}).timeout(20000);
it("goes from 'processingUserRequest' back to 'processingUserRequest' when SPEECH_RESULT is received", async function () {
// Arrange
const { machine } = await import("../src/machine");
const input = getActorInput();
const actor = createActor(machine, {
input
});
// Act
actor.start();
actor.send({ type: "SPEECH_START" });
await waitFor(50);
let { value: state0 } = actor.getSnapshot();
expect(state0).to.equal("listeningToUser");
actor.send({ type: "SPEECH_RESULT", speech: "Hello?", responseTime: 1000 });
// Wait just enough to get a MAX_SPEECH_WAIT_TIMEOUT
await waitFor(600);
let { value: state1 } = actor.getSnapshot();
expect(state1).to.equal("processingUserRequest");
actor.send({ type: "SPEECH_RESULT", speech: "Hello!", responseTime: 1000 });
// Just enough to be in the middle of processing the request
await waitFor(2000);
// Assert
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("processingUserRequest");
expect(context.speechBuffer).to.equal("Hello? Hello!");
expect(input.voice.say).to.have.been.calledWith(ASSISTANT_RESPONSE);
// Cleanup
actor.stop();
}).timeout(20000);
it("with barge-in off, SPEECH_START during processingUserRequest is ignored and AI completes via onDone", async function () {
// Arrange: barge-in disabled
const { machine } = await import("../src/machine");
const input = getActorInput();
input.conversationSettings.allowUserBargeIn = false;
const actor = createActor(machine, { input });
// Act: get to processingUserRequest. With barge-in off the greeting cannot
// be cut short, so wait for it to finish before speaking.
actor.start();
await waitFor(4600);
actor.send({ type: "SPEECH_START" });
await waitFor(50);
actor.send({ type: "SPEECH_RESULT", speech: "Hello", responseTime: 1000 });
await waitFor(600);
expect(actor.getSnapshot().value).to.equal("processingUserRequest");
// Send SPEECH_START while AI is "speaking" (barge-in would interrupt if enabled)
actor.send({ type: "SPEECH_START" });
// Assert: still in processingUserRequest (guard blocked transition)
expect(actor.getSnapshot().value).to.equal("processingUserRequest");
// Wait for AI to finish; should transition via onDone only
await waitFor(5500);
const { context, value: state } = actor.getSnapshot();
expect(state).to.equal("listeningToUser");
expect(context.speechBuffer).to.equal("");
expect(input.voice.say).to.have.been.calledWith(ASSISTANT_RESPONSE);
expect(input.languageModel.invoke).to.have.been.calledOnce;
actor.stop();
}).timeout(20000);
it("with barge-in off, SPEECH_RESULT during processingUserRequest is ignored and AI completes via onDone", async function () {
// Arrange: barge-in disabled
const { machine } = await import("../src/machine");
const input = getActorInput();
input.conversationSettings.allowUserBargeIn = false;
const actor = createActor(machine, { input });
// Act: get to processingUserRequest. With barge-in off the greeting cannot
// be cut short, so wait for it to finish before speaking.
actor.start();
await waitFor(4600);
actor.send({ type: "SPEECH_START" });
await waitFor(50);
actor.send({ type: "SPEECH_RESULT", speech: "Hello?", responseTime: 1000 });
await waitFor(600);
expect(actor.getSnapshot().value).to.equal("processingUserRequest");
// Send late SPEECH_RESULT (would interrupt and reenter if barge-in were enabled)
actor.send({ type: "SPEECH_RESULT", speech: "Hello!", responseTime: 1000 });
await waitFor(2000);
// Assert: still in processingUserRequest, buffer unchanged, no reenter (invoke only once)
let snapshot = actor.getSnapshot();
expect(snapshot.value).to.equal("processingUserRequest");
expect(snapshot.context.speechBuffer).to.equal("Hello?");
expect(input.languageModel.invoke).to.have.been.calledOnce;
// Wait for AI to finish via onDone
await waitFor(5500);
snapshot = actor.getSnapshot();
expect(snapshot.value).to.equal("listeningToUser");
expect(input.voice.say).to.have.been.calledWith(ASSISTANT_RESPONSE);
expect(input.languageModel.invoke).to.have.been.calledOnce;
actor.stop();
}).timeout(20000);
});