mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:01:34 +00:00
* fix(voice-call): clamp continue poll RPCs to remaining deadline * test(voice-call): prove continue deadline bound --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
// Voice Call tests cover cli plugin behavior.
|
|
import { Command } from "commander";
|
|
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
const callGatewayFromCliMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("openclaw/plugin-sdk/gateway-runtime")>()),
|
|
callGatewayFromCli: callGatewayFromCliMock,
|
|
}));
|
|
|
|
import { registerVoiceCallCli } from "./cli.js";
|
|
|
|
function captureStdout() {
|
|
let output = "";
|
|
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
|
|
output += String(chunk);
|
|
return true;
|
|
}) as typeof process.stdout.write);
|
|
return {
|
|
output: () => output,
|
|
restore: () => writeSpy.mockRestore(),
|
|
};
|
|
}
|
|
|
|
describe("voice-call CLI status fallback", () => {
|
|
afterEach(() => {
|
|
callGatewayFromCliMock.mockReset();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function buildProgram(
|
|
manager: Record<string, unknown>,
|
|
config: Record<string, unknown> = {},
|
|
): Command {
|
|
const program = new Command();
|
|
registerVoiceCallCli({
|
|
program,
|
|
config: config as never,
|
|
ensureRuntime: async () => ({ manager }) as never,
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} } as never,
|
|
});
|
|
return program;
|
|
}
|
|
|
|
async function runStatusWithUnavailableGateway(
|
|
manager: Record<string, unknown>,
|
|
error = new Error("connect ECONNREFUSED 127.0.0.1:18789"),
|
|
): Promise<unknown> {
|
|
callGatewayFromCliMock.mockRejectedValue(error);
|
|
const program = buildProgram(manager);
|
|
const capturer = captureStdout();
|
|
try {
|
|
await program.parseAsync(["voicecall", "status", "--call-id", "call-1", "--json"], {
|
|
from: "user",
|
|
});
|
|
} finally {
|
|
capturer.restore();
|
|
}
|
|
return JSON.parse(capturer.output().trim());
|
|
}
|
|
|
|
it("uses the manager's persisted fallback when the gateway is unavailable", async () => {
|
|
const result = await runStatusWithUnavailableGateway({
|
|
getActiveCalls: () => [],
|
|
getCallFromMemoryOrStore: async () => ({
|
|
callId: "call-1",
|
|
providerCallId: "CA123",
|
|
state: "completed",
|
|
endReason: "completed",
|
|
endedAt: 1,
|
|
}),
|
|
});
|
|
expect(result).toMatchObject({ callId: "call-1", state: "completed" });
|
|
});
|
|
|
|
it("reports found:false when the call is neither active nor persisted", async () => {
|
|
const result = await runStatusWithUnavailableGateway({
|
|
getActiveCalls: () => [],
|
|
getCallFromMemoryOrStore: async () => undefined,
|
|
});
|
|
expect(result).toEqual({ found: false });
|
|
});
|
|
|
|
it("falls back after an abnormal local gateway close", async () => {
|
|
const result = await runStatusWithUnavailableGateway(
|
|
{
|
|
getActiveCalls: () => [],
|
|
getCallFromMemoryOrStore: async () => ({ callId: "call-1", state: "completed" }),
|
|
},
|
|
new Error("gateway closed (1006 abnormal closure (no close frame)): no close reason"),
|
|
);
|
|
expect(result).toMatchObject({ callId: "call-1", state: "completed" });
|
|
});
|
|
|
|
it("rejects non-decimal tail options through the registered command", async () => {
|
|
const program = buildProgram({});
|
|
await expect(
|
|
program.parseAsync(["voicecall", "tail", "--since", "0x10"], { from: "user" }),
|
|
).rejects.toThrow("Invalid numeric value for --since: 0x10");
|
|
});
|
|
|
|
it("caps oversized operation timeouts through the start command", async () => {
|
|
callGatewayFromCliMock.mockResolvedValue({ callId: "call-1" });
|
|
const program = buildProgram({}, { ringTimeoutMs: Number.MAX_SAFE_INTEGER });
|
|
await program.parseAsync(["voicecall", "start", "--to", "+15550001111"], {
|
|
from: "user",
|
|
});
|
|
expect(callGatewayFromCliMock).toHaveBeenCalledWith(
|
|
"voicecall.start",
|
|
{ json: true, timeout: String(MAX_TIMER_TIMEOUT_MS) },
|
|
{ to: "+15550001111", mode: "conversation" },
|
|
{ progress: false },
|
|
);
|
|
});
|
|
|
|
it("caps oversized legacy continue timeouts through the command", async () => {
|
|
callGatewayFromCliMock
|
|
.mockRejectedValueOnce(new Error("unknown method: voicecall.continue.start"))
|
|
.mockResolvedValueOnce({ success: true, transcript: "done" });
|
|
const program = buildProgram({}, { transcriptTimeoutMs: Number.MAX_SAFE_INTEGER });
|
|
await program.parseAsync(
|
|
["voicecall", "continue", "--call-id", "call-1", "--message", "hello"],
|
|
{ from: "user" },
|
|
);
|
|
expect(callGatewayFromCliMock).toHaveBeenLastCalledWith(
|
|
"voicecall.continue",
|
|
{ json: true, timeout: String(MAX_TIMER_TIMEOUT_MS) },
|
|
{ callId: "call-1", message: "hello" },
|
|
{ progress: false },
|
|
);
|
|
});
|
|
|
|
it("uses the configured continue deadline when the gateway poll timeout is non-finite", async () => {
|
|
callGatewayFromCliMock.mockResolvedValueOnce({
|
|
operationId: "op-1",
|
|
status: "pending",
|
|
pollTimeoutMs: Number.NaN,
|
|
});
|
|
vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(50_000);
|
|
const program = buildProgram({}, { transcriptTimeoutMs: 100 });
|
|
await expect(
|
|
program.parseAsync(["voicecall", "continue", "--call-id", "call-1", "--message", "hello"], {
|
|
from: "user",
|
|
}),
|
|
).rejects.toThrow("voicecall continue timed out waiting for gateway operation");
|
|
expect(callGatewayFromCliMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("bounds a withheld continue.result RPC to the overall poll deadline", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
callGatewayFromCliMock
|
|
.mockResolvedValueOnce({
|
|
operationId: "op-1",
|
|
status: "pending",
|
|
pollTimeoutMs: 1_500,
|
|
})
|
|
.mockResolvedValueOnce({ status: "pending" })
|
|
.mockImplementationOnce(
|
|
async (_method: string, opts: { timeout: string }) =>
|
|
await new Promise((_, reject) => {
|
|
setTimeout(
|
|
() => reject(new Error(`gateway timeout after ${opts.timeout}ms`)),
|
|
Number(opts.timeout),
|
|
);
|
|
}),
|
|
);
|
|
|
|
const program = buildProgram({}, { transcriptTimeoutMs: 100 });
|
|
const startedAtMs = Date.now();
|
|
const execution = program.parseAsync(
|
|
["voicecall", "continue", "--call-id", "call-1", "--message", "hello"],
|
|
{ from: "user" },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
"voicecall.continue.result",
|
|
{ json: true, timeout: "500" },
|
|
{ operationId: "op-1" },
|
|
{ progress: false },
|
|
);
|
|
const rejected = expect(execution).rejects.toThrow("gateway timeout after 500ms");
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await rejected;
|
|
expect(Date.now() - startedAtMs).toBe(1_500);
|
|
});
|
|
});
|