mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-08 19:53:56 +00:00
test: execute media and Talk runtime boundaries (#101091)
* test: execute media and Talk runtime boundaries * test(qa): centralize gateway fixture startup * test(qa): check in voice call plugin fixture
This commit is contained in:
@@ -115,6 +115,65 @@ async function expectPathMissing(filePath: string): Promise<void> {
|
||||
throw new Error(`expected ${filePath} to be missing`);
|
||||
}
|
||||
|
||||
describe("runQaGatewayCliCommand", () => {
|
||||
it("runs CLI commands with the Gateway fixture environment", async () => {
|
||||
const output = await testing.runQaGatewayCliCommand({
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: [
|
||||
"--eval",
|
||||
'process.stdout.write(`${process.env.OPENCLAW_CLI}:${process.env.QA_VALUE}:${process.argv.slice(1).join(",")}`)',
|
||||
],
|
||||
args: ["voicecall", "start"],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, QA_VALUE: "fixture" },
|
||||
});
|
||||
|
||||
expect(output).toBe("1:fixture:voicecall,start");
|
||||
});
|
||||
|
||||
it("reports CLI stderr when a fixture command fails", async () => {
|
||||
await expect(
|
||||
testing.runQaGatewayCliCommand({
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: ["--eval", 'process.stderr.write("fixture failure"); process.exit(7)'],
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
}),
|
||||
).rejects.toThrow("OpenClaw CLI exited 7: fixture failure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gateway child fixture helpers", () => {
|
||||
it("creates an empty transport config seam", () => {
|
||||
expect(testing.createQaGatewayEmptyTransport()).toEqual({
|
||||
requiredPluginIds: [],
|
||||
createGatewayConfig: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves source and built Gateway CLI commands", async () => {
|
||||
const repoRoot = await tempDirs.makeTempDir("qa-gateway-command-");
|
||||
await mkdir(path.join(repoRoot, "src"), { recursive: true });
|
||||
await writeFile(path.join(repoRoot, "src", "entry.ts"), "export {};\n", "utf8");
|
||||
|
||||
expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: ["--import", "tsx", path.join(repoRoot, "src", "entry.ts")],
|
||||
cwd: repoRoot,
|
||||
});
|
||||
|
||||
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
|
||||
await writeFile(path.join(repoRoot, "dist", "index.js"), "export {};\n", "utf8");
|
||||
expect(testing.resolveQaGatewayChildCommand(repoRoot)).toEqual({
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: [path.join(repoRoot, "dist", "index.js")],
|
||||
cwd: repoRoot,
|
||||
usePackagedPlugins: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildQaRuntimeEnv", () => {
|
||||
it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => {
|
||||
const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-"));
|
||||
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
resolveQaOwnerPluginIdsForProviderIds,
|
||||
resolveQaRuntimeHostVersion,
|
||||
} from "./bundled-plugin-staging.js";
|
||||
import {
|
||||
appendQaChildOutput,
|
||||
appendQaChildOutputTail,
|
||||
createQaChildOutputCapture,
|
||||
createQaChildOutputTail,
|
||||
formatQaChildOutputTail,
|
||||
readQaChildOutput,
|
||||
} from "./child-output.js";
|
||||
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
|
||||
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
|
||||
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
|
||||
@@ -86,6 +94,66 @@ export type QaGatewayChildListeningContext = {
|
||||
runtimeEnv: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function createQaGatewayEmptyTransport() {
|
||||
return {
|
||||
requiredPluginIds: [] as const,
|
||||
createGatewayConfig: () => ({}),
|
||||
} satisfies Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
|
||||
}
|
||||
|
||||
function resolveQaGatewayChildCommand(repoRoot: string): QaGatewayChildCommand {
|
||||
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
|
||||
const entryPath = path.join(repoRoot, relativePath);
|
||||
if (existsSync(entryPath)) {
|
||||
return {
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: [entryPath],
|
||||
cwd: repoRoot,
|
||||
usePackagedPlugins: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
|
||||
if (existsSync(sourceEntryPath)) {
|
||||
return {
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: ["--import", "tsx", sourceEntryPath],
|
||||
cwd: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
|
||||
}
|
||||
|
||||
async function runQaGatewayCliCommand(params: {
|
||||
executablePath: string;
|
||||
argsPrefix: readonly string[];
|
||||
args: readonly string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<string> {
|
||||
const stdout = createQaChildOutputCapture();
|
||||
const stderr = createQaChildOutputTail();
|
||||
const child = spawn(params.executablePath, [...params.argsPrefix, ...params.args], {
|
||||
cwd: params.cwd,
|
||||
env: { ...params.env, OPENCLAW_CLI: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => appendQaChildOutput(stdout, chunk));
|
||||
child.stderr.on("data", (chunk) => appendQaChildOutputTail(stderr, chunk));
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => resolve(code ?? 1));
|
||||
});
|
||||
const stdoutText = readQaChildOutput(stdout);
|
||||
if (exitCode !== 0) {
|
||||
const stderrText = formatQaChildOutputTail(stderr, "stderr");
|
||||
throw new Error(`OpenClaw CLI exited ${exitCode}: ${stderrText || stdoutText}`);
|
||||
}
|
||||
return stdoutText;
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -378,6 +446,8 @@ export const testing = {
|
||||
redactQaGatewayDebugText,
|
||||
readQaLiveProviderConfigOverrides,
|
||||
resolveQaGatewayChildProviderMode,
|
||||
resolveQaGatewayChildCommand,
|
||||
createQaGatewayEmptyTransport,
|
||||
assertQaLiveCodexAuthAvailable,
|
||||
stageQaLiveApiKeyProfiles,
|
||||
stageQaLiveAnthropicSetupToken,
|
||||
@@ -387,6 +457,7 @@ export const testing = {
|
||||
resolveQaOwnerPluginIdsForProviderIds,
|
||||
resolveQaBundledPluginSourceDir,
|
||||
resolveQaRuntimeHostVersion,
|
||||
runQaGatewayCliCommand,
|
||||
createQaGatewayChildLogCollector,
|
||||
createQaBundledPluginsDir,
|
||||
signalQaGatewayChildProcessTree,
|
||||
@@ -671,8 +742,9 @@ export function resolveQaControlUiRoot(params: { repoRoot: string; controlUiEnab
|
||||
export async function startQaGatewayChild(params: {
|
||||
repoRoot: string;
|
||||
command?: QaGatewayChildCommand;
|
||||
useRepoCli?: boolean;
|
||||
providerBaseUrl?: string;
|
||||
transport: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
|
||||
transport?: Pick<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
|
||||
transportBaseUrl: string;
|
||||
controlUiAllowedOrigins?: string[];
|
||||
providerMode?: QaProviderMode;
|
||||
@@ -694,7 +766,9 @@ export async function startQaGatewayChild(params: {
|
||||
);
|
||||
const runtimeCwd = tempRoot;
|
||||
const distEntryPath = path.join(params.repoRoot, "dist", "index.js");
|
||||
const gatewayCommand = params.command;
|
||||
const gatewayCommand =
|
||||
params.command ??
|
||||
(params.useRepoCli ? resolveQaGatewayChildCommand(params.repoRoot) : undefined);
|
||||
const gatewayExecutablePath = gatewayCommand?.executablePath;
|
||||
const gatewayArgsPrefix = gatewayCommand?.argsPrefix ?? [];
|
||||
const gatewayArgsSuffix = gatewayCommand?.argsSuffix ?? [];
|
||||
@@ -707,6 +781,7 @@ export async function startQaGatewayChild(params: {
|
||||
const xdgCacheHome = path.join(tempRoot, "xdg-cache");
|
||||
const configPath = path.join(tempRoot, "openclaw.json");
|
||||
const gatewayToken = `qa-suite-${randomUUID()}`;
|
||||
const transport = params.transport ?? createQaGatewayEmptyTransport();
|
||||
await seedQaAgentWorkspace({
|
||||
workspaceDir,
|
||||
repoRoot: params.repoRoot,
|
||||
@@ -757,8 +832,8 @@ export async function startQaGatewayChild(params: {
|
||||
primaryModel: params.primaryModel,
|
||||
alternateModel: params.alternateModel,
|
||||
enabledPluginIds,
|
||||
transportPluginIds: params.transport.requiredPluginIds,
|
||||
transportConfig: params.transport.createGatewayConfig({
|
||||
transportPluginIds: transport.requiredPluginIds,
|
||||
transportConfig: transport.createGatewayConfig({
|
||||
baseUrl: params.transportBaseUrl,
|
||||
}),
|
||||
liveProviderConfigs,
|
||||
@@ -809,8 +884,11 @@ export async function startQaGatewayChild(params: {
|
||||
|
||||
try {
|
||||
const nodeExecPath = gatewayExecutablePath ?? (await resolveQaNodeExecPath());
|
||||
const cliArgsPrefix = gatewayExecutablePath
|
||||
? gatewayArgsPrefix
|
||||
: [distEntryPath, ...gatewayArgsPrefix];
|
||||
const buildGatewayArgs = () => [
|
||||
...(gatewayExecutablePath ? gatewayArgsPrefix : [distEntryPath, ...gatewayArgsPrefix]),
|
||||
...cliArgsPrefix,
|
||||
"gateway",
|
||||
"run",
|
||||
"--port",
|
||||
@@ -1104,6 +1182,15 @@ export async function startQaGatewayChild(params: {
|
||||
configPath,
|
||||
runtimeEnv: runningEnv,
|
||||
logs,
|
||||
runCli(args: readonly string[]) {
|
||||
return runQaGatewayCliCommand({
|
||||
executablePath: nodeExecPath,
|
||||
argsPrefix: cliArgsPrefix,
|
||||
args,
|
||||
cwd: gatewayCwd,
|
||||
env: runningEnv,
|
||||
});
|
||||
},
|
||||
signalProcess(signal: NodeJS.Signals) {
|
||||
if (!activeChild.pid) {
|
||||
throw new Error("qa gateway child has no pid");
|
||||
|
||||
@@ -262,10 +262,20 @@ describe("validateProviderConfig", () => {
|
||||
const result = validateProviderConfig(config);
|
||||
|
||||
expect(result.errors).not.toContain(
|
||||
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
|
||||
'plugins.entries.voice-call.config.provider must be "twilio", "telnyx", or "mock" when realtime.enabled is true',
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts realtime.enabled with provider=mock", () => {
|
||||
const config = createBaseConfig("mock");
|
||||
config.realtime.enabled = true;
|
||||
config.inboundPolicy = "allowlist";
|
||||
|
||||
const result = validateProviderConfig(config);
|
||||
|
||||
expect(result).toEqual({ valid: true, errors: [] });
|
||||
});
|
||||
|
||||
it("rejects realtime.enabled with providers that do not support it yet", () => {
|
||||
const config = createBaseConfig("plivo");
|
||||
config.realtime.enabled = true;
|
||||
@@ -275,7 +285,7 @@ describe("validateProviderConfig", () => {
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
|
||||
'plugins.entries.voice-call.config.provider must be "twilio", "telnyx", or "mock" when realtime.enabled is true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -924,10 +924,11 @@ export function validateProviderConfig(config: VoiceCallConfig): {
|
||||
config.realtime.enabled &&
|
||||
config.provider &&
|
||||
config.provider !== "twilio" &&
|
||||
config.provider !== "telnyx"
|
||||
config.provider !== "telnyx" &&
|
||||
config.provider !== "mock"
|
||||
) {
|
||||
errors.push(
|
||||
'plugins.entries.voice-call.config.provider must be "twilio" or "telnyx" when realtime.enabled is true',
|
||||
'plugins.entries.voice-call.config.provider must be "twilio", "telnyx", or "mock" when realtime.enabled is true',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
// Voice Call E2E tests cover CLI, Gateway RPC, and agent tool through the mock provider.
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import { testing as voiceCallCliTesting } from "./cli.js";
|
||||
import {
|
||||
createVoiceCallStateRuntimeForTests,
|
||||
createTestStorePath,
|
||||
installVoiceCallStateRuntimeForTests,
|
||||
} from "./manager.test-harness.js";
|
||||
import { clearVoiceCallStateRuntime } from "./runtime-state.js";
|
||||
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||
|
||||
type GatewayHandler = (request: {
|
||||
params?: Record<string, unknown>;
|
||||
respond: (ok: boolean, payload?: unknown, error?: { message?: string }) => void;
|
||||
}) => Promise<void>;
|
||||
|
||||
function asOptionalRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function reserveLocalPort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
reject(new Error("failed to reserve local voice-call port"));
|
||||
return;
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isAddressInUseError(error: unknown): boolean {
|
||||
return error instanceof Error && `${error.message}\n${error.stack ?? ""}`.includes("EADDRINUSE");
|
||||
}
|
||||
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
async function runVoiceCallEntryPointFixture(): Promise<void> {
|
||||
const stateDir = createTestStorePath();
|
||||
tempDirs.add(stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
resetPluginStateStoreForTests();
|
||||
installVoiceCallStateRuntimeForTests();
|
||||
const stateRuntime = createVoiceCallStateRuntimeForTests();
|
||||
const config = createVoiceCallBaseConfig({ provider: "mock" });
|
||||
config.maxConcurrentCalls = 3;
|
||||
config.store = path.join(stateDir, "voice-calls");
|
||||
config.serve.port = await reserveLocalPort();
|
||||
|
||||
const methods = new Map<string, GatewayHandler>();
|
||||
const tools: Array<{
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: unknown) => Promise<{ details?: unknown }>;
|
||||
}> = [];
|
||||
let cliRegistrar: ((context: { program: Command }) => void) | undefined;
|
||||
let service:
|
||||
| {
|
||||
stop?: (context: { config: unknown; stateDir: string; logger: unknown }) => Promise<void>;
|
||||
}
|
||||
| undefined;
|
||||
const logger = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
const api = createTestPluginApi({
|
||||
id: "voice-call",
|
||||
name: "Voice Call",
|
||||
source: "qa",
|
||||
config: {},
|
||||
pluginConfig: config,
|
||||
logger,
|
||||
runtime: {
|
||||
agent: {},
|
||||
state: stateRuntime,
|
||||
tts: { textToSpeechTelephony: vi.fn() },
|
||||
} as unknown as OpenClawPluginApi["runtime"],
|
||||
registerGatewayMethod: (method, handler) => {
|
||||
methods.set(method, handler as GatewayHandler);
|
||||
},
|
||||
registerTool: (tool) => {
|
||||
tools.push(tool as (typeof tools)[number]);
|
||||
},
|
||||
registerCli: (registrar) => {
|
||||
cliRegistrar = registrar as typeof cliRegistrar;
|
||||
},
|
||||
registerService: (registeredService) => {
|
||||
service = registeredService as typeof service;
|
||||
},
|
||||
});
|
||||
plugin.register(api);
|
||||
|
||||
const invokeGateway = async (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const handler = methods.get(method);
|
||||
if (!handler) {
|
||||
throw new Error(`missing Gateway handler: ${method}`);
|
||||
}
|
||||
return await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
void handler({
|
||||
params,
|
||||
respond(ok, payload, error) {
|
||||
if (ok) {
|
||||
const record = asOptionalRecord(payload);
|
||||
if (!record) {
|
||||
reject(new Error(`${method} returned a non-object payload`));
|
||||
return;
|
||||
}
|
||||
resolve(record);
|
||||
return;
|
||||
}
|
||||
reject(new Error(error?.message ?? `${method} failed`));
|
||||
},
|
||||
}).catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
voiceCallCliTesting.setCallGatewayFromCliForTests(async (method, _options, params) =>
|
||||
invokeGateway(method, asOptionalRecord(params)),
|
||||
);
|
||||
const program = new Command();
|
||||
cliRegistrar?.({ program });
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
await program.parseAsync(
|
||||
["voicecall", "start", "--to", "+15550001111", "--message", "CLI fixture"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const rpcResult = await invokeGateway("voicecall.initiate", {
|
||||
to: "+15550002222",
|
||||
message: "RPC fixture",
|
||||
mode: "notify",
|
||||
});
|
||||
expect(rpcResult).toMatchObject({ initiated: true, callId: expect.any(String) });
|
||||
|
||||
const voiceCallTool = tools.find((tool) => tool.name === "voice_call");
|
||||
if (!voiceCallTool) {
|
||||
throw new Error("voice_call tool was not registered");
|
||||
}
|
||||
const toolResult = await voiceCallTool.execute("tool-call", {
|
||||
action: "initiate_call",
|
||||
to: "+15550003333",
|
||||
message: "Agent tool fixture",
|
||||
mode: "conversation",
|
||||
});
|
||||
expect(toolResult.details).toMatchObject({ initiated: true, callId: expect.any(String) });
|
||||
|
||||
const status = (await invokeGateway("voicecall.status")) as { calls?: unknown[] };
|
||||
expect(status.calls).toHaveLength(3);
|
||||
expect(stdout).toHaveBeenCalled();
|
||||
} finally {
|
||||
await service?.stop?.({ config: {}, stateDir, logger }).catch(() => undefined);
|
||||
voiceCallCliTesting.setCallGatewayFromCliForTests();
|
||||
clearVoiceCallStateRuntime();
|
||||
resetPluginStateStoreForTests();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
}
|
||||
|
||||
describe("QA Voice Call CLI, RPC, and agent tool", () => {
|
||||
afterEach(() => {
|
||||
voiceCallCliTesting.setCallGatewayFromCliForTests();
|
||||
clearVoiceCallStateRuntime();
|
||||
resetPluginStateStoreForTests();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
});
|
||||
|
||||
it("routes all three entry points through one mock-provider runtime", async () => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await runVoiceCallEntryPointFixture();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isAddressInUseError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
});
|
||||
});
|
||||
@@ -416,6 +416,7 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error {
|
||||
|
||||
export type GatewayClientOptions = {
|
||||
url?: string; // ws://127.0.0.1:18789
|
||||
origin?: string;
|
||||
connectChallengeTimeoutMs?: number;
|
||||
/** @deprecated Use connectChallengeTimeoutMs. */
|
||||
connectDelayMs?: number;
|
||||
@@ -637,6 +638,7 @@ export class GatewayClient {
|
||||
this.deps.beforeConnect();
|
||||
const wsOptions: FingerprintCheckingClientOptions = {
|
||||
maxPayload: 25 * 1024 * 1024,
|
||||
...(this.opts.origin ? { origin: this.opts.origin } : {}),
|
||||
};
|
||||
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
||||
wsOptions.rejectUnauthorized = false;
|
||||
|
||||
@@ -98,6 +98,23 @@ describe("GatewayClient", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sends the configured websocket origin", async () => {
|
||||
const port = await getFreePort();
|
||||
wss = new WebSocketServer({ port, host: "127.0.0.1" });
|
||||
const receivedOrigin = new Promise<string | undefined>((resolve) => {
|
||||
wss?.once("connection", (_socket, request) => resolve(request.headers.origin));
|
||||
});
|
||||
const client = new GatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
connectChallengeTimeoutMs: 0,
|
||||
});
|
||||
client.start();
|
||||
|
||||
await expect(receivedOrigin).resolves.toBe(`http://127.0.0.1:${port}`);
|
||||
client.stop();
|
||||
});
|
||||
|
||||
test("prefers connectChallengeTimeoutMs and still honors the legacy alias", () => {
|
||||
expect(resolveGatewayClientConnectChallengeTimeoutMs({})).toBe(
|
||||
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
|
||||
|
||||
@@ -5,7 +5,7 @@ scenario:
|
||||
surface: media-understanding-and-media-generation
|
||||
category: media-understanding-and-media-generation.text-to-speech-delivery
|
||||
coverage:
|
||||
secondary:
|
||||
primary:
|
||||
- media.tts
|
||||
- media.outbound-voice-audio-delivery
|
||||
objective: Verify WebChat auto-TTS synthesizes only the final reply tail and serves trusted local audio through scoped browser media tickets.
|
||||
@@ -22,8 +22,13 @@ scenario:
|
||||
- packages/speech-core/src/tts.ts
|
||||
- src/gateway/server-methods/chat-webchat-media.ts
|
||||
- src/gateway/control-ui.ts
|
||||
- test/e2e/qa-lab/media/webchat-auto-tts.e2e.test.ts
|
||||
- test/e2e/qa-lab/runtime/media-talk-gateway.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/media/webchat-auto-tts.e2e.test.ts
|
||||
summary: Vitest QA Lab coverage for mock WebChat TTS synthesis and real scoped media-ticket delivery.
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/runtime/media-talk-gateway.ts
|
||||
summary: Starts a real Gateway and mock model/TTS providers, sends a WebChat turn, and verifies the history attachment plus scoped media-ticket route.
|
||||
args:
|
||||
- --scenario
|
||||
- webchat-auto-tts
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
title: Voice Call CLI, RPC, and agent tool mock-provider flow
|
||||
title: Voice Call CLI, RPC, agent tool, and realtime consult flow
|
||||
|
||||
scenario:
|
||||
id: voice-call-cli-rpc-agent-tool
|
||||
@@ -7,12 +7,13 @@ scenario:
|
||||
coverage:
|
||||
primary:
|
||||
- voice-call.cli-rpc-agent-tool
|
||||
objective: Verify the Voice Call CLI, Gateway RPC, and agent tool share one executable mock-provider runtime.
|
||||
objective: Verify the real Voice Call CLI, Gateway RPC, and Gateway-invoked agent tool share one mock-provider runtime whose realtime media stream reaches embedded-agent consult context.
|
||||
successCriteria:
|
||||
- The CLI starts an outbound call through its Gateway RPC path.
|
||||
- The registered Gateway RPC starts an outbound call through the mock provider.
|
||||
- The registered agent tool starts an outbound call through the same runtime.
|
||||
- Runtime status reports all calls and cleanup stops the local webhook fixture.
|
||||
- The registered Gateway RPC starts an outbound call through the same mock provider runtime.
|
||||
- Gateway tools.invoke starts an outbound call through the registered voice_call agent tool.
|
||||
- The runtime issues a real webhook media-stream token for the tool-started call.
|
||||
- The fake realtime provider invokes openclaw_agent_consult and the embedded agent receives caller transcript and provider context.
|
||||
docsRefs:
|
||||
- docs/cli/voicecall.md
|
||||
- docs/plugins/voice-call.md
|
||||
@@ -20,9 +21,13 @@ scenario:
|
||||
codeRefs:
|
||||
- extensions/voice-call/index.ts
|
||||
- extensions/voice-call/src/cli.ts
|
||||
- extensions/voice-call/src/manager.test-harness.ts
|
||||
- extensions/voice-call/src/voice-call-cli-rpc-agent-tool.e2e.test.ts
|
||||
- extensions/voice-call/src/runtime.ts
|
||||
- extensions/voice-call/src/webhook/realtime-handler.ts
|
||||
- test/e2e/qa-lab/runtime/voice-call-gateway.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: extensions/voice-call/src/voice-call-cli-rpc-agent-tool.e2e.test.ts
|
||||
summary: Vitest QA Lab coverage for Voice Call CLI, RPC, and agent tool entry points using the mock provider.
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/runtime/voice-call-gateway.ts
|
||||
summary: Starts a real Gateway and Voice Call plugin, runs the actual CLI plus RPC and tools.invoke entry points, then drives the runtime-issued realtime media stream through embedded-agent consult.
|
||||
args:
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
|
||||
@@ -5,22 +5,27 @@ scenario:
|
||||
surface: voice-and-realtime-talk
|
||||
category: voice-and-realtime-talk.realtime-talk-sessions
|
||||
coverage:
|
||||
secondary:
|
||||
primary:
|
||||
- voice.active-talk-agent-run-status
|
||||
objective: Verify a mock realtime Talk session wires status, steering, follow-up, and cancellation through the active-run control contract.
|
||||
objective: Verify a real Gateway Talk session drives status, steering, follow-up, and cancellation against one active agent run.
|
||||
successCriteria:
|
||||
- A registered mock realtime provider creates a browser-owned Talk session with consult and control tools.
|
||||
- Status formatting reports the latest supplied non-control tool progress.
|
||||
- Steering and follow-up invoke the injected queue boundary with the expected modes.
|
||||
- Cancellation invokes the injected abort boundary for the resolved active session.
|
||||
- A registered mock realtime provider creates a browser-owned Talk session with consult and control tools through Gateway RPC.
|
||||
- A consult tool call starts a real active agent run through the Gateway chat path.
|
||||
- Status, steering, and follow-up RPCs observe and queue work to that active run.
|
||||
- Cancellation RPC aborts that same active run from the owning browser connection.
|
||||
docsRefs:
|
||||
- docs/nodes/talk.md
|
||||
- docs/web/control-ui.md
|
||||
codeRefs:
|
||||
- src/gateway/server-methods/talk-client.ts
|
||||
- src/talk/agent-run-control.ts
|
||||
- test/e2e/qa-lab/voice/active-talk-agent-run-status.e2e.test.ts
|
||||
- test/e2e/qa-lab/runtime/media-talk-gateway.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/voice/active-talk-agent-run-status.e2e.test.ts
|
||||
summary: Vitest QA Lab boundary coverage for mock realtime session creation and active Talk run-control dependencies.
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/runtime/media-talk-gateway.ts
|
||||
summary: Starts a real Gateway and persistent WebChat client, creates a Talk session, starts an agent consult, and exercises status, steer, follow-up, and cancel RPCs.
|
||||
args:
|
||||
- --scenario
|
||||
- active-talk-agent-run-status
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
|
||||
@@ -119,6 +119,7 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error {
|
||||
|
||||
export type GatewayClientOptions = {
|
||||
url?: string;
|
||||
origin?: string;
|
||||
connectChallengeTimeoutMs?: number;
|
||||
/** @deprecated Use connectChallengeTimeoutMs. */
|
||||
connectDelayMs?: number;
|
||||
|
||||
@@ -2249,6 +2249,38 @@ describe("talk.client.toolCall handler", () => {
|
||||
expect(response.idempotencyKey).toMatch(/^talk-call-1-/);
|
||||
});
|
||||
|
||||
it("returns the tool-call acknowledgement while the agent run continues", async () => {
|
||||
let finishRun: (() => void) | undefined;
|
||||
mocks.chatSend.mockImplementationOnce(
|
||||
({ respond }: { respond: (ok: boolean, result?: unknown, error?: unknown) => void }) =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishRun = resolve;
|
||||
respond(true, { runId: "run-active" }, undefined);
|
||||
}),
|
||||
);
|
||||
const respond = vi.fn();
|
||||
|
||||
await talkHandlers["talk.client.toolCall"]({
|
||||
req: { type: "req", id: "1", method: "talk.client.toolCall" },
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
callId: "call-active",
|
||||
name: "openclaw_agent_consult",
|
||||
args: { question: "What is running?" },
|
||||
},
|
||||
client: { connId: "conn-1" } as never,
|
||||
isWebchatConnect: () => false,
|
||||
respond: respond as never,
|
||||
context: {
|
||||
getRuntimeConfig: () => ({}) as OpenClawConfig,
|
||||
logGateway: { warn: vi.fn() },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expectRespondOk(respond, { runId: "run-active" });
|
||||
finishRun?.();
|
||||
});
|
||||
|
||||
it("passes configured consult thinking and fast-mode overrides to chat.send", async () => {
|
||||
const respond = vi.fn();
|
||||
|
||||
|
||||
@@ -76,36 +76,63 @@ export async function startTalkRealtimeAgentConsult(params: {
|
||||
}
|
||||
const idempotencyKey = `talk-${params.callId}-${randomUUID()}`;
|
||||
const normalizedTalk = normalizeTalkSection(params.context.getRuntimeConfig().talk);
|
||||
let chatResponse: { ok: true; result: unknown } | { ok: false; error: ErrorShape } | undefined;
|
||||
await chatHandlers["chat.send"]({
|
||||
req: {
|
||||
type: "req",
|
||||
id: `${params.requestId}:talk-tool-call`,
|
||||
method: "chat.send",
|
||||
},
|
||||
client: params.client,
|
||||
isWebchatConnect: params.isWebchatConnect,
|
||||
context: params.context,
|
||||
params: {
|
||||
sessionKey: params.sessionKey,
|
||||
message,
|
||||
idempotencyKey,
|
||||
...(normalizedTalk?.consultThinkingLevel
|
||||
? { thinking: normalizedTalk.consultThinkingLevel }
|
||||
: {}),
|
||||
...(typeof normalizedTalk?.consultFastMode === "boolean"
|
||||
? { fastMode: normalizedTalk.consultFastMode }
|
||||
: {}),
|
||||
},
|
||||
respond: (ok: boolean, result?: unknown, error?: ErrorShape) => {
|
||||
chatResponse = ok
|
||||
? { ok: true, result }
|
||||
: {
|
||||
ok: false,
|
||||
error: error ?? errorShape(ErrorCodes.UNAVAILABLE, "chat.send failed without error"),
|
||||
};
|
||||
},
|
||||
} as Parameters<GatewayRequestHandlers[string]>[0]);
|
||||
const chatResponse = await new Promise<
|
||||
{ ok: true; result: unknown } | { ok: false; error: ErrorShape } | undefined
|
||||
>((resolve) => {
|
||||
let acknowledged = false;
|
||||
const chatSendResult = chatHandlers["chat.send"]({
|
||||
req: {
|
||||
type: "req",
|
||||
id: `${params.requestId}:talk-tool-call`,
|
||||
method: "chat.send",
|
||||
},
|
||||
client: params.client,
|
||||
isWebchatConnect: params.isWebchatConnect,
|
||||
context: params.context,
|
||||
params: {
|
||||
sessionKey: params.sessionKey,
|
||||
message,
|
||||
idempotencyKey,
|
||||
...(normalizedTalk?.consultThinkingLevel
|
||||
? { thinking: normalizedTalk.consultThinkingLevel }
|
||||
: {}),
|
||||
...(typeof normalizedTalk?.consultFastMode === "boolean"
|
||||
? { fastMode: normalizedTalk.consultFastMode }
|
||||
: {}),
|
||||
},
|
||||
respond: (ok: boolean, result?: unknown, error?: ErrorShape) => {
|
||||
acknowledged = true;
|
||||
resolve(
|
||||
ok
|
||||
? { ok: true, result }
|
||||
: {
|
||||
ok: false,
|
||||
error:
|
||||
error ?? errorShape(ErrorCodes.UNAVAILABLE, "chat.send failed without error"),
|
||||
},
|
||||
);
|
||||
},
|
||||
} as Parameters<GatewayRequestHandlers[string]>[0]);
|
||||
void Promise.resolve(chatSendResult).then(
|
||||
() => {
|
||||
if (!acknowledged) {
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (acknowledged) {
|
||||
params.context.logGateway.warn(
|
||||
`realtime Talk agent consult failed after acknowledgement: ${formatForLog(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!chatResponse) {
|
||||
return {
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { maybeApplyTtsToPayload } from "../../../../packages/speech-core/src/tts.ts";
|
||||
import { setRuntimeConfigSnapshot } from "../../../../src/config/config.ts";
|
||||
import { buildWebchatAudioContentBlocksFromReplyPayloads } from "../../../../src/gateway/server-methods/chat-webchat-media.ts";
|
||||
import {
|
||||
installGatewayTestHooks,
|
||||
setTestPluginRegistry,
|
||||
testState,
|
||||
withGatewayServer,
|
||||
} from "../../../../src/gateway/test-helpers.ts";
|
||||
import { createPluginRecord } from "../../../../src/plugins/loader-records.ts";
|
||||
import { createPluginRegistry } from "../../../../src/plugins/registry.ts";
|
||||
import { getActivePluginRegistry } from "../../../../src/plugins/runtime.ts";
|
||||
import { resetPluginRuntimeStateForTest } from "../../../../src/plugins/runtime.ts";
|
||||
import { getSpeechProvider } from "../../../../src/tts/provider-registry.ts";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const CONTROL_UI_E2E_TOKEN = "test-gateway-token-1234567890";
|
||||
|
||||
const noopLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
function installMockTtsProvider() {
|
||||
const registry = createPluginRegistry({
|
||||
logger: noopLogger,
|
||||
runtime: {},
|
||||
activateGlobalSideEffects: false,
|
||||
});
|
||||
const record = createPluginRecord({
|
||||
id: "qa-webchat-auto-tts",
|
||||
name: "QA WebChat Auto TTS",
|
||||
source: "test/e2e/qa-lab/media/webchat-auto-tts.e2e.test.ts",
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
});
|
||||
const synthesizeCalls: string[] = [];
|
||||
|
||||
registry.registerSpeechProvider(record, {
|
||||
id: "mock",
|
||||
label: "Mock",
|
||||
autoSelectOrder: 1,
|
||||
isConfigured: () => true,
|
||||
synthesize: async (request) => {
|
||||
synthesizeCalls.push(request.text);
|
||||
return {
|
||||
audioBuffer: Buffer.from("voice"),
|
||||
fileExtension: ".ogg",
|
||||
outputFormat: "ogg",
|
||||
voiceCompatible: request.target === "voice-note",
|
||||
};
|
||||
},
|
||||
});
|
||||
setTestPluginRegistry(registry.registry);
|
||||
return synthesizeCalls;
|
||||
}
|
||||
|
||||
describe("QA WebChat auto TTS", () => {
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
it("synthesizes only the final WebChat tail and exposes trusted local audio", async () => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
const synthesizeCalls = installMockTtsProvider();
|
||||
const prefsPath = path.join(os.tmpdir(), `openclaw-webchat-tts-${process.pid}.json`);
|
||||
let mediaPath: string | undefined;
|
||||
|
||||
try {
|
||||
const text = "WebChat streams block text; dispatch synthesizes one TTS tail with kind final.";
|
||||
const cfg = {
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
prefsPath,
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
setRuntimeConfigSnapshot(cfg, cfg);
|
||||
|
||||
expect(getActivePluginRegistry()?.speechProviders.map((entry) => entry.provider.id)).toEqual([
|
||||
"mock",
|
||||
]);
|
||||
expect(getSpeechProvider("mock", cfg)?.id).toBe("mock");
|
||||
|
||||
const blockResult = await maybeApplyTtsToPayload({
|
||||
payload: { text },
|
||||
cfg,
|
||||
channel: "webchat",
|
||||
kind: "block",
|
||||
});
|
||||
expect(blockResult.mediaUrl).toBeUndefined();
|
||||
expect(blockResult.text).toBe(text);
|
||||
expect(synthesizeCalls).toEqual([]);
|
||||
|
||||
const tailResult = await maybeApplyTtsToPayload({
|
||||
payload: { text },
|
||||
cfg,
|
||||
channel: "webchat",
|
||||
kind: "final",
|
||||
});
|
||||
mediaPath = tailResult.mediaUrl;
|
||||
|
||||
expect(synthesizeCalls).toEqual([text]);
|
||||
expect(mediaPath).toMatch(/voice-\d+\.ogg$/);
|
||||
if (!mediaPath || !fs.existsSync(mediaPath)) {
|
||||
throw new Error("expected final WebChat TTS to write local audio");
|
||||
}
|
||||
expect(tailResult).toMatchObject({
|
||||
spokenText: text,
|
||||
trustedLocalMedia: true,
|
||||
});
|
||||
|
||||
const trustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
|
||||
[
|
||||
{
|
||||
mediaUrl: mediaPath,
|
||||
audioAsVoice: tailResult.audioAsVoice,
|
||||
spokenText: text,
|
||||
trustedLocalMedia: true,
|
||||
},
|
||||
],
|
||||
{ localRoots: [path.dirname(mediaPath)] },
|
||||
);
|
||||
const untrustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
|
||||
[{ mediaUrl: mediaPath }],
|
||||
{ localRoots: [path.dirname(mediaPath)] },
|
||||
);
|
||||
|
||||
expect(trustedBlocks).toHaveLength(1);
|
||||
expect(trustedBlocks[0]).toMatchObject({
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
kind: "audio",
|
||||
label: path.basename(mediaPath),
|
||||
mimeType: "audio/ogg",
|
||||
url: fs.realpathSync(mediaPath),
|
||||
},
|
||||
});
|
||||
expect(untrustedBlocks).toHaveLength(0);
|
||||
|
||||
const source = trustedBlocks[0]?.type === "attachment" ? trustedBlocks[0].attachment.url : "";
|
||||
testState.gatewayAuth = { mode: "token", token: CONTROL_UI_E2E_TOKEN };
|
||||
await withGatewayServer(
|
||||
async ({ port }) => {
|
||||
const route = `http://127.0.0.1:${port}/__openclaw__/assistant-media`;
|
||||
const sourceParam = encodeURIComponent(source);
|
||||
const metadata = await fetch(`${route}?meta=1&source=${sourceParam}`, {
|
||||
headers: { Authorization: `Bearer ${CONTROL_UI_E2E_TOKEN}` },
|
||||
});
|
||||
expect(metadata.status).toBe(200);
|
||||
const ticket = (await metadata.json()) as {
|
||||
available?: boolean;
|
||||
mediaTicket?: string;
|
||||
};
|
||||
expect(ticket.available).toBe(true);
|
||||
expect(ticket.mediaTicket).toMatch(/^v1\./);
|
||||
|
||||
const withoutTicket = await fetch(`${route}?source=${sourceParam}`);
|
||||
expect(withoutTicket.status).toBe(401);
|
||||
|
||||
const ticketed = await fetch(
|
||||
`${route}?source=${sourceParam}&mediaTicket=${encodeURIComponent(ticket.mediaTicket ?? "")}`,
|
||||
);
|
||||
expect(ticketed.status).toBe(200);
|
||||
expect(ticketed.headers.get("content-type")).toContain("audio/ogg");
|
||||
expect(Buffer.from(await ticketed.arrayBuffer())).toEqual(Buffer.from("voice"));
|
||||
},
|
||||
{
|
||||
serverOptions: {
|
||||
auth: { mode: "token", token: CONTROL_UI_E2E_TOKEN },
|
||||
controlUiEnabled: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (mediaPath) {
|
||||
fs.rmSync(path.dirname(mediaPath), { recursive: true, force: true });
|
||||
}
|
||||
fs.rmSync(prefsPath, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const append = (filePath, value) => {
|
||||
fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`);
|
||||
};
|
||||
|
||||
export default {
|
||||
id: "qa-voice-call-runtime",
|
||||
register(api) {
|
||||
api.registerRealtimeVoiceProvider({
|
||||
id: "qa-voice-call-realtime",
|
||||
label: "QA Voice Call Realtime",
|
||||
isConfigured: () => true,
|
||||
createBrowserSession() {
|
||||
throw new Error("Voice Call fixture is bridge-only");
|
||||
},
|
||||
createBridge(request) {
|
||||
append(process.env.OPENCLAW_QA_VOICE_BRIDGE_CALLS_PATH, {
|
||||
instructions: request.instructions,
|
||||
tools: request.tools?.map((tool) => tool.name) ?? [],
|
||||
});
|
||||
let connected = false;
|
||||
return {
|
||||
supportsToolResultContinuation: true,
|
||||
async connect() {
|
||||
connected = true;
|
||||
request.onReady?.();
|
||||
request.onTranscript?.("user", "Caller partial transcript context", false);
|
||||
setTimeout(() => {
|
||||
request.onToolCall?.({
|
||||
itemId: "qa-consult-item",
|
||||
callId: "qa-consult-call",
|
||||
name: "openclaw_agent_consult",
|
||||
args: {
|
||||
question: "Use the embedded agent context to identify the caller request.",
|
||||
context: "Realtime provider context marker: VOICE-CONSULT-42",
|
||||
},
|
||||
});
|
||||
}, 10);
|
||||
},
|
||||
sendAudio() {},
|
||||
setMediaTimestamp() {},
|
||||
submitToolResult(callId, result, options) {
|
||||
append(process.env.OPENCLAW_QA_VOICE_TOOL_RESULTS_PATH, { callId, result, options });
|
||||
},
|
||||
acknowledgeMark() {},
|
||||
close() {
|
||||
connected = false;
|
||||
},
|
||||
isConnected() {
|
||||
return connected;
|
||||
},
|
||||
triggerGreeting() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
api.registerGatewayMethod("qa.voiceCall.streamSession", async ({ params, respond }) => {
|
||||
const runtime = globalThis[Symbol.for("openclaw.voice-call.runtime")];
|
||||
const callId = typeof params?.callId === "string" ? params.callId : "";
|
||||
const call = runtime?.manager?.getCall?.(callId);
|
||||
const issue = runtime?.manager?.streamSessionIssuer;
|
||||
if (!runtime || !call || typeof issue !== "function" || !call.providerCallId) {
|
||||
respond(false, undefined, {
|
||||
code: "UNAVAILABLE",
|
||||
message: "Voice Call runtime stream issuer unavailable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const session = issue({
|
||||
providerName: "twilio",
|
||||
callId,
|
||||
from: call.from,
|
||||
to: call.to,
|
||||
direction: call.direction,
|
||||
});
|
||||
respond(true, {
|
||||
...session,
|
||||
providerCallId: call.providerCallId,
|
||||
webhookUrl: runtime.webhookUrl,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "qa-voice-call-runtime",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -18,36 +18,6 @@ async function writeEntry(root: string, relativePath: string) {
|
||||
}
|
||||
|
||||
describe("gateway MCP real transport producer", () => {
|
||||
it("uses the source CLI entry when build output is absent", async () => {
|
||||
const root = createRepoRoot();
|
||||
const entryPath = await writeEntry(root, "src/entry.ts");
|
||||
|
||||
const cli = testing.resolveOpenClawCliInvocation(root);
|
||||
|
||||
expect(cli.command).toBe(process.execPath);
|
||||
expect(cli.argsPrefix).toStrictEqual(["--import", "tsx", entryPath]);
|
||||
expect(cli.cwd).toBe(root);
|
||||
expect(cli.gatewayCommand).toMatchObject({
|
||||
executablePath: process.execPath,
|
||||
argsPrefix: ["--import", "tsx", entryPath],
|
||||
cwd: root,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers built package output when it exists", async () => {
|
||||
const root = createRepoRoot();
|
||||
const distEntry = await writeEntry(root, "dist/index.mjs");
|
||||
await writeEntry(root, "src/entry.ts");
|
||||
|
||||
const cli = testing.resolveOpenClawCliInvocation(root);
|
||||
|
||||
expect(cli.argsPrefix).toStrictEqual([distEntry]);
|
||||
expect(cli.gatewayCommand).toMatchObject({
|
||||
argsPrefix: [distEntry],
|
||||
usePackagedPlugins: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the source channel MCP module when build output is absent", async () => {
|
||||
const root = createRepoRoot();
|
||||
const channelServerPath = await writeEntry(root, "src/mcp/channel-server.ts");
|
||||
|
||||
@@ -11,7 +11,6 @@ import { WebSocket, WebSocketServer, type RawData } from "ws";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
startQaGatewayChild,
|
||||
type QaGatewayChildCommand,
|
||||
type QaEvidenceSummaryJson,
|
||||
type QaGatewayChildListeningContext,
|
||||
} from "../../../../extensions/qa-lab/api.js";
|
||||
@@ -58,13 +57,6 @@ type GatewayProxy = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type OpenClawCliInvocation = {
|
||||
argsPrefix: string[];
|
||||
command: string;
|
||||
cwd: string;
|
||||
gatewayCommand: QaGatewayChildCommand;
|
||||
};
|
||||
|
||||
type ChannelMcpInvocation = {
|
||||
args: string[];
|
||||
command: string;
|
||||
@@ -220,50 +212,6 @@ function withFixturePlugin(config: OpenClawConfig, pluginDir: string): OpenClawC
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTransport() {
|
||||
return {
|
||||
requiredPluginIds: [] as string[],
|
||||
createGatewayConfig: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawCliInvocation(repoRoot: string): OpenClawCliInvocation {
|
||||
for (const relativePath of ["dist/index.mjs", "dist/index.js"]) {
|
||||
const entryPath = path.join(repoRoot, relativePath);
|
||||
if (existsSync(entryPath)) {
|
||||
const argsPrefix = [entryPath];
|
||||
return {
|
||||
argsPrefix,
|
||||
command: process.execPath,
|
||||
cwd: repoRoot,
|
||||
gatewayCommand: {
|
||||
executablePath: process.execPath,
|
||||
argsPrefix,
|
||||
cwd: repoRoot,
|
||||
usePackagedPlugins: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sourceEntryPath = path.join(repoRoot, "src/entry.ts");
|
||||
if (existsSync(sourceEntryPath)) {
|
||||
const argsPrefix = ["--import", "tsx", sourceEntryPath];
|
||||
return {
|
||||
argsPrefix,
|
||||
command: process.execPath,
|
||||
cwd: repoRoot,
|
||||
gatewayCommand: {
|
||||
executablePath: process.execPath,
|
||||
argsPrefix,
|
||||
cwd: repoRoot,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts");
|
||||
}
|
||||
|
||||
function resolveChannelMcpInvocation(params: {
|
||||
gatewayToken: string;
|
||||
gatewayUrl: string;
|
||||
@@ -525,8 +473,7 @@ async function approvePendingMcpPairing(gateway: Awaited<ReturnType<typeof start
|
||||
async function runGatewaySmokeProof(options: ProducerOptions): Promise<string> {
|
||||
const gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
|
||||
transport: emptyTransport(),
|
||||
useRepoCli: true,
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
@@ -585,8 +532,7 @@ async function runMcpGatewayStartupRetryProof(options: ProducerOptions): Promise
|
||||
};
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand,
|
||||
transport: emptyTransport(),
|
||||
useRepoCli: true,
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: false,
|
||||
onListening,
|
||||
@@ -813,5 +759,4 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
||||
export const testing = {
|
||||
resolveChannelMcpInvocation,
|
||||
resolveOpenClawCliInvocation,
|
||||
};
|
||||
|
||||
615
test/e2e/qa-lab/runtime/media-talk-gateway.ts
Normal file
615
test/e2e/qa-lab/runtime/media-talk-gateway.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
// QA Lab producer exercises WebChat media delivery and Talk run control through a real Gateway.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
type QaEvidenceSummaryJson,
|
||||
} from "../../../../extensions/qa-lab/src/evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../../../extensions/qa-lab/src/gateway-child.js";
|
||||
import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/src/providers/mock-openai/server.js";
|
||||
import { GatewayClient, type GatewayClientOptions } from "../../../../src/gateway/client.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
type GatewayClientMode,
|
||||
type GatewayClientName,
|
||||
} from "../../../../src/utils/message-channel.js";
|
||||
import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js";
|
||||
|
||||
const FIXTURE_PLUGIN_ID = "qa-media-talk-runtime";
|
||||
const FIXTURE_SPEECH_PROVIDER_ID = "qa-speech";
|
||||
const FIXTURE_REALTIME_PROVIDER_ID = "qa-realtime";
|
||||
const FIXTURE_WAV_BASE64 =
|
||||
"UklGRsQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/runtime/media-talk-gateway.ts";
|
||||
|
||||
type ScenarioId = "webchat-auto-tts" | "active-talk-agent-run-status";
|
||||
|
||||
type ProducerOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
scenarioId: ScenarioId;
|
||||
};
|
||||
|
||||
type ProofResult = {
|
||||
details?: string;
|
||||
durationMs: number;
|
||||
status: QaScriptEvidenceStatus;
|
||||
};
|
||||
|
||||
const SCENARIOS = {
|
||||
"webchat-auto-tts": {
|
||||
title: "WebChat auto TTS delivery",
|
||||
sourcePath: "qa/scenarios/media/webchat-auto-tts.yaml",
|
||||
primaryCoverageIds: ["media.tts", "media.outbound-voice-audio-delivery"],
|
||||
docsRefs: ["docs/tools/tts.md", "docs/tools/media-overview.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"packages/speech-core/src/tts.ts",
|
||||
"src/gateway/server-methods/chat.ts",
|
||||
"src/gateway/control-ui.ts",
|
||||
],
|
||||
},
|
||||
"active-talk-agent-run-status": {
|
||||
title: "Active Talk agent-run control boundaries",
|
||||
sourcePath: "qa/scenarios/runtime/active-talk-agent-run-status.yaml",
|
||||
primaryCoverageIds: ["voice.active-talk-agent-run-status"],
|
||||
docsRefs: ["docs/nodes/talk.md", "docs/web/control-ui.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"src/gateway/server-methods/talk-client.ts",
|
||||
"src/talk/agent-run-control.ts",
|
||||
"src/agents/embedded-agent-runner/runs.ts",
|
||||
],
|
||||
},
|
||||
} as const;
|
||||
|
||||
function parseOptions(argv: readonly string[]): ProducerOptions {
|
||||
const readValue = (name: string) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : undefined;
|
||||
};
|
||||
const scenarioId = readValue("--scenario");
|
||||
if (!scenarioId || !(scenarioId in SCENARIOS)) {
|
||||
throw new Error(`--scenario must be one of: ${Object.keys(SCENARIOS).join(", ")}`);
|
||||
}
|
||||
const artifactBase = readValue("--artifact-base");
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return {
|
||||
artifactBase: path.resolve(artifactBase),
|
||||
repoRoot: path.resolve(readValue("--repo-root") ?? process.cwd()),
|
||||
scenarioId: scenarioId as ScenarioId,
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixturePlugin(root: string) {
|
||||
const pluginDir = path.join(root, FIXTURE_PLUGIN_ID);
|
||||
const speechCallsPath = path.join(root, "speech-calls.jsonl");
|
||||
const realtimeCallsPath = path.join(root, "realtime-calls.jsonl");
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: FIXTURE_PLUGIN_ID,
|
||||
activation: { onStartup: true },
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "index.js"),
|
||||
`const fs = require("node:fs");
|
||||
|
||||
module.exports = {
|
||||
id: ${JSON.stringify(FIXTURE_PLUGIN_ID)},
|
||||
register(api) {
|
||||
api.registerSpeechProvider({
|
||||
id: ${JSON.stringify(FIXTURE_SPEECH_PROVIDER_ID)},
|
||||
label: "QA Speech",
|
||||
autoSelectOrder: 1,
|
||||
isConfigured: () => true,
|
||||
async synthesize(request) {
|
||||
fs.appendFileSync(process.env.OPENCLAW_QA_SPEECH_CALLS_PATH, JSON.stringify({ text: request.text, target: request.target }) + "\\n");
|
||||
return {
|
||||
audioBuffer: Buffer.from(${JSON.stringify(FIXTURE_WAV_BASE64)}, "base64"),
|
||||
fileExtension: ".wav",
|
||||
outputFormat: "wav",
|
||||
voiceCompatible: request.target === "voice-note",
|
||||
};
|
||||
},
|
||||
});
|
||||
api.registerRealtimeVoiceProvider({
|
||||
id: ${JSON.stringify(FIXTURE_REALTIME_PROVIDER_ID)},
|
||||
label: "QA Realtime",
|
||||
isConfigured: () => true,
|
||||
async createBrowserSession(request) {
|
||||
fs.appendFileSync(process.env.OPENCLAW_QA_REALTIME_CALLS_PATH, JSON.stringify({ tools: request.tools?.map((tool) => tool.name) ?? [] }) + "\\n");
|
||||
return {
|
||||
provider: ${JSON.stringify(FIXTURE_REALTIME_PROVIDER_ID)},
|
||||
transport: "provider-websocket",
|
||||
protocol: "google-live-bidi",
|
||||
clientSecret: "qa-browser-token",
|
||||
websocketUrl: "wss://qa.invalid/realtime",
|
||||
audio: {
|
||||
inputEncoding: "pcm16",
|
||||
inputSampleRateHz: 16000,
|
||||
outputEncoding: "pcm16",
|
||||
outputSampleRateHz: 24000,
|
||||
},
|
||||
};
|
||||
},
|
||||
createBridge() {
|
||||
throw new Error("QA browser Talk provider does not create server bridges");
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
return { pluginDir, realtimeCallsPath, speechCallsPath };
|
||||
}
|
||||
|
||||
function withFixturePlugin(config: OpenClawConfig, pluginDir: string): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
enabled: true,
|
||||
allow: [...new Set([...(config.plugins?.allow ?? []), FIXTURE_PLUGIN_ID])],
|
||||
load: {
|
||||
...config.plugins?.load,
|
||||
paths: [...new Set([...(config.plugins?.load?.paths ?? []), pluginDir])],
|
||||
},
|
||||
entries: {
|
||||
...config.plugins?.entries,
|
||||
[FIXTURE_PLUGIN_ID]: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function connectGatewayClient(params: {
|
||||
clientName: GatewayClientName;
|
||||
mode: GatewayClientMode;
|
||||
onEvent?: GatewayClientOptions["onEvent"];
|
||||
token: string;
|
||||
url: string;
|
||||
}) {
|
||||
const gatewayUrl = new URL(params.url);
|
||||
gatewayUrl.protocol = gatewayUrl.protocol === "wss:" ? "https:" : "http:";
|
||||
let resolveHello: (() => void) | undefined;
|
||||
let rejectHello: ((error: Error) => void) | undefined;
|
||||
const hello = new Promise<void>((resolve, reject) => {
|
||||
resolveHello = resolve;
|
||||
rejectHello = reject;
|
||||
});
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
origin: gatewayUrl.origin,
|
||||
token: params.token,
|
||||
clientName: params.clientName,
|
||||
mode: params.mode,
|
||||
role: "operator",
|
||||
scopes: [
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.talk.secrets",
|
||||
],
|
||||
platform: "qa",
|
||||
requestTimeoutMs: 30_000,
|
||||
onEvent: params.onEvent,
|
||||
onHelloOk: () => resolveHello?.(),
|
||||
onConnectError: (error) => rejectHello?.(error),
|
||||
onClose: (code, reason) => rejectHello?.(new Error(`Gateway closed ${code}: ${reason}`)),
|
||||
});
|
||||
client.start();
|
||||
const timer = setTimeout(() => rejectHello?.(new Error("Gateway connect timeout")), 20_000);
|
||||
try {
|
||||
await hello;
|
||||
} catch (error) {
|
||||
client.stop();
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function collectRecords(value: unknown, records: Record<string, unknown>[] = []) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectRecords(entry, records);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return records;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
records.push(record);
|
||||
for (const entry of Object.values(record)) {
|
||||
collectRecords(entry, records);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function findAudioAttachmentSource(value: unknown): string | undefined {
|
||||
return collectRecords(value)
|
||||
.filter((record) => record.kind === "audio")
|
||||
.map((record) => record.url)
|
||||
.find((url): url is string => typeof url === "string" && url.length > 0);
|
||||
}
|
||||
|
||||
async function readJsonLines(filePath: string): Promise<Record<string, unknown>[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async function waitForChatFinal(
|
||||
events: Array<{ event: string; payload?: unknown }>,
|
||||
runId: string,
|
||||
) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const finalEvent = events.find((event) => {
|
||||
if (event.event !== "chat" || !event.payload || typeof event.payload !== "object") {
|
||||
return false;
|
||||
}
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
return payload.runId === runId && payload.state === "final";
|
||||
});
|
||||
if (finalEvent) {
|
||||
return finalEvent.payload;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`timed out waiting for WebChat final event for run ${runId}`);
|
||||
}
|
||||
|
||||
async function waitForWebchatAudio(params: {
|
||||
client: GatewayClient;
|
||||
events: Array<{ event: string; payload?: unknown }>;
|
||||
sessionKey: string;
|
||||
}) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
let history: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
history = await params.client.request("chat.history", {
|
||||
sessionKey: params.sessionKey,
|
||||
limit: 20,
|
||||
});
|
||||
const source = findAudioAttachmentSource(params.events) ?? findAudioAttachmentSource(history);
|
||||
if (source) {
|
||||
return { history, source };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return { history, source: undefined };
|
||||
}
|
||||
|
||||
async function runWebchatAutoTtsProof(options: ProducerOptions): Promise<string> {
|
||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-webchat-tts-"));
|
||||
const fixture = await createFixturePlugin(fixtureRoot);
|
||||
const mock = await startQaMockOpenAiServer();
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let client: GatewayClient | undefined;
|
||||
const events: Array<{ event: string; payload?: unknown }> = [];
|
||||
try {
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: true,
|
||||
runtimeEnvPatch: {
|
||||
OPENCLAW_QA_SPEECH_CALLS_PATH: fixture.speechCallsPath,
|
||||
OPENCLAW_QA_REALTIME_CALLS_PATH: fixture.realtimeCallsPath,
|
||||
},
|
||||
mutateConfig: (config) => {
|
||||
const withPlugin = withFixturePlugin(config, fixture.pluginDir);
|
||||
return {
|
||||
...withPlugin,
|
||||
messages: {
|
||||
...withPlugin.messages,
|
||||
tts: {
|
||||
auto: "always",
|
||||
mode: "final",
|
||||
provider: FIXTURE_SPEECH_PROVIDER_ID,
|
||||
prefsPath: path.join(fixtureRoot, "tts-prefs.json"),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
client = await connectGatewayClient({
|
||||
clientName: GATEWAY_CLIENT_NAMES.WEBCHAT_UI,
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
onEvent: (event) => events.push(event),
|
||||
token: gateway.token,
|
||||
url: gateway.wsUrl,
|
||||
});
|
||||
const sessionKey = "agent:qa:main";
|
||||
const runId = randomUUID();
|
||||
await client.request("chat.send", {
|
||||
sessionKey,
|
||||
message: "block streaming qa check; answer with one short sentence",
|
||||
deliver: false,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
await waitForChatFinal(events, runId);
|
||||
const { history, source } = await waitForWebchatAudio({ client, events, sessionKey });
|
||||
if (!source) {
|
||||
const speechCalls = await readJsonLines(fixture.speechCallsPath);
|
||||
throw new Error(
|
||||
`WebChat history did not contain an audio attachment; speech=${JSON.stringify(speechCalls)}; gateway=${gateway.logs()}; history=${JSON.stringify(history)}`,
|
||||
);
|
||||
}
|
||||
const speechCalls = await readJsonLines(fixture.speechCallsPath);
|
||||
if (speechCalls.length !== 1) {
|
||||
throw new Error(`expected one final-tail TTS synthesis, received ${speechCalls.length}`);
|
||||
}
|
||||
const route = `${gateway.baseUrl}/__openclaw__/assistant-media`;
|
||||
const sourceParam = encodeURIComponent(source);
|
||||
const metadata = await fetch(`${route}?meta=1&source=${sourceParam}`, {
|
||||
headers: { Authorization: `Bearer ${gateway.token}` },
|
||||
});
|
||||
if (!metadata.ok) {
|
||||
throw new Error(`media metadata failed with ${metadata.status}: ${await metadata.text()}`);
|
||||
}
|
||||
const ticket = (await metadata.json()) as { available?: boolean; mediaTicket?: string };
|
||||
if (ticket.available !== true || !ticket.mediaTicket?.startsWith("v1.")) {
|
||||
throw new Error(`media metadata did not mint a scoped ticket: ${JSON.stringify(ticket)}`);
|
||||
}
|
||||
const withoutTicket = await fetch(`${route}?source=${sourceParam}`);
|
||||
if (withoutTicket.status !== 401) {
|
||||
throw new Error(`media route without ticket returned ${withoutTicket.status}, expected 401`);
|
||||
}
|
||||
const ticketed = await fetch(
|
||||
`${route}?source=${sourceParam}&mediaTicket=${encodeURIComponent(ticket.mediaTicket)}`,
|
||||
);
|
||||
const body = Buffer.from(await ticketed.arrayBuffer());
|
||||
if (!ticketed.ok || !ticketed.headers.get("content-type")?.includes("audio/wav")) {
|
||||
throw new Error(`ticketed media failed with ${ticketed.status}`);
|
||||
}
|
||||
if (!body.equals(Buffer.from(FIXTURE_WAV_BASE64, "base64"))) {
|
||||
throw new Error(`ticketed media returned unexpected bytes: ${body.toString("hex")}`);
|
||||
}
|
||||
return `real Gateway pid=${gateway.pid ?? "unknown"}; WebChat history contained trusted audio; syntheses=1; scoped ticket served ${body.length} bytes`;
|
||||
} finally {
|
||||
client?.stop();
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock.stop();
|
||||
await fs.rm(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function assertControlResult(
|
||||
value: unknown,
|
||||
expected: { mode: string; active?: boolean; queued?: boolean; aborted?: boolean },
|
||||
) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`Talk control returned non-object: ${JSON.stringify(value)}`);
|
||||
}
|
||||
const result = value as Record<string, unknown>;
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
if (result[key] !== expectedValue) {
|
||||
throw new Error(`Talk ${expected.mode} returned ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
throw new Error(`Talk ${expected.mode} failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForActiveTalkStatus(client: GatewayClient, sessionKey: string) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const status = await client.request("talk.client.steer", {
|
||||
sessionKey,
|
||||
text: "status",
|
||||
mode: "status",
|
||||
});
|
||||
assertControlResult(status, { mode: "status", active: true });
|
||||
return status;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("timed out waiting for active Talk run");
|
||||
}
|
||||
|
||||
async function waitForQueuedTalkSteer(client: GatewayClient, sessionKey: string) {
|
||||
const deadline = Date.now() + 20_000;
|
||||
let lastResult: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
lastResult = await client.request("talk.client.steer", {
|
||||
sessionKey,
|
||||
text: "use the safer path",
|
||||
mode: "steer",
|
||||
});
|
||||
if (
|
||||
lastResult &&
|
||||
typeof lastResult === "object" &&
|
||||
(lastResult as Record<string, unknown>).queued === true
|
||||
) {
|
||||
return lastResult;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`timed out waiting for steerable Talk run: ${JSON.stringify(lastResult)}`);
|
||||
}
|
||||
|
||||
async function runActiveTalkAgentRunProof(options: ProducerOptions): Promise<string> {
|
||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-talk-"));
|
||||
const fixture = await createFixturePlugin(fixtureRoot);
|
||||
const mock = await startQaMockOpenAiServer({ finalOnlyMarkerPauseMs: 60_000 });
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let client: GatewayClient | undefined;
|
||||
try {
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: true,
|
||||
runtimeEnvPatch: {
|
||||
OPENCLAW_QA_SPEECH_CALLS_PATH: fixture.speechCallsPath,
|
||||
OPENCLAW_QA_REALTIME_CALLS_PATH: fixture.realtimeCallsPath,
|
||||
},
|
||||
mutateConfig: (config) => {
|
||||
const withPlugin = withFixturePlugin(config, fixture.pluginDir);
|
||||
return {
|
||||
...withPlugin,
|
||||
talk: {
|
||||
...withPlugin.talk,
|
||||
realtime: {
|
||||
...withPlugin.talk?.realtime,
|
||||
provider: FIXTURE_REALTIME_PROVIDER_ID,
|
||||
providers: {
|
||||
...withPlugin.talk?.realtime?.providers,
|
||||
[FIXTURE_REALTIME_PROVIDER_ID]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
client = await connectGatewayClient({
|
||||
clientName: GATEWAY_CLIENT_NAMES.WEBCHAT_UI,
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
token: gateway.token,
|
||||
url: gateway.wsUrl,
|
||||
});
|
||||
const sessionKey = "agent:qa:main";
|
||||
const created = await client.request<Record<string, unknown>>("talk.client.create", {
|
||||
sessionKey,
|
||||
provider: FIXTURE_REALTIME_PROVIDER_ID,
|
||||
});
|
||||
if (created.provider !== FIXTURE_REALTIME_PROVIDER_ID) {
|
||||
throw new Error(`Talk client used unexpected provider: ${JSON.stringify(created)}`);
|
||||
}
|
||||
const providerCalls = await readJsonLines(fixture.realtimeCallsPath);
|
||||
const tools = providerCalls[0]?.tools;
|
||||
if (
|
||||
!Array.isArray(tools) ||
|
||||
!tools.includes("openclaw_agent_consult") ||
|
||||
!tools.includes("openclaw_agent_control")
|
||||
) {
|
||||
throw new Error(
|
||||
`Talk provider did not receive consult/control tools: ${JSON.stringify(tools)}`,
|
||||
);
|
||||
}
|
||||
const consultRequest = client.request("talk.client.toolCall", {
|
||||
sessionKey,
|
||||
callId: `qa-talk-${randomUUID()}`,
|
||||
name: "openclaw_agent_consult",
|
||||
args: { question: "final-only marker streaming qa check: inspect the active run" },
|
||||
});
|
||||
const steer = await waitForQueuedTalkSteer(client, sessionKey);
|
||||
assertControlResult(steer, { mode: "steer", active: true, queued: true });
|
||||
await waitForActiveTalkStatus(client, sessionKey);
|
||||
const followup = await client.request("talk.client.steer", {
|
||||
sessionKey,
|
||||
text: "also verify migration cleanup",
|
||||
mode: "followup",
|
||||
});
|
||||
assertControlResult(followup, { mode: "followup", active: true, queued: true });
|
||||
const cancel = await client.request("talk.client.steer", {
|
||||
sessionKey,
|
||||
text: "cancel",
|
||||
mode: "cancel",
|
||||
});
|
||||
assertControlResult(cancel, { mode: "cancel", active: true, aborted: true });
|
||||
await consultRequest;
|
||||
return `real Gateway pid=${gateway.pid ?? "unknown"}; persistent WebChat connection created Talk session and completed status, steer, follow-up, cancel RPCs`;
|
||||
} finally {
|
||||
client?.stop();
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock.stop();
|
||||
await fs.rm(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function produceProof(options: ProducerOptions): Promise<ProofResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const details =
|
||||
options.scenarioId === "webchat-auto-tts"
|
||||
? await runWebchatAutoTtsProof(options)
|
||||
: await runActiveTalkAgentRunProof(options);
|
||||
return { details, durationMs: Math.max(1, Date.now() - startedAt), status: "pass" };
|
||||
} catch (error) {
|
||||
return {
|
||||
details: formatErrorMessage(error),
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMediaTalkGatewayProducer(
|
||||
options: ProducerOptions,
|
||||
): Promise<QaEvidenceSummaryJson> {
|
||||
const scenario = SCENARIOS[options.scenarioId];
|
||||
const writer = createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: `${options.scenarioId}.log`,
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: options.scenarioId,
|
||||
title: scenario.title,
|
||||
sourcePath: scenario.sourcePath,
|
||||
primaryCoverageIds: scenario.primaryCoverageIds,
|
||||
docsRefs: scenario.docsRefs,
|
||||
codeRefs: scenario.codeRefs,
|
||||
},
|
||||
});
|
||||
const result = await produceProof(options);
|
||||
writer.appendLog(`${result.status}: ${result.details ?? "no details"}\n`);
|
||||
return await writer.write(result);
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]) {
|
||||
const options = parseOptions(argv);
|
||||
const evidence = await runMediaTalkGatewayProducer(options);
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
console.log(`Media/Talk Gateway evidence: ${QA_EVIDENCE_FILENAME}`);
|
||||
console.log(`Media/Talk Gateway status: ${status}`);
|
||||
return status === "pass" ? 0 : 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
364
test/e2e/qa-lab/runtime/voice-call-gateway.ts
Normal file
364
test/e2e/qa-lab/runtime/voice-call-gateway.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// QA Lab producer exercises Voice Call CLI, Gateway RPC/tool, webhook, and realtime consult.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
type QaEvidenceSummaryJson,
|
||||
} from "../../../../extensions/qa-lab/src/evidence-summary.js";
|
||||
import { startQaGatewayChild } from "../../../../extensions/qa-lab/src/gateway-child.js";
|
||||
import { startQaMockOpenAiServer } from "../../../../extensions/qa-lab/src/providers/mock-openai/server.js";
|
||||
import { getFreePort } from "../../../../src/test-utils/ports.js";
|
||||
import { createQaScriptEvidenceWriter, type QaScriptEvidenceStatus } from "./script-evidence.js";
|
||||
|
||||
const FIXTURE_PLUGIN_ID = "qa-voice-call-runtime";
|
||||
const FIXTURE_REALTIME_PROVIDER_ID = "qa-voice-call-realtime";
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/runtime/voice-call-gateway.ts";
|
||||
|
||||
type ProducerOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
type ProofResult = {
|
||||
details?: string;
|
||||
durationMs: number;
|
||||
status: QaScriptEvidenceStatus;
|
||||
};
|
||||
|
||||
function parseOptions(argv: readonly string[]): ProducerOptions {
|
||||
const readValue = (name: string) => {
|
||||
const index = argv.indexOf(name);
|
||||
return index >= 0 ? argv[index + 1] : undefined;
|
||||
};
|
||||
const artifactBase = readValue("--artifact-base");
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return {
|
||||
artifactBase: path.resolve(artifactBase),
|
||||
repoRoot: path.resolve(readValue("--repo-root") ?? process.cwd()),
|
||||
};
|
||||
}
|
||||
|
||||
function createFixturePlugin(repoRoot: string, outputRoot: string) {
|
||||
return {
|
||||
pluginDir: path.join(repoRoot, "test/e2e/qa-lab/runtime/fixtures/voice-call-runtime-plugin"),
|
||||
bridgeCallsPath: path.join(outputRoot, "bridge-calls.jsonl"),
|
||||
toolResultsPath: path.join(outputRoot, "tool-results.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
function withVoiceCallConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
pluginDir: string;
|
||||
servePort: number;
|
||||
}): OpenClawConfig {
|
||||
const config = params.config;
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
enabled: true,
|
||||
allow: [...new Set([...(config.plugins?.allow ?? []), "voice-call", FIXTURE_PLUGIN_ID])],
|
||||
load: {
|
||||
...config.plugins?.load,
|
||||
paths: [...new Set([...(config.plugins?.load?.paths ?? []), params.pluginDir])],
|
||||
},
|
||||
entries: {
|
||||
...config.plugins?.entries,
|
||||
"voice-call": {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
provider: "mock",
|
||||
inboundPolicy: "open",
|
||||
maxConcurrentCalls: 4,
|
||||
serve: { port: params.servePort, bind: "127.0.0.1", path: "/voice/webhook" },
|
||||
realtime: {
|
||||
enabled: true,
|
||||
provider: FIXTURE_REALTIME_PROVIDER_ID,
|
||||
streamPath: "/voice/stream/realtime",
|
||||
toolPolicy: "safe-read-only",
|
||||
consultPolicy: "auto",
|
||||
providers: { [FIXTURE_REALTIME_PROVIDER_ID]: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
[FIXTURE_PLUGIN_ID]: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findStringByKey(value: unknown, key: string): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const found = findStringByKey(entry, key);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record[key] === "string") {
|
||||
return record[key];
|
||||
}
|
||||
for (const entry of Object.values(record)) {
|
||||
const found = findStringByKey(entry, key);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function waitForFinalToolResult(filePath: string) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
||||
const entries = raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const final = entries.find(
|
||||
(entry) =>
|
||||
entry.callId === "qa-consult-call" &&
|
||||
entry.options === undefined &&
|
||||
entry.result &&
|
||||
typeof entry.result === "object" &&
|
||||
!Object.hasOwn(entry.result, "status"),
|
||||
);
|
||||
if (final) {
|
||||
return { entries, final };
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("timed out waiting for final Voice Call consult tool result");
|
||||
}
|
||||
|
||||
async function openRealtimeMediaStream(params: {
|
||||
providerCallId: string;
|
||||
servePort: number;
|
||||
streamUrl: string;
|
||||
}) {
|
||||
const streamPath = new URL(params.streamUrl).pathname;
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${params.servePort}${streamPath}`);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once("open", resolve);
|
||||
ws.once("error", reject);
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
event: "start",
|
||||
start: { streamSid: "MZ-qa-voice-call", callSid: params.providerCallId },
|
||||
}),
|
||||
);
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function runVoiceCallProof(options: ProducerOptions): Promise<string> {
|
||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voice-call-gateway-"));
|
||||
const fixture = createFixturePlugin(options.repoRoot, fixtureRoot);
|
||||
const mock = await startQaMockOpenAiServer();
|
||||
const servePort = await getFreePort();
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
let mediaStream: WebSocket | undefined;
|
||||
try {
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot: options.repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
transportBaseUrl: "http://127.0.0.1",
|
||||
controlUiEnabled: false,
|
||||
enabledPluginIds: ["voice-call"],
|
||||
runtimeEnvPatch: {
|
||||
OPENCLAW_QA_VOICE_BRIDGE_CALLS_PATH: fixture.bridgeCallsPath,
|
||||
OPENCLAW_QA_VOICE_TOOL_RESULTS_PATH: fixture.toolResultsPath,
|
||||
},
|
||||
mutateConfig: (config) =>
|
||||
withVoiceCallConfig({ config, pluginDir: fixture.pluginDir, servePort }),
|
||||
});
|
||||
const cliOutput = await gateway.runCli([
|
||||
"voicecall",
|
||||
"start",
|
||||
"--to",
|
||||
"+15550001111",
|
||||
"--message",
|
||||
"CLI fixture",
|
||||
"--mode",
|
||||
"conversation",
|
||||
]);
|
||||
const cliCallId = findStringByKey(JSON.parse(cliOutput), "callId");
|
||||
if (!cliCallId) {
|
||||
throw new Error(`Voice Call CLI did not return a callId: ${cliOutput}`);
|
||||
}
|
||||
const rpc = await gateway.call("voicecall.initiate", {
|
||||
to: "+15550002222",
|
||||
message: "Gateway RPC fixture",
|
||||
mode: "conversation",
|
||||
sessionKey: "agent:main:voice-rpc",
|
||||
});
|
||||
const rpcCallId = findStringByKey(rpc, "callId");
|
||||
if (!rpcCallId) {
|
||||
throw new Error(`voicecall.initiate did not return a callId: ${JSON.stringify(rpc)}`);
|
||||
}
|
||||
const tool = await gateway.call("tools.invoke", {
|
||||
name: "voice_call",
|
||||
sessionKey: "agent:main:requester",
|
||||
args: {
|
||||
action: "initiate_call",
|
||||
to: "+15550003333",
|
||||
message: "Agent tool fixture",
|
||||
mode: "conversation",
|
||||
sessionKey: "agent:main:voice-consult",
|
||||
requesterSessionKey: "agent:main:requester",
|
||||
},
|
||||
});
|
||||
const toolCallId = findStringByKey(tool, "callId");
|
||||
if (!toolCallId) {
|
||||
throw new Error(`tools.invoke voice_call did not return a callId: ${JSON.stringify(tool)}`);
|
||||
}
|
||||
const status = (await gateway.call("voicecall.status", {})) as {
|
||||
calls?: Array<{ providerCallId?: string }>;
|
||||
};
|
||||
if (!Array.isArray(status.calls) || status.calls.length !== 3) {
|
||||
throw new Error(
|
||||
`Voice Call status did not report all three calls: ${JSON.stringify(status)}`,
|
||||
);
|
||||
}
|
||||
const providerCallIds = status.calls.map((call) => call.providerCallId);
|
||||
if (providerCallIds.some((callId) => !callId?.startsWith("mock-"))) {
|
||||
throw new Error(
|
||||
`Voice Call entries did not use the mock provider: ${JSON.stringify(status)}`,
|
||||
);
|
||||
}
|
||||
const stream = (await gateway.call("qa.voiceCall.streamSession", {
|
||||
callId: toolCallId,
|
||||
})) as { providerCallId?: string; streamUrl?: string };
|
||||
if (!stream.providerCallId || !stream.streamUrl) {
|
||||
throw new Error(`Voice Call stream issuer returned invalid data: ${JSON.stringify(stream)}`);
|
||||
}
|
||||
mediaStream = await openRealtimeMediaStream({
|
||||
providerCallId: stream.providerCallId,
|
||||
servePort,
|
||||
streamUrl: stream.streamUrl,
|
||||
});
|
||||
const toolResults = await waitForFinalToolResult(fixture.toolResultsPath);
|
||||
const bridgeCalls = (await fs.readFile(fixture.bridgeCallsPath, "utf8"))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { tools?: string[] });
|
||||
if (!bridgeCalls.some((call) => call.tools?.includes("openclaw_agent_consult"))) {
|
||||
throw new Error(
|
||||
`realtime bridge did not expose embedded consult: ${JSON.stringify(bridgeCalls)}`,
|
||||
);
|
||||
}
|
||||
const requests = (await fetch(`${mock.baseUrl}/debug/requests`).then((response) =>
|
||||
response.json(),
|
||||
)) as Array<{ allInputText?: string; instructions?: string }>;
|
||||
const consultRequest = requests.find((request) =>
|
||||
request.allInputText?.includes("VOICE-CONSULT-42"),
|
||||
);
|
||||
if (!consultRequest) {
|
||||
throw new Error(
|
||||
`embedded consult request did not include provider context: ${JSON.stringify(requests)}`,
|
||||
);
|
||||
}
|
||||
const promptText = `${consultRequest.instructions ?? ""}\n${consultRequest.allInputText ?? ""}`;
|
||||
for (const marker of [
|
||||
"live phone call",
|
||||
"Caller partial transcript context",
|
||||
"VOICE-CONSULT-42",
|
||||
"Use the embedded agent context",
|
||||
]) {
|
||||
if (!promptText.includes(marker)) {
|
||||
throw new Error(`embedded consult prompt missed ${marker}: ${promptText}`);
|
||||
}
|
||||
}
|
||||
return `real CLI, voicecall.initiate, and tools.invoke created ${status.calls.length} mock-provider calls; runtime-issued media stream invoked embedded consult with transcript/provider context; tool results=${toolResults.entries.length}`;
|
||||
} finally {
|
||||
if (mediaStream && mediaStream.readyState < WebSocket.CLOSING) {
|
||||
mediaStream.close();
|
||||
}
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock.stop();
|
||||
await fs.rm(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function produceProof(options: ProducerOptions): Promise<ProofResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return {
|
||||
details: await runVoiceCallProof(options),
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
details: formatErrorMessage(error),
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runVoiceCallGatewayProducer(
|
||||
options: ProducerOptions,
|
||||
): Promise<QaEvidenceSummaryJson> {
|
||||
const writer = createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "voice-call-cli-rpc-agent-tool.log",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: "voice-call-cli-rpc-agent-tool",
|
||||
title: "Voice Call CLI, RPC, and agent tool flow",
|
||||
sourcePath: "qa/scenarios/plugins/voice-call-cli-rpc-agent-tool.yaml",
|
||||
primaryCoverageIds: ["voice-call.cli-rpc-agent-tool"],
|
||||
docsRefs: ["docs/cli/voicecall.md", "docs/plugins/voice-call.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"extensions/voice-call/index.ts",
|
||||
"extensions/voice-call/src/runtime.ts",
|
||||
"extensions/voice-call/src/webhook/realtime-handler.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = await produceProof(options);
|
||||
writer.appendLog(`${result.status}: ${result.details ?? "no details"}\n`);
|
||||
return await writer.write(result);
|
||||
}
|
||||
|
||||
async function main(argv: readonly string[]) {
|
||||
const options = parseOptions(argv);
|
||||
const evidence = await runVoiceCallGatewayProducer(options);
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
console.log(`Voice Call Gateway evidence: ${QA_EVIDENCE_FILENAME}`);
|
||||
console.log(`Voice Call Gateway status: ${status}`);
|
||||
return status === "pass" ? 0 : 1;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// QA Talk E2E tests cover provider session creation and active-run voice controls.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { talkHandlers } from "../../../../src/gateway/server-methods/talk.ts";
|
||||
import { createPluginRecord } from "../../../../src/plugins/loader-records.ts";
|
||||
import { createPluginRegistry } from "../../../../src/plugins/registry.ts";
|
||||
import {
|
||||
resetPluginRuntimeStateForTest,
|
||||
setActivePluginRegistry,
|
||||
} from "../../../../src/plugins/runtime.ts";
|
||||
import { controlRealtimeVoiceAgentRun } from "../../../../src/talk/agent-run-control.ts";
|
||||
import type { TalkEvent } from "../../../../src/talk/talk-events.ts";
|
||||
|
||||
const noopLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
function installMockRealtimeProvider() {
|
||||
const registry = createPluginRegistry({
|
||||
logger: noopLogger,
|
||||
runtime: {},
|
||||
activateGlobalSideEffects: false,
|
||||
});
|
||||
const record = createPluginRecord({
|
||||
id: "qa-talk-realtime",
|
||||
name: "QA Talk Realtime",
|
||||
source: "test/e2e/qa-lab/voice/active-talk-agent-run-status.e2e.test.ts",
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
configSchema: false,
|
||||
});
|
||||
const createBrowserSession = vi.fn(async () => ({
|
||||
provider: "qa-realtime",
|
||||
transport: "provider-websocket" as const,
|
||||
protocol: "google-live-bidi" as const,
|
||||
clientSecret: "auth_tokens/qa-talk",
|
||||
websocketUrl:
|
||||
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained",
|
||||
audio: {
|
||||
inputEncoding: "pcm16" as const,
|
||||
inputSampleRateHz: 16_000,
|
||||
outputEncoding: "pcm16" as const,
|
||||
outputSampleRateHz: 24_000,
|
||||
},
|
||||
}));
|
||||
registry.registerRealtimeVoiceProvider(record, {
|
||||
id: "qa-realtime",
|
||||
label: "QA Realtime",
|
||||
isConfigured: () => true,
|
||||
createBrowserSession,
|
||||
createBridge: vi.fn(),
|
||||
});
|
||||
setActivePluginRegistry(registry.registry);
|
||||
return createBrowserSession;
|
||||
}
|
||||
|
||||
function createControlDeps() {
|
||||
return {
|
||||
abortEmbeddedAgentRun: vi.fn(() => true),
|
||||
queueEmbeddedAgentMessageWithOutcomeAsync: vi.fn(async (sessionId: string) => ({
|
||||
queued: true as const,
|
||||
sessionId,
|
||||
target: "embedded_run" as const,
|
||||
gatewayHealth: "live" as const,
|
||||
enqueuedAtMs: 123,
|
||||
})),
|
||||
getDiagnosticSessionActivitySnapshot: vi.fn(() => ({
|
||||
activeWorkKind: "embedded_run" as const,
|
||||
hasActiveEmbeddedRun: true,
|
||||
})),
|
||||
resolveActiveEmbeddedRunSessionId: vi.fn(() => "session-active"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("QA active Talk agent-run status", () => {
|
||||
afterEach(() => {
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
it("creates a mock realtime session and controls one active agent run", async () => {
|
||||
const createBrowserSession = installMockRealtimeProvider();
|
||||
const respond = vi.fn();
|
||||
await talkHandlers["talk.client.create"]({
|
||||
req: { type: "req", id: "create", method: "talk.client.create" },
|
||||
params: { sessionKey: "agent:main:main", provider: "qa-realtime" },
|
||||
client: { connId: "qa-talk" },
|
||||
isWebchatConnect: () => true,
|
||||
respond,
|
||||
context: {
|
||||
getRuntimeConfig: () =>
|
||||
({
|
||||
talk: {
|
||||
realtime: {
|
||||
provider: "qa-realtime",
|
||||
providers: { "qa-realtime": {} },
|
||||
},
|
||||
},
|
||||
}) satisfies OpenClawConfig,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(createBrowserSession).toHaveBeenCalledTimes(1);
|
||||
expect(createBrowserSession.mock.calls[0]?.[0]).toMatchObject({
|
||||
tools: [
|
||||
expect.objectContaining({ name: "openclaw_agent_consult" }),
|
||||
expect.objectContaining({ name: "openclaw_agent_control" }),
|
||||
],
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ provider: "qa-realtime", transport: "provider-websocket" }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const deps = createControlDeps();
|
||||
const recentEvents = [
|
||||
{
|
||||
id: "event-1",
|
||||
type: "tool.progress",
|
||||
sessionId: "talk-1",
|
||||
seq: 1,
|
||||
timestamp: new Date(0).toISOString(),
|
||||
mode: "realtime",
|
||||
transport: "provider-websocket",
|
||||
brain: "agent-consult",
|
||||
payload: { name: "exec_command", phase: "running" },
|
||||
} satisfies TalkEvent,
|
||||
];
|
||||
|
||||
await expect(
|
||||
controlRealtimeVoiceAgentRun(
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
text: "status",
|
||||
mode: "status",
|
||||
recentEvents,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
active: true,
|
||||
message: "OpenClaw is working in exec_command (running).",
|
||||
});
|
||||
|
||||
await expect(
|
||||
controlRealtimeVoiceAgentRun(
|
||||
{ sessionKey: "agent:main:main", text: "use the safer path", mode: "steer" },
|
||||
deps,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, mode: "steer", queued: true });
|
||||
await expect(
|
||||
controlRealtimeVoiceAgentRun(
|
||||
{ sessionKey: "agent:main:main", text: "also check migration", mode: "followup" },
|
||||
deps,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, mode: "followup", queued: true });
|
||||
expect(deps.queueEmbeddedAgentMessageWithOutcomeAsync.mock.calls[1]?.[1]).toContain(
|
||||
"Spoken follow-up for the current voice call.",
|
||||
);
|
||||
|
||||
await expect(
|
||||
controlRealtimeVoiceAgentRun(
|
||||
{ sessionKey: "agent:main:main", text: "cancel", mode: "cancel" },
|
||||
deps,
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, mode: "cancel", aborted: true });
|
||||
expect(deps.abortEmbeddedAgentRun).toHaveBeenCalledWith("session-active");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user