fix(voice-call): tighten manager outbound behavior

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:23 +00:00
parent 8c1afc4b63
commit 081ab9c99d
3 changed files with 139 additions and 210 deletions

View File

@@ -46,17 +46,44 @@ class FakeProvider implements VoiceCallProvider {
}
}
let storeSeq = 0;
function createTestStorePath(): string {
storeSeq += 1;
return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
}
function createManagerHarness(
configOverrides: Record<string, unknown> = {},
provider = new FakeProvider(),
): {
manager: CallManager;
provider: FakeProvider;
} {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
...configOverrides,
});
const manager = new CallManager(config, createTestStorePath());
manager.initialize(provider, "https://example.com/voice/webhook");
return { manager, provider };
}
function markCallAnswered(manager: CallManager, callId: string, eventId: string): void {
manager.processEvent({
id: eventId,
type: "call.answered",
callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
}
describe("CallManager", () => {
it("upgrades providerCallId mapping when provider ID changes", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const manager = new CallManager(config, storePath);
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
const { manager } = createManagerHarness();
const { callId, success, error } = await manager.initiateCall("+15550000001");
expect(success).toBe(true);
@@ -81,16 +108,7 @@ describe("CallManager", () => {
});
it("speaks initial message on answered for notify mode (non-Twilio)", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const { manager, provider } = createManagerHarness();
const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
message: "Hello there",
@@ -113,19 +131,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-missing",
type: "call.initiated",
@@ -142,19 +152,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-anon",
type: "call.initiated",
@@ -172,19 +174,11 @@ describe("CallManager", () => {
});
it("rejects inbound calls that only match allowlist suffixes", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-suffix",
type: "call.initiated",
@@ -202,18 +196,10 @@ describe("CallManager", () => {
});
it("rejects duplicate inbound events with a single hangup call", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
inboundPolicy: "disabled",
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-reject-init",
type: "call.initiated",
@@ -242,18 +228,11 @@ describe("CallManager", () => {
});
it("accepts inbound calls that exactly match the allowlist", () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager } = createManagerHarness({
inboundPolicy: "allowlist",
allowFrom: ["+15550001234"],
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const manager = new CallManager(config, storePath);
manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
manager.processEvent({
id: "evt-allowlist-exact",
type: "call.initiated",
@@ -269,28 +248,14 @@ describe("CallManager", () => {
});
it("completes a closed-loop turn without live audio", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000003");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-closed-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
const turnPromise = manager.continueCall(started.callId, "How can I help?");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -323,28 +288,14 @@ describe("CallManager", () => {
});
it("rejects overlapping continueCall requests for the same call", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000004");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-overlap-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-overlap-answered");
const first = manager.continueCall(started.callId, "First prompt");
const second = await manager.continueCall(started.callId, "Second prompt");
@@ -369,28 +320,14 @@ describe("CallManager", () => {
});
it("tracks latency metadata across multiple closed-loop turns", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000005");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-multi-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-multi-answered");
const firstTurn = manager.continueCall(started.callId, "First question");
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -436,28 +373,14 @@ describe("CallManager", () => {
});
it("handles repeated closed-loop turns without waiter churn", async () => {
const config = VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
const { manager, provider } = createManagerHarness({
transcriptTimeoutMs: 5000,
});
const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
const provider = new FakeProvider();
const manager = new CallManager(config, storePath);
manager.initialize(provider, "https://example.com/voice/webhook");
const started = await manager.initiateCall("+15550000006");
expect(started.success).toBe(true);
manager.processEvent({
id: "evt-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
markCallAnswered(manager, started.callId, "evt-loop-answered");
for (let i = 1; i <= 5; i++) {
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);

View File

@@ -45,6 +45,32 @@ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallPr
};
}
function createInboundDisabledConfig() {
return VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
});
}
function createInboundInitiatedEvent(params: {
id: string;
providerCallId: string;
from: string;
}): NormalizedEvent {
return {
id: params.id,
type: "call.initiated",
callId: params.providerCallId,
providerCallId: params.providerCallId,
timestamp: Date.now(),
direction: "inbound",
from: params.from,
to: "+15550000000",
};
}
describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => {
const hangupCalls: HangupCallInput[] = [];
@@ -55,24 +81,14 @@ describe("processEvent (functional)", () => {
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-1",
type: "call.initiated",
callId: "prov-1",
providerCallId: "prov-1",
timestamp: Date.now(),
direction: "inbound",
from: "+15559999999",
to: "+15550000000",
};
});
processEvent(ctx, event);
@@ -87,24 +103,14 @@ describe("processEvent (functional)", () => {
it("does not call hangup when provider is null", () => {
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider: null,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-2",
type: "call.initiated",
callId: "prov-2",
providerCallId: "prov-2",
timestamp: Date.now(),
direction: "inbound",
from: "+15551111111",
to: "+15550000000",
};
});
processEvent(ctx, event);
@@ -119,24 +125,14 @@ describe("processEvent (functional)", () => {
},
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event1: NormalizedEvent = {
const event1 = createInboundInitiatedEvent({
id: "evt-init",
type: "call.initiated",
callId: "prov-dup",
providerCallId: "prov-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222",
to: "+15550000000",
};
});
const event2: NormalizedEvent = {
id: "evt-ring",
type: "call.ringing",
@@ -228,24 +224,14 @@ describe("processEvent (functional)", () => {
},
});
const ctx = createContext({
config: VoiceCallConfigSchema.parse({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
config: createInboundDisabledConfig(),
provider,
});
const event: NormalizedEvent = {
const event = createInboundInitiatedEvent({
id: "evt-fail",
type: "call.initiated",
callId: "prov-fail",
providerCallId: "prov-fail",
timestamp: Date.now(),
direction: "inbound",
from: "+15553333333",
to: "+15550000000",
};
});
expect(() => processEvent(ctx, event)).not.toThrow();
expect(ctx.activeCalls.size).toBe(0);

View File

@@ -51,6 +51,32 @@ type EndCallContext = Pick<
| "maxDurationTimers"
>;
type ConnectedCallContext = Pick<CallManagerContext, "activeCalls" | "provider">;
type ConnectedCallLookup =
| { kind: "error"; error: string }
| { kind: "ended"; call: CallRecord }
| {
kind: "ok";
call: CallRecord;
providerCallId: string;
provider: NonNullable<ConnectedCallContext["provider"]>;
};
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { kind: "error", error: "Call not found" };
}
if (!ctx.provider || !call.providerCallId) {
return { kind: "error", error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
return { kind: "ended", call };
}
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
}
export async function initiateCall(
ctx: InitiateContext,
to: string,
@@ -149,26 +175,25 @@ export async function speak(
callId: CallId,
text: string,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
try {
transitionState(call, "speaking");
persistCallRecord(ctx.storePath, call);
addTranscriptEntry(call, "bot", text);
const voice = ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({
const voice = provider.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await provider.playTts({
callId,
providerCallId: call.providerCallId,
providerCallId,
text,
voice,
});
@@ -232,16 +257,15 @@ export async function continueCall(
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
return { success: false, error: "Already waiting for transcript" };
}
@@ -256,13 +280,13 @@ export async function continueCall(
persistCallRecord(ctx.storePath, call);
const listenStartedAt = Date.now();
await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
await provider.startListening({ callId, providerCallId });
const transcript = await waitForFinalTranscript(ctx, callId);
const transcriptReceivedAt = Date.now();
// Best-effort: stop listening after final transcript.
await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
await provider.stopListening({ callId, providerCallId });
const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
@@ -302,21 +326,19 @@ export async function endCall(
ctx: EndCallContext,
callId: CallId,
): Promise<{ success: boolean; error?: string }> {
const call = ctx.activeCalls.get(callId);
if (!call) {
return { success: false, error: "Call not found" };
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
}
if (!ctx.provider || !call.providerCallId) {
return { success: false, error: "Call not connected" };
}
if (TerminalStates.has(call.state)) {
if (lookup.kind === "ended") {
return { success: true };
}
const { call, providerCallId, provider } = lookup;
try {
await ctx.provider.hangupCall({
await provider.hangupCall({
callId,
providerCallId: call.providerCallId,
providerCallId,
reason: "hangup-bot",
});
@@ -329,9 +351,7 @@ export async function endCall(
rejectTranscriptWaiter(ctx, callId, "Call ended: hangup-bot");
ctx.activeCalls.delete(callId);
if (call.providerCallId) {
ctx.providerCallIdMap.delete(call.providerCallId);
}
ctx.providerCallIdMap.delete(providerCallId);
return { success: true };
} catch (err) {