


























@@ -7,13 +7,14 @@ import {
77createPluginStateSyncKeyedStoreForTests,
88resetPluginStateStoreForTests,
99} from "openclaw/plugin-sdk/plugin-state-test-runtime";
10-import { afterEach, beforeEach, describe, expect, it } from "vitest";
10+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1111import { VoiceCallConfigSchema } from "../config.js";
1212import type { VoiceCallProvider } from "../providers/base.js";
1313import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "../runtime-state.js";
1414import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js";
1515import type { CallManagerContext } from "./context.js";
1616import { processEvent } from "./events.js";
17+import { speakInitialMessage } from "./outbound.js";
1718import { flushPendingCallRecordWritesForTest } from "./store.js";
18191920const contexts: CallManagerContext[] = [];
@@ -54,6 +55,8 @@ afterEach(async () => {
5455}
5556clearVoiceCallStateRuntime();
5657resetPluginStateStoreForTests();
58+vi.useRealTimers();
59+vi.restoreAllMocks();
5760});
58615962function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
@@ -363,6 +366,155 @@ describe("processEvent (functional)", () => {
363366expect(answeredCallId).toBe("call-2");
364367});
365368369+it.each([
370+{
371+name: "speaking",
372+expectedState: "speaking",
373+createEvent: (timestamp: number): NormalizedEvent => ({
374+id: "evt-live-speaking",
375+type: "call.speaking",
376+callId: "call-live",
377+providerCallId: "provider-live",
378+ timestamp,
379+text: "hello",
380+}),
381+},
382+{
383+name: "listening",
384+expectedState: "listening",
385+createEvent: (timestamp: number): NormalizedEvent => ({
386+id: "evt-live-listening",
387+type: "call.speech",
388+callId: "call-live",
389+providerCallId: "provider-live",
390+ timestamp,
391+transcript: "hello",
392+isFinal: true,
393+}),
394+},
395+])(
396+"starts max-duration enforcement when $name arrives before answered",
397+async ({ expectedState, createEvent }) => {
398+const now = new Date("2026-03-22T12:00:00.000Z").getTime();
399+vi.useFakeTimers();
400+vi.setSystemTime(now);
401+const hangupCalls: HangupCallInput[] = [];
402+const ctx = createContext({
403+config: VoiceCallConfigSchema.parse({
404+enabled: true,
405+provider: "plivo",
406+fromNumber: "+15550000000",
407+maxDurationSeconds: 1,
408+}),
409+provider: createProvider({
410+hangupCall: async (input: HangupCallInput): Promise<void> => {
411+hangupCalls.push(input);
412+},
413+}),
414+});
415+ctx.activeCalls.set("call-live", {
416+callId: "call-live",
417+providerCallId: "provider-live",
418+provider: "plivo",
419+direction: "inbound",
420+state: "ringing",
421+from: "+15550000002",
422+to: "+15550000000",
423+startedAt: now - 120_000,
424+transcript: [],
425+processedEventIds: [],
426+metadata: {},
427+});
428+ctx.providerCallIdMap.set("provider-live", "call-live");
429+const liveTimestamp = now + 250;
430+431+processEvent(ctx, createEvent(liveTimestamp));
432+433+const call = ctx.activeCalls.get("call-live");
434+if (!call) {
435+throw new Error("expected live call to remain active");
436+}
437+expect(call.state).toBe(expectedState);
438+expect(call.answeredAt).toBe(liveTimestamp);
439+expect(ctx.maxDurationTimers.has("call-live")).toBe(true);
440+441+await vi.advanceTimersByTimeAsync(1_000);
442+443+expect(hangupCalls).toEqual([
444+{
445+callId: "call-live",
446+providerCallId: "provider-live",
447+reason: "timeout",
448+},
449+]);
450+expect(ctx.activeCalls.has("call-live")).toBe(false);
451+vi.useRealTimers();
452+},
453+);
454+455+it("enforces max duration for Twilio initial-message streams without answeredAt", async () => {
456+const now = new Date("2026-03-22T12:00:00.000Z").getTime();
457+vi.useFakeTimers();
458+vi.setSystemTime(now);
459+const hangupCalls: HangupCallInput[] = [];
460+const provider = createProvider({
461+name: "twilio",
462+hangupCall: async (input: HangupCallInput): Promise<void> => {
463+hangupCalls.push(input);
464+},
465+}) as VoiceCallProvider & { isConversationStreamConnectEnabled?: () => boolean };
466+provider.isConversationStreamConnectEnabled = () => true;
467+const ctx = createContext({
468+config: VoiceCallConfigSchema.parse({
469+enabled: true,
470+provider: "twilio",
471+fromNumber: "+15550000000",
472+maxDurationSeconds: 1,
473+streaming: { enabled: true },
474+}),
475+ provider,
476+});
477+ctx.activeCalls.set("call-stream", {
478+callId: "call-stream",
479+providerCallId: "provider-stream",
480+provider: "twilio",
481+direction: "inbound",
482+state: "active",
483+from: "+15550000002",
484+to: "+15550000000",
485+startedAt: now - 120_000,
486+transcript: [],
487+processedEventIds: [],
488+metadata: {
489+initialMessage: "Hello from the bot.",
490+mode: "conversation",
491+},
492+});
493+ctx.providerCallIdMap.set("provider-stream", "call-stream");
494+495+await speakInitialMessage(ctx, "provider-stream");
496+497+const call = ctx.activeCalls.get("call-stream");
498+if (!call) {
499+throw new Error("expected initial-message call to remain active");
500+}
501+expect(call.state).toBe("speaking");
502+expect(call.answeredAt).toBe(now);
503+expect(ctx.maxDurationTimers.has("call-stream")).toBe(true);
504+505+await vi.advanceTimersByTimeAsync(1_000);
506+507+expect(hangupCalls).toEqual([
508+{
509+callId: "call-stream",
510+providerCallId: "provider-stream",
511+reason: "timeout",
512+},
513+]);
514+expect(ctx.activeCalls.has("call-stream")).toBe(false);
515+vi.useRealTimers();
516+});
517+366518it("removes active call even when hangup rejects", () => {
367519const provider = createProvider({
368520hangupCall: async (): Promise<void> => {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。