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
376 lines
13 KiB
TypeScript
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);
|
|
});
|