























@@ -6,6 +6,7 @@ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
66import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77import type { OpenClawPluginApi } from "./api.js";
88import type { VoiceCallRuntime } from "./runtime-entry.js";
9+import type { CallRecord } from "./src/types.js";
9101011let runtimeStub: VoiceCallRuntime;
1112@@ -52,8 +53,12 @@ function captureStdout() {
5253}
53545455function createRuntimeStub(callId = "call-1"): VoiceCallRuntime {
56+const call = createCallRecord({ callId });
5557return {
56-config: { toNumber: "+15550001234" } as VoiceCallRuntime["config"],
58+config: {
59+toNumber: "+15550001234",
60+realtime: { enabled: false },
61+} as VoiceCallRuntime["config"],
5762provider: {} as VoiceCallRuntime["provider"],
5863manager: {
5964initiateCall: vi.fn(async () => ({ callId, success: true })),
@@ -64,17 +69,35 @@ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime {
6469speak: vi.fn(async () => ({ success: true })),
6570sendDtmf: vi.fn(async () => ({ success: true })),
6671endCall: vi.fn(async () => ({ success: true })),
67-getCall: vi.fn((id: string) => (id === callId ? { callId } : undefined)),
72+getCall: vi.fn((id: string) => (id === callId ? call : undefined)),
6873getCallByProviderCallId: vi.fn(() => undefined),
69-getActiveCalls: vi.fn(() => [{ callId }]),
74+getActiveCalls: vi.fn(() => [call]),
75+getCallHistory: vi.fn(async () => []),
7076} as unknown as VoiceCallRuntime["manager"],
71-webhookServer: {} as VoiceCallRuntime["webhookServer"],
77+webhookServer: {
78+speakRealtime: vi.fn(() => ({ success: false, error: "No active realtime bridge for call" })),
79+} as unknown as VoiceCallRuntime["webhookServer"],
7280webhookUrl: "http://127.0.0.1:3334/voice/webhook",
7381publicUrl: null,
7482stop: vi.fn(async () => {}),
7583};
7684}
778586+function createCallRecord(overrides: Partial<CallRecord> = {}): CallRecord {
87+return {
88+callId: "call-1",
89+provider: "mock",
90+direction: "outbound",
91+state: "active",
92+from: "+15550001111",
93+to: "+15550001234",
94+startedAt: Date.UTC(2026, 4, 2, 9, 0, 0),
95+transcript: [],
96+processedEventIds: [],
97+ ...overrides,
98+};
99+}
100+78101function createServiceContext(): Parameters<NonNullable<Registered["service"]>["start"]>[0] {
79102return {
80103config: {},
@@ -397,6 +420,60 @@ describe("voice-call plugin", () => {
397420expect(respond.mock.calls[0]).toEqual([true, { success: true }]);
398421});
399422423+it("normalizes provider call ids before speaking", async () => {
424+runtimeStub.manager.getCall = vi.fn(() => undefined);
425+runtimeStub.manager.getCallByProviderCallId = vi.fn(() =>
426+createCallRecord({
427+callId: "call-1",
428+providerCallId: "CA123",
429+}),
430+);
431+const { methods } = setup({ provider: "mock" });
432+const handler = methods.get("voicecall.speak") as
433+| ((ctx: {
434+params: Record<string, unknown>;
435+respond: ReturnType<typeof vi.fn>;
436+}) => Promise<void>)
437+| undefined;
438+const respond = vi.fn();
439+440+await handler?.({ params: { callId: "CA123", message: "hello" }, respond });
441+442+expect(runtimeStub.manager.speak).toHaveBeenCalledWith("call-1", "hello");
443+expect(respond.mock.calls[0]).toEqual([true, { success: true }]);
444+});
445+446+it("reports ended call history when speaking to a stale call", async () => {
447+runtimeStub.manager.getCall = vi.fn(() => undefined);
448+runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
449+runtimeStub.manager.getCallHistory = vi.fn(async () => [
450+createCallRecord({
451+callId: "call-1",
452+providerCallId: "CA123",
453+state: "completed",
454+endReason: "completed",
455+endedAt: Date.UTC(2026, 4, 2, 9, 18, 23),
456+}),
457+]);
458+const { methods } = setup({ provider: "mock" });
459+const handler = methods.get("voicecall.speak") as
460+| ((ctx: {
461+params: Record<string, unknown>;
462+respond: ReturnType<typeof vi.fn>;
463+}) => Promise<void>)
464+| undefined;
465+const respond = vi.fn();
466+467+await handler?.({ params: { callId: "CA123", message: "hello" }, respond });
468+469+const [ok, , error] = respond.mock.calls[0] ?? [];
470+expect(ok).toBe(false);
471+expect(error.message).toContain("call is not active");
472+expect(error.message).toContain("last state=completed");
473+expect(error.message).toContain("endReason=completed");
474+expect(runtimeStub.manager.speak).not.toHaveBeenCalled();
475+});
476+400477it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
401478const { methods } = setup({
402479enabled: true,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。