mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:21:34 +00:00
Merge origin/main into fix(openai): retain gateway startup audio
* origin/main: fix(whatsapp): normalize future-proof QA poll and video note ingress (#117579) fix(perf): separate warm and first-device health probes (#117525) fix(ci): repair plugin prerelease validation (#117562) test(openai): parse realtime websocket frames safely test(ui): cover reentrant Talk meter cleanup fix(ui): reclaim reentrant Talk audio meters fix(openai): own queued realtime audio buffer snapshots
This commit is contained in:
3
.github/workflows/openclaw-performance.yml
vendored
3
.github/workflows/openclaw-performance.yml
vendored
@@ -807,7 +807,8 @@ jobs:
|
||||
|
||||
OPENCLAW_HOME="$gateway_home" OPENCLAW_STATE_DIR="$gateway_state" OPENCLAW_CONFIG_PATH="$gateway_config" OPENCLAW_GATEWAY_PORT="$gateway_port" \
|
||||
node --import tsx scripts/bench-cli-startup.ts \
|
||||
--case gatewayHealthJson \
|
||||
--case gatewayHealthJsonConnected \
|
||||
--case gatewayHealthJsonFirstDevice \
|
||||
--case configGetGatewayPort \
|
||||
--runs "$source_runs" \
|
||||
--warmup 1 \
|
||||
|
||||
132
extensions/openai/realtime-audio-buffer-ownership.test.ts
Normal file
132
extensions/openai/realtime-audio-buffer-ownership.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { once } from "node:events";
|
||||
import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import WebSocket, { type RawData, WebSocketServer } from "ws";
|
||||
import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js";
|
||||
import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
|
||||
|
||||
type RealtimeProviderKind = "native" | "gpt-live";
|
||||
|
||||
function parseWebSocketMessage(data: RawData): Record<string, unknown> {
|
||||
const bytes = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data)
|
||||
: Buffer.from(data);
|
||||
return JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function withRealtimeProvider(
|
||||
kind: RealtimeProviderKind,
|
||||
prepareAudio: (bridge: RealtimeVoiceBridge) => void,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected an available local realtime WebSocket address");
|
||||
}
|
||||
const received: Array<Record<string, unknown>> = [];
|
||||
server.once("connection", (socket) => {
|
||||
socket.on("message", (payload) => {
|
||||
const event = parseWebSocketMessage(payload);
|
||||
received.push(event);
|
||||
if (event.type === "session.update") {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
kind === "native"
|
||||
? { type: "session.updated" }
|
||||
: {
|
||||
type: "session.started",
|
||||
session: { id: "fixture-live", expires_at: Math.floor(Date.now() / 1000) + 60 },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const endpoint = `http://127.0.0.1:${address.port}`;
|
||||
const bridge =
|
||||
kind === "native"
|
||||
? buildOpenAIRealtimeVoiceProvider().createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "fixture-local", // pragma: allowlist secret
|
||||
azureEndpoint: endpoint,
|
||||
azureDeployment: "fixture-realtime",
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
})
|
||||
: new OpenAIQuicksilverVoiceBridge({
|
||||
providerConfig: {},
|
||||
model: "gpt-live-1-codex",
|
||||
audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
resolveAuth: async () => ({ type: "api-key", token: "fixture-local" }),
|
||||
webSocketFactory: (_url, options) => new WebSocket(endpoint, options),
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
prepareAudio(bridge);
|
||||
await bridge.connect();
|
||||
await vi.waitFor(() => {
|
||||
expect(received.some((event) => event.type === audioEventType)).toBe(true);
|
||||
});
|
||||
return received;
|
||||
} finally {
|
||||
bridge.close();
|
||||
for (const client of server.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAI realtime queued audio buffer ownership", () => {
|
||||
it.each<RealtimeProviderKind>(["native", "gpt-live"])(
|
||||
"%s preserves each reusable producer frame until the real WebSocket is ready",
|
||||
async (kind) => {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const received = await withRealtimeProvider(kind, (bridge) => {
|
||||
const producerAllocation = Buffer.alloc(2 * 1024 * 1024, 0x7f);
|
||||
const producerView = producerAllocation.subarray(0, 1);
|
||||
bridge.sendAudio(producerView);
|
||||
producerAllocation[0] = 0x41;
|
||||
bridge.sendAudio(producerView);
|
||||
producerAllocation[0] = 0;
|
||||
});
|
||||
|
||||
expect(received.filter((event) => event.type === audioEventType)).toEqual([
|
||||
{ type: audioEventType, audio: "fw==" },
|
||||
{ type: audioEventType, audio: "QQ==" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<RealtimeProviderKind>(["native", "gpt-live"])(
|
||||
"%s rejects oversized producer frames before allocating a queued copy",
|
||||
async (kind) => {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const received = await withRealtimeProvider(kind, (bridge) => {
|
||||
const oversized = Buffer.alloc(1024 * 1024 + 1);
|
||||
const copyBuffer = vi.spyOn(Buffer, "from");
|
||||
try {
|
||||
bridge.sendAudio(oversized);
|
||||
expect(copyBuffer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
copyBuffer.mockRestore();
|
||||
}
|
||||
bridge.sendAudio(Buffer.from([0x7f]));
|
||||
});
|
||||
|
||||
expect(received.filter((event) => event.type === audioEventType)).toEqual([
|
||||
{ type: audioEventType, audio: "fw==" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -568,8 +568,10 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingAudio.push(audio);
|
||||
this.pendingAudioBytes += audio.byteLength;
|
||||
// Capture transports can recycle caller-owned views before the provider becomes ready.
|
||||
const queuedAudio = Buffer.from(audio);
|
||||
this.pendingAudio.push(queuedAudio);
|
||||
this.pendingAudioBytes += queuedAudio.byteLength;
|
||||
}
|
||||
|
||||
private resetTerminalState(): void {
|
||||
|
||||
@@ -1684,8 +1684,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingAudio.push(audio);
|
||||
this.pendingAudioBytes += audio.byteLength;
|
||||
// Capture transports can recycle caller-owned views before the provider becomes ready.
|
||||
const queuedAudio = Buffer.from(audio);
|
||||
this.pendingAudio.push(queuedAudio);
|
||||
this.pendingAudioBytes += queuedAudio.byteLength;
|
||||
}
|
||||
|
||||
private clearPendingAudio(): void {
|
||||
|
||||
@@ -462,6 +462,100 @@ describe("startWhatsAppQaDriverSession", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
...[
|
||||
{ name: "captionless video note", message: { ptvMessage: {} } },
|
||||
{
|
||||
name: "ephemeral captionless video note",
|
||||
message: { ephemeralMessage: { message: { ptvMessage: {} } } },
|
||||
},
|
||||
{
|
||||
name: "edited captionless video note",
|
||||
message: { editedMessage: { message: { ptvMessage: {} } } },
|
||||
},
|
||||
].map(({ name, message }) => ({
|
||||
name,
|
||||
message,
|
||||
expected: { kind: "media", mediaType: "video/mp4", text: "" },
|
||||
})),
|
||||
...[
|
||||
"pollCreationMessage",
|
||||
"pollCreationMessageV2",
|
||||
"pollCreationMessageV3",
|
||||
"pollCreationMessageV5",
|
||||
].flatMap((pollKey) => {
|
||||
const poll = {
|
||||
[pollKey]: {
|
||||
name: "Choose a time",
|
||||
options: [{ optionName: "Morning" }, { optionName: "Afternoon" }],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
kind: "poll",
|
||||
poll: { question: "Choose a time", options: ["Morning", "Afternoon"] },
|
||||
};
|
||||
return [
|
||||
{ name: pollKey, message: poll, expected },
|
||||
{
|
||||
name: `ephemeral ${pollKey}`,
|
||||
message: { ephemeralMessage: { message: poll } },
|
||||
expected,
|
||||
},
|
||||
];
|
||||
}),
|
||||
...["pollCreationMessageV3", "pollCreationMessageV5"].flatMap((pollKey) => {
|
||||
const poll = {
|
||||
[pollKey]: {
|
||||
name: "Choose a time",
|
||||
options: [{ optionName: "Morning" }, { optionName: "Afternoon" }],
|
||||
},
|
||||
};
|
||||
const wrappedPoll = { pollCreationMessageV4: { message: poll } };
|
||||
const expected = {
|
||||
kind: "poll",
|
||||
poll: { question: "Choose a time", options: ["Morning", "Afternoon"] },
|
||||
};
|
||||
return [
|
||||
{ name: `future-proof version-4 ${pollKey}`, message: wrappedPoll, expected },
|
||||
{
|
||||
name: `ephemeral future-proof version-4 ${pollKey}`,
|
||||
message: { ephemeralMessage: { message: wrappedPoll } },
|
||||
expected,
|
||||
},
|
||||
{ name: `edited ${pollKey}`, message: { editedMessage: { message: poll } }, expected },
|
||||
];
|
||||
}),
|
||||
])("resolves live ingress waiters for $name", async ({ message, expected }) => {
|
||||
const sock = createMockSocket();
|
||||
mocks.createWaSocket.mockResolvedValue(sock);
|
||||
mocks.waitForWaConnection.mockResolvedValue(undefined);
|
||||
mocks.jidToE164.mockReturnValue("+15551234567");
|
||||
|
||||
const session = await startWhatsAppQaDriverSession({
|
||||
authDir: "/tmp/openclaw-whatsapp-auth",
|
||||
});
|
||||
|
||||
try {
|
||||
const observed = session.waitForMessage({
|
||||
timeoutMs: 150,
|
||||
match: (candidate) => candidate.kind === expected.kind,
|
||||
});
|
||||
sock.ev.emit("messages.upsert", {
|
||||
messages: [
|
||||
{
|
||||
key: { fromMe: false, id: "observed-message", remoteJid: "12345@lid" },
|
||||
message,
|
||||
} as WAMessage,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(observed).resolves.toMatchObject(expected);
|
||||
expect(session.getObservedMessages()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => {
|
||||
const sock = createMockSocket();
|
||||
mocks.createWaSocket.mockResolvedValue(sock);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Whatsapp plugin module implements qa driver behavior.
|
||||
import type { ConnectionState, proto, WAMessage } from "baileys";
|
||||
import { getContentType, type ConnectionState, type proto, type WAMessage } from "baileys";
|
||||
import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
describeReplyContext,
|
||||
@@ -180,19 +180,10 @@ function findMessageSection(
|
||||
if (current.depth >= 4) {
|
||||
continue;
|
||||
}
|
||||
for (const wrapperName of [
|
||||
"botInvokeMessage",
|
||||
"documentWithCaptionMessage",
|
||||
"ephemeralMessage",
|
||||
"groupMentionedMessage",
|
||||
"viewOnceMessage",
|
||||
"viewOnceMessageV2",
|
||||
"viewOnceMessageV2Extension",
|
||||
]) {
|
||||
const wrapper = current.value[wrapperName];
|
||||
if (isRecord(wrapper) && isRecord(wrapper.message)) {
|
||||
queue.push({ depth: current.depth + 1, value: wrapper.message });
|
||||
}
|
||||
const contentType = getContentType(current.value as proto.IMessage);
|
||||
const wrapper = contentType ? current.value[contentType] : undefined;
|
||||
if (isRecord(wrapper) && isRecord(wrapper.message)) {
|
||||
queue.push({ depth: current.depth + 1, value: wrapper.message });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -218,6 +209,7 @@ function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined {
|
||||
"pollCreationMessage",
|
||||
"pollCreationMessageV2",
|
||||
"pollCreationMessageV3",
|
||||
"pollCreationMessageV5",
|
||||
]);
|
||||
if (!poll) {
|
||||
return undefined;
|
||||
@@ -244,6 +236,7 @@ function readMedia(message: unknown):
|
||||
const mediaSections = [
|
||||
"imageMessage",
|
||||
"videoMessage",
|
||||
"ptvMessage",
|
||||
"audioMessage",
|
||||
"documentMessage",
|
||||
"stickerMessage",
|
||||
|
||||
@@ -12,6 +12,7 @@ type CommandCase = {
|
||||
name: string;
|
||||
args: string[];
|
||||
presets: readonly string[];
|
||||
stateScope?: "case" | "sample";
|
||||
expectedExitCodes?: readonly number[];
|
||||
expectedNonzeroOutputIncludes?: readonly string[];
|
||||
firstOutputBudgetMs?: number;
|
||||
@@ -444,6 +445,19 @@ const COMMAND_CASES: readonly CommandCase[] = [
|
||||
expectedExitCodes: [0, 1],
|
||||
expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonConnected",
|
||||
name: "gateway health --json (connected)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
stateScope: "case",
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonFirstDevice",
|
||||
name: "gateway health --json (first device)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{
|
||||
id: "configGetGatewayPort",
|
||||
name: "config get gateway.port",
|
||||
@@ -649,6 +663,8 @@ function buildConfigFixture(commandCase: CommandCase): Record<string, unknown> |
|
||||
if (
|
||||
commandCase.id !== "configGetGatewayPort" &&
|
||||
commandCase.id !== "gatewayHealthJson" &&
|
||||
commandCase.id !== "gatewayHealthJsonConnected" &&
|
||||
commandCase.id !== "gatewayHealthJsonFirstDevice" &&
|
||||
commandCase.id !== "health" &&
|
||||
commandCase.id !== "healthJson"
|
||||
) {
|
||||
@@ -717,8 +733,10 @@ async function runSample(params: {
|
||||
cpuProfDir?: string;
|
||||
heapProfDir?: string;
|
||||
rssHookPath: string;
|
||||
runRoot?: string;
|
||||
}): Promise<Sample> {
|
||||
const runRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"));
|
||||
const runRoot = params.runRoot ?? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"));
|
||||
const ownsRunRoot = params.runRoot == null;
|
||||
const stateDir = path.join(runRoot, ".openclaw");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const configFixture = buildConfigFixture(params.commandCase);
|
||||
@@ -849,7 +867,9 @@ async function runSample(params: {
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
if (ownsRunRoot) {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,14 +959,24 @@ async function runCase(params: {
|
||||
}): Promise<Sample[]> {
|
||||
const samples: Sample[] = [];
|
||||
const totalRuns = params.warmup + params.runs;
|
||||
for (let i = 0; i < totalRuns; i += 1) {
|
||||
const sample = await runSample(params);
|
||||
if (i < params.warmup) {
|
||||
continue;
|
||||
const caseRunRoot =
|
||||
params.commandCase.stateScope === "case"
|
||||
? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"))
|
||||
: undefined;
|
||||
try {
|
||||
for (let i = 0; i < totalRuns; i += 1) {
|
||||
const sample = await runSample({ ...params, runRoot: caseRunRoot });
|
||||
if (i < params.warmup) {
|
||||
continue;
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
} finally {
|
||||
if (caseRunRoot) {
|
||||
rmSync(caseRunRoot, { recursive: true, force: true });
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function tailLines(value: string, maxLines: number): string {
|
||||
|
||||
9
scripts/lib/state-schema-inline-plugin.d.mts
Normal file
9
scripts/lib/state-schema-inline-plugin.d.mts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME: string;
|
||||
|
||||
export function createStateSchemaInlinePlugin(rootDir?: string): {
|
||||
name: string;
|
||||
load(
|
||||
this: { addWatchFile(id: string): void },
|
||||
id: string,
|
||||
): { code: string; moduleType: "js" } | null;
|
||||
};
|
||||
40
scripts/lib/state-schema-inline-plugin.mjs
Normal file
40
scripts/lib/state-schema-inline-plugin.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas";
|
||||
|
||||
const STATE_SCHEMA_MODULES = [
|
||||
{
|
||||
modulePath: "src/state/openclaw-state-schema.ts",
|
||||
schemaPath: "src/state/openclaw-state-schema.sql",
|
||||
exportName: "OPENCLAW_STATE_SCHEMA_SQL",
|
||||
},
|
||||
{
|
||||
modulePath: "src/state/openclaw-agent-schema.ts",
|
||||
schemaPath: "src/state/openclaw-agent-schema.sql",
|
||||
exportName: "OPENCLAW_AGENT_SCHEMA_SQL",
|
||||
},
|
||||
];
|
||||
|
||||
/** Inline canonical schema bytes so bundled consumers need no SQL asset. */
|
||||
export function createStateSchemaInlinePlugin(rootDir = process.cwd()) {
|
||||
const schemasByModulePath = new Map(
|
||||
STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]),
|
||||
);
|
||||
|
||||
return {
|
||||
name: STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
load(id) {
|
||||
const schema = schemasByModulePath.get(path.resolve(id));
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const schemaPath = path.resolve(rootDir, schema.schemaPath);
|
||||
this.addWatchFile(schemaPath);
|
||||
return {
|
||||
code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`,
|
||||
moduleType: "js",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,8 +80,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./plugin-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
|
||||
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
|
||||
|
||||
@@ -61,7 +61,7 @@ vi.mock("./active-runtime-registry.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-registry.js", () => ({
|
||||
vi.mock("./plugin-registry-snapshot.js", () => ({
|
||||
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
|
||||
loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata,
|
||||
}));
|
||||
|
||||
@@ -16,8 +16,8 @@ import { resetPluginRuntimeStateForTest } from "./runtime.js";
|
||||
const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn());
|
||||
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./plugin-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
|
||||
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshotWithMetadata: (params: unknown) =>
|
||||
|
||||
@@ -104,9 +104,10 @@ function createRuntimeWebFetchProvider() {
|
||||
|
||||
describe("resolvePluginWebFetchProviders", () => {
|
||||
beforeAll(async () => {
|
||||
vi.doMock("./plugin-registry.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./plugin-registry.js")>("./plugin-registry.js");
|
||||
vi.doMock("./plugin-registry-snapshot.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./plugin-registry-snapshot.js")>(
|
||||
"./plugin-registry-snapshot.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshotWithMetadata: () => ({
|
||||
|
||||
@@ -462,6 +462,18 @@ describe("bench-cli-startup", () => {
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: ["real"],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonConnected",
|
||||
name: "gateway health --json (connected)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonFirstDevice",
|
||||
name: "gateway health --json (first device)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{ id: "health", name: "health", args: ["health"], presets: ["startup", "real"] },
|
||||
{
|
||||
id: "healthJson",
|
||||
@@ -485,16 +497,22 @@ describe("bench-cli-startup", () => {
|
||||
expect(testing.parseGatewayPortEnv("::1")).toBe(32123);
|
||||
expect(testing.parseGatewayPortEnv("[::1]")).toBe(32123);
|
||||
|
||||
expect(
|
||||
withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () =>
|
||||
testing.buildConfigFixture({
|
||||
id: "gatewayHealthJson",
|
||||
name: "gateway health --json",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: ["real"],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ gateway: { port: 45678 } });
|
||||
for (const id of [
|
||||
"gatewayHealthJson",
|
||||
"gatewayHealthJsonConnected",
|
||||
"gatewayHealthJsonFirstDevice",
|
||||
]) {
|
||||
expect(
|
||||
withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () =>
|
||||
testing.buildConfigFixture({
|
||||
id,
|
||||
name: "gateway health --json",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ gateway: { port: 45678 } });
|
||||
}
|
||||
|
||||
for (const invalid of ["45678abc", "127.0.0.1:45678abc"]) {
|
||||
expect(() =>
|
||||
|
||||
@@ -34,6 +34,109 @@ describe("CLI startup benchmark script spawners", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses warmed state for gateway health while isolating first-device samples", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-state-scope-test-"));
|
||||
try {
|
||||
const fixturePath = path.join(tmpDir, "record-home.mjs");
|
||||
const homeLogPath = path.join(tmpDir, "homes.log");
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'import { appendFileSync } from "node:fs";',
|
||||
"appendFileSync(process.env.OPENCLAW_BENCH_HOME_LOG, `${process.env.HOME}\\n`);",
|
||||
"console.log('{\"ok\":true}');",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const runCase = (caseId: string) => {
|
||||
fs.rmSync(homeLogPath, { force: true });
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-cli-startup.ts",
|
||||
"--entry",
|
||||
fixturePath,
|
||||
"--case",
|
||||
caseId,
|
||||
"--runs",
|
||||
"2",
|
||||
"--warmup",
|
||||
"1",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BENCH_HOME_LOG: homeLogPath,
|
||||
},
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return fs.readFileSync(homeLogPath, "utf8").trim().split("\n");
|
||||
};
|
||||
|
||||
const warmedHomes = runCase("gatewayHealthJsonConnected");
|
||||
expect(warmedHomes).toHaveLength(3);
|
||||
expect(new Set(warmedHomes).size).toBe(1);
|
||||
expect(warmedHomes.every((home) => !fs.existsSync(home))).toBe(true);
|
||||
|
||||
for (const caseId of ["gatewayHealthJson", "gatewayHealthJsonFirstDevice"]) {
|
||||
const sampleHomes = runCase(caseId);
|
||||
expect(sampleHomes).toHaveLength(3);
|
||||
expect(new Set(sampleHomes).size).toBe(3);
|
||||
expect(sampleHomes.every((home) => !fs.existsSync(home))).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires connected gateway health probes to exit successfully", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-connected-test-"));
|
||||
try {
|
||||
const fixturePath = path.join(tmpDir, "transport-error.mjs");
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'console.log(\'{"ok":false,"gateway_transport_error":"closed"}\');',
|
||||
"process.exitCode = 1;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const runCase = (caseId: string) =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-cli-startup.ts",
|
||||
"--entry",
|
||||
fixturePath,
|
||||
"--case",
|
||||
caseId,
|
||||
"--runs",
|
||||
"1",
|
||||
"--warmup",
|
||||
"0",
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(runCase("gatewayHealthJson").status).toBe(0);
|
||||
for (const caseId of ["gatewayHealthJsonConnected", "gatewayHealthJsonFirstDevice"]) {
|
||||
const result = runCase(caseId);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`${caseId} sample 1: exited with code 1`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require unrelated fixture cases for a narrowed preset", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-budget-test-"));
|
||||
try {
|
||||
|
||||
@@ -254,6 +254,13 @@ describe("OpenClaw performance workflow", () => {
|
||||
expect(run.indexOf(probeCap)).toBeLessThan(run.indexOf(boundedProbe));
|
||||
});
|
||||
|
||||
it("measures warmed and first-device gateway health separately", () => {
|
||||
const run = findStep("Run OpenClaw source performance probes", "source_performance").run ?? "";
|
||||
|
||||
expect(run).toContain("--case gatewayHealthJsonConnected \\");
|
||||
expect(run).toContain("--case gatewayHealthJsonFirstDevice \\");
|
||||
});
|
||||
|
||||
it("isolates required publication in a fresh artifact-consuming job", () => {
|
||||
const workflow = readWorkflow();
|
||||
const publisher = workflow.jobs?.publish;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" };
|
||||
import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs";
|
||||
import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" };
|
||||
import { createStateSchemaInlinePlugin } from "../../scripts/lib/state-schema-inline-plugin.mjs";
|
||||
import {
|
||||
detectVitestHostInfo as detectVitestHostInfoImpl,
|
||||
isCiLikeEnv,
|
||||
@@ -158,6 +159,7 @@ if (!isCI && localScheduling.throttledBySystem && shouldPrintVitestThrottle(proc
|
||||
export const sharedVitestConfig = {
|
||||
root: repoRoot,
|
||||
envDir: false as const,
|
||||
plugins: [createStateSchemaInlinePlugin(repoRoot)],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
pluginSdkEntrypoints,
|
||||
productionPluginSdkEntrypoints,
|
||||
} from "./scripts/lib/plugin-sdk-entries.mjs";
|
||||
import {
|
||||
createStateSchemaInlinePlugin,
|
||||
STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
} from "./scripts/lib/state-schema-inline-plugin.mjs";
|
||||
import {
|
||||
TSDOWN_PACKAGE_CONFIG_GROUP,
|
||||
TSDOWN_UNIFIED_CONFIG_GROUP,
|
||||
@@ -46,43 +50,7 @@ const env = {
|
||||
const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1";
|
||||
const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1";
|
||||
const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD;
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas";
|
||||
|
||||
const STATE_SCHEMA_MODULES = [
|
||||
{
|
||||
modulePath: "src/state/openclaw-state-schema.ts",
|
||||
schemaPath: "src/state/openclaw-state-schema.sql",
|
||||
exportName: "OPENCLAW_STATE_SCHEMA_SQL",
|
||||
},
|
||||
{
|
||||
modulePath: "src/state/openclaw-agent-schema.ts",
|
||||
schemaPath: "src/state/openclaw-agent-schema.sql",
|
||||
exportName: "OPENCLAW_AGENT_SCHEMA_SQL",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Inline canonical schema bytes so packaged database opens need no SQL asset. */
|
||||
export function createStateSchemaInlinePlugin(rootDir: string = process.cwd()) {
|
||||
const schemasByModulePath = new Map(
|
||||
STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]),
|
||||
);
|
||||
|
||||
return {
|
||||
name: STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
load(this: { addWatchFile(id: string): void }, id: string) {
|
||||
const schema = schemasByModulePath.get(path.resolve(id));
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const schemaPath = path.resolve(rootDir, schema.schemaPath);
|
||||
this.addWatchFile(schemaPath);
|
||||
return {
|
||||
code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`,
|
||||
moduleType: "js" as const,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createStateSchemaInlinePlugin, STATE_SCHEMA_INLINE_PLUGIN_NAME };
|
||||
|
||||
const SUPPRESSED_EVAL_WARNING_PATHS = [
|
||||
"@protobufjs/inquire/index.js",
|
||||
|
||||
@@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reclaims its interval when the initial level callback stops it", () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.fn(async () => undefined);
|
||||
const disconnectSource = vi.fn();
|
||||
const disconnectAnalyser = vi.fn();
|
||||
class MockAudioContext {
|
||||
readonly close = close;
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn(), disconnect: disconnectSource };
|
||||
}
|
||||
createAnalyser() {
|
||||
return {
|
||||
fftSize: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
disconnect: disconnectAnalyser,
|
||||
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
|
||||
};
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("AudioContext", MockAudioContext);
|
||||
const onLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
meter.stop();
|
||||
}
|
||||
});
|
||||
const meter = new RealtimeTalkMediaStreamMeter(onLevel);
|
||||
|
||||
meter.start({} as MediaStream);
|
||||
meter.stop();
|
||||
meter.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(disconnectSource).toHaveBeenCalledOnce();
|
||||
expect(disconnectAnalyser).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes an owned AudioContext when analyser setup fails", () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
class MockAudioContext {
|
||||
|
||||
@@ -160,8 +160,10 @@ export class RealtimeTalkMediaStreamMeter {
|
||||
analyser.fftSize = this.samples.length;
|
||||
analyser.smoothingTimeConstant = 0;
|
||||
source.connect(analyser);
|
||||
this.publishCurrentLevel();
|
||||
this.timer = globalThis.setInterval(() => this.publishCurrentLevel(), 100);
|
||||
// The initial level callback can synchronously stop its owning transport.
|
||||
// Own the interval first so that reentrant cleanup cannot leave it behind.
|
||||
this.publishCurrentLevel();
|
||||
} catch {
|
||||
// Metering is feedback only; capture must still work if Web Audio analysis
|
||||
// is unavailable in an otherwise functional WebRTC browser.
|
||||
|
||||
@@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => {
|
||||
expect(onInputLevel).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it("reclaims the input meter when its first level update stops the transport", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createClient();
|
||||
const onInputLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
transport.stop();
|
||||
}
|
||||
});
|
||||
const transport = createTransport({ client, callbacks: { onInputLevel } });
|
||||
|
||||
await expect(transport.start()).resolves.toBe("ready");
|
||||
transport.stop();
|
||||
transport.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds stalled microphone appends and aborts every owner on stop", async () => {
|
||||
const onStatus = vi.fn();
|
||||
const client = createClient();
|
||||
|
||||
@@ -216,11 +216,6 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport {
|
||||
const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel);
|
||||
this.inputMeter = inputMeter;
|
||||
inputMeter.start(this.media, this.inputContext);
|
||||
if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) {
|
||||
// start() publishes synchronously before installing its interval. A
|
||||
// reentrant stop must reclaim the interval that start() installs next.
|
||||
inputMeter.stop(false);
|
||||
}
|
||||
this.assertActivationCurrent();
|
||||
}
|
||||
this.startMicrophonePump();
|
||||
|
||||
@@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reclaims the input meter when its first level update stops the transport", async () => {
|
||||
vi.useFakeTimers();
|
||||
stubAnswerSdpFetch();
|
||||
const close = vi.fn(async () => undefined);
|
||||
class MockAudioContext {
|
||||
readonly close = close;
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
createAnalyser() {
|
||||
return {
|
||||
fftSize: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
|
||||
};
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("AudioContext", MockAudioContext);
|
||||
const onInputLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
transport.stop();
|
||||
}
|
||||
});
|
||||
const transport = createOpenAiTransport({}, { onInputLevel });
|
||||
|
||||
await expect(transport.start()).resolves.toBe("cancelled");
|
||||
transport.stop();
|
||||
transport.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(stopInputTrack).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not continue WebRTC setup when stopped while microphone access is pending", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
Reference in New Issue
Block a user