fix(voice-call): cap manager timer delays

This commit is contained in:
Peter Steinberger
2026-05-30 04:45:00 -04:00
parent b19584b25e
commit 65fc5d1c5d
7 changed files with 116 additions and 6 deletions

View File

@@ -20,6 +20,7 @@ import {
loadActiveCallsFromStore,
persistCallRecord,
} from "./manager/store.js";
import { resolveVoiceCallSecondsTimerDelayMs } from "./manager/timer-delays.js";
import { startMaxDurationTimer } from "./manager/timers.js";
import type { VoiceCallProvider } from "./providers/base.js";
import {
@@ -129,7 +130,7 @@ export class CallManager {
for (const [callId, call] of verified) {
if (call.answeredAt && !TerminalStates.has(call.state)) {
const elapsed = Date.now() - call.answeredAt;
const maxDurationMs = this.config.maxDurationSeconds * 1000;
const maxDurationMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
if (elapsed >= maxDurationMs) {
// Already expired — remove instead of keeping
verified.delete(callId);
@@ -174,7 +175,7 @@ export class CallManager {
return new Map();
}
const maxAgeMs = this.config.maxDurationSeconds * 1000;
const maxAgeMs = resolveVoiceCallSecondsTimerDelayMs(this.config.maxDurationSeconds);
const now = Date.now();
const verified = new Map<CallId, CallRecord>();
const verifyTasks: Array<{ callId: CallId; call: CallRecord; promise: Promise<void> }> = [];

View File

@@ -1,3 +1,4 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
@@ -51,7 +52,7 @@ vi.mock("./twiml.js", () => ({
generateNotifyTwiml: generateNotifyTwimlMock,
}));
import { endCall, initiateCall, sendDtmf, speak } from "./outbound.js";
import { endCall, initiateCall, sendDtmf, speak, speakInitialMessage } from "./outbound.js";
function createActiveCallContext(params: { hangupCall?: ReturnType<typeof vi.fn> } = {}) {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
@@ -359,6 +360,38 @@ describe("voice-call outbound helpers", () => {
});
});
it("caps notify-mode auto-hangup delay before scheduling", async () => {
const call = {
callId: "call-1",
providerCallId: "provider-1",
state: "active",
metadata: { initialMessage: "hello", mode: "notify" },
};
const playTts = vi.fn(async () => {});
const timeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
getCallByProviderCallIdMock.mockReturnValue(call);
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map([["provider-1", "call-1"]]),
provider: { name: "twilio", playTts },
initialMessageInFlight: new Set(),
config: {
outbound: { notifyHangupDelaySec: Number.MAX_SAFE_INTEGER },
tts: { provider: "openai", providers: { openai: { voice: "alloy" } } },
},
storePath: "/tmp/voice-call.json",
};
try {
await speakInitialMessage(ctx as never, "provider-1");
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
timeoutSpy.mockRestore();
}
});
it("uses per-number route TTS voice for routed inbound calls", async () => {
const call = {
callId: "call-1",

View File

@@ -19,6 +19,7 @@ import { finalizeCall } from "./lifecycle.js";
import { getCallByProviderCallId } from "./lookup.js";
import { addTranscriptEntry, transitionState } from "./state.js";
import { persistCallRecord } from "./store.js";
import { resolveVoiceCallSecondsTimerDelayMs } from "./timer-delays.js";
import { clearTranscriptWaiter, waitForFinalTranscript } from "./timers.js";
import { generateDtmfRedirectTwiml, generateNotifyTwiml } from "./twiml.js";
@@ -380,6 +381,7 @@ export async function speakInitialMessage(
if (mode === "notify") {
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
const delayMs = resolveVoiceCallSecondsTimerDelayMs(delaySec, 0);
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(async () => {
const currentCall = ctx.activeCalls.get(call.callId);
@@ -387,7 +389,7 @@ export async function speakInitialMessage(
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
}, delaySec * 1000);
}, delayMs);
} else if (
mode === "conversation" &&
ctx.provider &&

View File

@@ -0,0 +1,17 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it } from "vitest";
import {
resolveVoiceCallSecondsTimerDelayMs,
resolveVoiceCallTimerDelayMs,
} from "./timer-delays.js";
describe("voice-call timer delays", () => {
it("caps second-based delays to timer-safe milliseconds", () => {
expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
expect(resolveVoiceCallSecondsTimerDelayMs(Number.MAX_VALUE)).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("caps millisecond delays to timer-safe values", () => {
expect(resolveVoiceCallTimerDelayMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,17 @@
import { MAX_TIMER_TIMEOUT_MS, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
export function resolveVoiceCallSecondsTimerDelayMs(seconds: number, minMs = 1): number {
if (!Number.isFinite(seconds)) {
return resolveTimerTimeoutMs(MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS, minMs);
}
const timeoutMs = Math.floor(seconds * 1000);
return resolveTimerTimeoutMs(
Number.isFinite(timeoutMs) ? timeoutMs : MAX_TIMER_TIMEOUT_MS,
minMs,
minMs,
);
}
export function resolveVoiceCallTimerDelayMs(timeoutMs: number, fallbackMs = 1): number {
return resolveTimerTimeoutMs(timeoutMs, fallbackMs);
}

View File

@@ -1,3 +1,4 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { persistCallRecordMock } = vi.hoisted(() => ({
@@ -82,6 +83,38 @@ describe("voice-call manager timers", () => {
expect(onTimeout).not.toHaveBeenCalled();
});
it("caps oversized max duration and transcript timers", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const ctx = {
activeCalls: new Map([["call-1", { id: "call-1", state: "active" }]]),
maxDurationTimers: new Map(),
transcriptWaiters: new Map(),
config: {
maxDurationSeconds: Number.MAX_SAFE_INTEGER,
transcriptTimeoutMs: Number.MAX_SAFE_INTEGER,
},
storePath: "/tmp/voice-call",
};
try {
startMaxDurationTimer({
ctx: ctx as never,
callId: "call-1",
onTimeout: vi.fn(async () => {}),
});
const transcript = waitForFinalTranscript(ctx as never, "call-2");
expect(
timeoutSpy.mock.calls.filter(([, delay]) => delay === MAX_TIMER_TIMEOUT_MS),
).toHaveLength(2);
clearMaxDurationTimer(ctx as never, "call-1");
rejectTranscriptWaiter(ctx as never, "call-2", "done");
void transcript.catch(() => {});
} finally {
timeoutSpy.mockRestore();
}
});
it("waits for transcripts, resolves matching tokens, rejects mismatches and timeouts", async () => {
const ctx = {
transcriptWaiters: new Map(),

View File

@@ -1,6 +1,10 @@
import { TerminalStates, type CallId } from "../types.js";
import type { CallManagerContext } from "./context.js";
import { persistCallRecord } from "./store.js";
import {
resolveVoiceCallSecondsTimerDelayMs,
resolveVoiceCallTimerDelayMs,
} from "./timer-delays.js";
type TimerContext = Pick<
CallManagerContext,
@@ -31,7 +35,10 @@ export function startMaxDurationTimer(params: {
}): void {
clearMaxDurationTimer(params.ctx, params.callId);
const maxDurationMs = params.timeoutMs ?? params.ctx.config.maxDurationSeconds * 1000;
const maxDurationMs =
params.timeoutMs === undefined
? resolveVoiceCallSecondsTimerDelayMs(params.ctx.config.maxDurationSeconds)
: resolveVoiceCallTimerDelayMs(params.timeoutMs);
console.log(
`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`,
);
@@ -101,7 +108,7 @@ export function waitForFinalTranscript(
return Promise.reject(new Error("Already waiting for transcript"));
}
const timeoutMs = ctx.config.transcriptTimeoutMs;
const timeoutMs = resolveVoiceCallTimerDelayMs(ctx.config.transcriptTimeoutMs);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ctx.transcriptWaiters.delete(callId);