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", () => { describe("CallManager", () => {
it("upgrades providerCallId mapping when provider ID changes", async () => { it("upgrades providerCallId mapping when provider ID changes", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager } = createManagerHarness();
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 { callId, success, error } = await manager.initiateCall("+15550000001"); const { callId, success, error } = await manager.initiateCall("+15550000001");
expect(success).toBe(true); expect(success).toBe(true);
@ -81,16 +108,7 @@ describe("CallManager", () => {
}); });
it("speaks initial message on answered for notify mode (non-Twilio)", async () => { it("speaks initial message on answered for notify mode (non-Twilio)", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness();
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 { callId, success } = await manager.initiateCall("+15550000002", undefined, { const { callId, success } = await manager.initiateCall("+15550000002", undefined, {
message: "Hello there", message: "Hello there",
@ -113,19 +131,11 @@ describe("CallManager", () => {
}); });
it("rejects inbound calls with missing caller ID when allowlist enabled", () => { it("rejects inbound calls with missing caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "allowlist", inboundPolicy: "allowlist",
allowFrom: ["+15550001234"], 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({ manager.processEvent({
id: "evt-allowlist-missing", id: "evt-allowlist-missing",
type: "call.initiated", type: "call.initiated",
@ -142,19 +152,11 @@ describe("CallManager", () => {
}); });
it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => { it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "allowlist", inboundPolicy: "allowlist",
allowFrom: ["+15550001234"], 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({ manager.processEvent({
id: "evt-allowlist-anon", id: "evt-allowlist-anon",
type: "call.initiated", type: "call.initiated",
@ -172,19 +174,11 @@ describe("CallManager", () => {
}); });
it("rejects inbound calls that only match allowlist suffixes", () => { it("rejects inbound calls that only match allowlist suffixes", () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "allowlist", inboundPolicy: "allowlist",
allowFrom: ["+15550001234"], 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({ manager.processEvent({
id: "evt-allowlist-suffix", id: "evt-allowlist-suffix",
type: "call.initiated", type: "call.initiated",
@ -202,18 +196,10 @@ describe("CallManager", () => {
}); });
it("rejects duplicate inbound events with a single hangup call", () => { it("rejects duplicate inbound events with a single hangup call", () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled", 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({ manager.processEvent({
id: "evt-reject-init", id: "evt-reject-init",
type: "call.initiated", type: "call.initiated",
@ -242,18 +228,11 @@ describe("CallManager", () => {
}); });
it("accepts inbound calls that exactly match the allowlist", () => { it("accepts inbound calls that exactly match the allowlist", () => {
const config = VoiceCallConfigSchema.parse({ const { manager } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "allowlist", inboundPolicy: "allowlist",
allowFrom: ["+15550001234"], 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({ manager.processEvent({
id: "evt-allowlist-exact", id: "evt-allowlist-exact",
type: "call.initiated", type: "call.initiated",
@ -269,28 +248,14 @@ describe("CallManager", () => {
}); });
it("completes a closed-loop turn without live audio", async () => { it("completes a closed-loop turn without live audio", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
transcriptTimeoutMs: 5000, 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"); const started = await manager.initiateCall("+15550000003");
expect(started.success).toBe(true); expect(started.success).toBe(true);
manager.processEvent({ markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
id: "evt-closed-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
const turnPromise = manager.continueCall(started.callId, "How can I help?"); const turnPromise = manager.continueCall(started.callId, "How can I help?");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@ -323,28 +288,14 @@ describe("CallManager", () => {
}); });
it("rejects overlapping continueCall requests for the same call", async () => { it("rejects overlapping continueCall requests for the same call", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
transcriptTimeoutMs: 5000, 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"); const started = await manager.initiateCall("+15550000004");
expect(started.success).toBe(true); expect(started.success).toBe(true);
manager.processEvent({ markCallAnswered(manager, started.callId, "evt-overlap-answered");
id: "evt-overlap-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
const first = manager.continueCall(started.callId, "First prompt"); const first = manager.continueCall(started.callId, "First prompt");
const second = await manager.continueCall(started.callId, "Second 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 () => { it("tracks latency metadata across multiple closed-loop turns", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
transcriptTimeoutMs: 5000, 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"); const started = await manager.initiateCall("+15550000005");
expect(started.success).toBe(true); expect(started.success).toBe(true);
manager.processEvent({ markCallAnswered(manager, started.callId, "evt-multi-answered");
id: "evt-multi-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
const firstTurn = manager.continueCall(started.callId, "First question"); const firstTurn = manager.continueCall(started.callId, "First question");
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
@ -436,28 +373,14 @@ describe("CallManager", () => {
}); });
it("handles repeated closed-loop turns without waiter churn", async () => { it("handles repeated closed-loop turns without waiter churn", async () => {
const config = VoiceCallConfigSchema.parse({ const { manager, provider } = createManagerHarness({
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
transcriptTimeoutMs: 5000, 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"); const started = await manager.initiateCall("+15550000006");
expect(started.success).toBe(true); expect(started.success).toBe(true);
manager.processEvent({ markCallAnswered(manager, started.callId, "evt-loop-answered");
id: "evt-loop-answered",
type: "call.answered",
callId: started.callId,
providerCallId: "request-uuid",
timestamp: Date.now(),
});
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const turnPromise = manager.continueCall(started.callId, `Prompt ${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)", () => { describe("processEvent (functional)", () => {
it("calls provider hangup when rejecting inbound call", () => { it("calls provider hangup when rejecting inbound call", () => {
const hangupCalls: HangupCallInput[] = []; const hangupCalls: HangupCallInput[] = [];
@ -55,24 +81,14 @@ describe("processEvent (functional)", () => {
}); });
const ctx = createContext({ const ctx = createContext({
config: VoiceCallConfigSchema.parse({ config: createInboundDisabledConfig(),
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
provider, provider,
}); });
const event: NormalizedEvent = { const event = createInboundInitiatedEvent({
id: "evt-1", id: "evt-1",
type: "call.initiated",
callId: "prov-1",
providerCallId: "prov-1", providerCallId: "prov-1",
timestamp: Date.now(),
direction: "inbound",
from: "+15559999999", from: "+15559999999",
to: "+15550000000", });
};
processEvent(ctx, event); processEvent(ctx, event);
@ -87,24 +103,14 @@ describe("processEvent (functional)", () => {
it("does not call hangup when provider is null", () => { it("does not call hangup when provider is null", () => {
const ctx = createContext({ const ctx = createContext({
config: VoiceCallConfigSchema.parse({ config: createInboundDisabledConfig(),
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
provider: null, provider: null,
}); });
const event: NormalizedEvent = { const event = createInboundInitiatedEvent({
id: "evt-2", id: "evt-2",
type: "call.initiated",
callId: "prov-2",
providerCallId: "prov-2", providerCallId: "prov-2",
timestamp: Date.now(),
direction: "inbound",
from: "+15551111111", from: "+15551111111",
to: "+15550000000", });
};
processEvent(ctx, event); processEvent(ctx, event);
@ -119,24 +125,14 @@ describe("processEvent (functional)", () => {
}, },
}); });
const ctx = createContext({ const ctx = createContext({
config: VoiceCallConfigSchema.parse({ config: createInboundDisabledConfig(),
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
provider, provider,
}); });
const event1: NormalizedEvent = { const event1 = createInboundInitiatedEvent({
id: "evt-init", id: "evt-init",
type: "call.initiated",
callId: "prov-dup",
providerCallId: "prov-dup", providerCallId: "prov-dup",
timestamp: Date.now(),
direction: "inbound",
from: "+15552222222", from: "+15552222222",
to: "+15550000000", });
};
const event2: NormalizedEvent = { const event2: NormalizedEvent = {
id: "evt-ring", id: "evt-ring",
type: "call.ringing", type: "call.ringing",
@ -228,24 +224,14 @@ describe("processEvent (functional)", () => {
}, },
}); });
const ctx = createContext({ const ctx = createContext({
config: VoiceCallConfigSchema.parse({ config: createInboundDisabledConfig(),
enabled: true,
provider: "plivo",
fromNumber: "+15550000000",
inboundPolicy: "disabled",
}),
provider, provider,
}); });
const event: NormalizedEvent = { const event = createInboundInitiatedEvent({
id: "evt-fail", id: "evt-fail",
type: "call.initiated",
callId: "prov-fail",
providerCallId: "prov-fail", providerCallId: "prov-fail",
timestamp: Date.now(),
direction: "inbound",
from: "+15553333333", from: "+15553333333",
to: "+15550000000", });
};
expect(() => processEvent(ctx, event)).not.toThrow(); expect(() => processEvent(ctx, event)).not.toThrow();
expect(ctx.activeCalls.size).toBe(0); expect(ctx.activeCalls.size).toBe(0);

View File

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