mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* fix(voice): keep audio flowing while the Gateway is busy Move Discord voice transport, packet pacing, codecs and capture deadlines into a session-owned worker. Run GPT Live WebRTC and WebSocket media in workers and connect continuous playback with a bounded direct audio port. Preserve authorization, selected-agent work, recording receipts and transcript ownership on main, with generation fencing and ordered teardown. Validated affected tests, the capture-finalization red/green regression, source and compiled worker lifecycle, root and plugin builds, type/lint/format and line-cap checks. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(voice): keep audio flowing while the Gateway is busy Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 58c87505-0064-4391-994e-57587279bcca * fix(voice): use explicit Node message transfer lists Replace six Node postMessage lint suppressions with explicit empty transfer lists. Preserve the Node messaging contract and the unchanged production suppression allowlist. Reproduced the original CI shard plan before the fix; the same plan, focused worker messaging tests, and type-aware lint pass afterward. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(openai): keep worker runtime out of cold voice catalogs Load the default media-socket factory only through abort-aware connection admission. Preserve injected factories and WebRTC routing, and centralize unchanged request-ID construction in the wire owner. Keep the cold-catalog contract and line cap intact. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * refactor(openai): keep socket contracts below connection admission Move unchanged socket interfaces into the existing shared leaf and migrate all private consumers, preserving lazy worker loading without a type import cycle. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(discord): preserve direct speech ownership through playback Track exact speech with an epoch-scoped shared playback witness. Order completion behind bounded direct-port flush receipts, including no-audio responses, and prevent stale idle or flush events from retiring newer speech. Cover original failures and the pending-flush race with deterministic production-owner regressions. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(discord): preserve audio ownership across worker retirement Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 6294ffa7-0c2f-456b-9baf-3dcdab60efc8 * refactor(discord): keep the worker status slot private Worked on by: - @steipete Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: dde5cff1-21c6-4bfa-825e-47c2f1ec2393 --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
177 lines
6 KiB
TypeScript
177 lines
6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { openAIRealtimeHost } from "./realtime-host.js";
|
|
import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js";
|
|
import { fakeQuicksilverMediaSocket } from "./realtime-quicksilver-socket.test-support.js";
|
|
import {
|
|
createDelegationHarness,
|
|
emitSideband,
|
|
FakeSocket,
|
|
} from "./realtime-quicksilver.test-helpers.js";
|
|
|
|
type Observer = (role: "user" | "assistant", text: string, final: boolean) => void;
|
|
async function createPublisher(kind: "controller" | "direct", onTranscript: Observer) {
|
|
const consult = vi.fn(async (_request?: unknown) => ({ text: "Done" }));
|
|
if (kind === "controller") {
|
|
const { controller } = createDelegationHarness({
|
|
model: "gpt-live-1",
|
|
onTranscript,
|
|
runAgentConsult: consult,
|
|
});
|
|
return {
|
|
consult,
|
|
frame: (event: unknown) => controller.handleFrame(Buffer.from(JSON.stringify(event)), false),
|
|
stop: async (_restart = false) => {
|
|
controller.stop(new Error("test closed"));
|
|
},
|
|
};
|
|
}
|
|
const sockets: FakeSocket[] = [];
|
|
const bridge = new OpenAIQuicksilverVoiceBridge(
|
|
{
|
|
providerConfig: {},
|
|
model: "gpt-live-1",
|
|
onTranscript,
|
|
onToolCall: (event) => {
|
|
void consult(event);
|
|
},
|
|
onAudio: vi.fn(),
|
|
onClearAudio: vi.fn(),
|
|
resolveAuth: async () => ({ type: "api-key", token: "fixture-key" }),
|
|
mediaSocketFactory: fakeQuicksilverMediaSocket(() => {
|
|
const socket = new FakeSocket();
|
|
sockets.push(socket);
|
|
return socket;
|
|
}),
|
|
},
|
|
openAIRealtimeHost,
|
|
);
|
|
const ready = async (connecting: Promise<void>, index: number) => {
|
|
await vi.waitFor(() => expect(sockets[index]?.sent.length).toBe(1));
|
|
emitSideband(sockets[index]!, { type: "session.started", session: {} });
|
|
await connecting;
|
|
};
|
|
await ready(bridge.connect(), 0);
|
|
return {
|
|
consult,
|
|
frame: (event: unknown) => emitSideband(sockets.at(-1)!, event),
|
|
stop: (restart = false) => {
|
|
const oldSocket = sockets.at(-1)!;
|
|
const closing = bridge.close();
|
|
const index = sockets.length;
|
|
const connecting = restart ? bridge.connect() : undefined;
|
|
emitSideband(oldSocket, { type: "session.closed", reason: "close_requested" });
|
|
return Promise.resolve(closing).then(async () => {
|
|
if (connecting) {
|
|
await ready(connecting, index);
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
function transcript(role: "user" | "assistant", text: string) {
|
|
return {
|
|
type: `session.${role === "user" ? "input" : "output"}_transcript.delta`,
|
|
delta: text,
|
|
start_ms: 0,
|
|
end_ms: 100,
|
|
};
|
|
}
|
|
function delegation(id: string) {
|
|
return {
|
|
type: "session.delegation.created",
|
|
offset_ms: 100,
|
|
delegation: { id, type: "delegation", target: "client" },
|
|
};
|
|
}
|
|
|
|
describe("public transcript publication boundaries", () => {
|
|
it.each(["controller", "direct"] as const)(
|
|
"%s publishes per-role aggregates independently of bounded context",
|
|
async (kind) => {
|
|
const pending = { user: "", assistant: "" };
|
|
const finals: Array<{ role: string; text: string }> = [];
|
|
const client = await createPublisher(kind, (role, text, final) => {
|
|
if (final) {
|
|
expect(text).toBe(pending[role]);
|
|
finals.push({ role, text });
|
|
pending[role] = "";
|
|
} else {
|
|
pending[role] += text;
|
|
}
|
|
});
|
|
try {
|
|
const request = "word".repeat(300);
|
|
client.frame(transcript("user", "Check "));
|
|
client.frame(transcript("assistant", "I can help."));
|
|
client.frame(transcript("user", request));
|
|
client.frame(delegation("interleave"));
|
|
expect(finals.filter((entry) => entry.role === "user")).toEqual([
|
|
{ role: "user", text: "Check " + request },
|
|
]);
|
|
const checkpoint = finals.length;
|
|
const oversized = "é".repeat(5_000);
|
|
client.frame(transcript("user", oversized));
|
|
client.frame(delegation("oversized"));
|
|
const savedUser = finals.slice(checkpoint).filter((entry) => entry.role === "user");
|
|
expect(savedUser.length).toBeGreaterThan(1);
|
|
expect(savedUser.map((entry) => entry.text).join("")).toBe(oversized);
|
|
const beforeClose = finals.length;
|
|
await client.stop();
|
|
expect(finals).toHaveLength(beforeClose);
|
|
} finally {
|
|
await client.stop();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ kind: "controller", restart: false },
|
|
{ kind: "direct", restart: false },
|
|
{ kind: "direct", restart: true },
|
|
] as const)(
|
|
"$kind checkpoint respects closing/replacement (restart=$restart)",
|
|
async ({ kind, restart }) => {
|
|
const order: string[] = [];
|
|
const partials: string[] = [];
|
|
const finals: string[] = [];
|
|
let stopping: Promise<void> | undefined;
|
|
const client = await createPublisher(kind, (role, text, final) => {
|
|
if (!final) {
|
|
partials.push(text);
|
|
return;
|
|
}
|
|
order.push(`start:${role}`);
|
|
finals.push(text);
|
|
if (!stopping) {
|
|
stopping = client.stop(restart);
|
|
}
|
|
order.push(`end:${role}`);
|
|
});
|
|
try {
|
|
const user = "u".repeat(7_000);
|
|
const assistant = "a".repeat(900);
|
|
client.frame(transcript("user", user));
|
|
client.frame(transcript("assistant", assistant));
|
|
client.frame(transcript("user", "never-admitted".repeat(20)));
|
|
await stopping;
|
|
expect(partials).toEqual([user, assistant]);
|
|
expect(order).toEqual(
|
|
restart
|
|
? ["start:user", "end:user"]
|
|
: ["start:user", "end:user", "start:assistant", "end:assistant"],
|
|
);
|
|
expect(finals).toEqual(restart ? [user] : [user, assistant]);
|
|
if (restart) {
|
|
client.frame(transcript("user", "Fresh request"));
|
|
client.frame(delegation("fresh"));
|
|
expect(finals.at(-1)).toBe("Fresh request");
|
|
} else {
|
|
client.frame(delegation("late"));
|
|
expect(client.consult).not.toHaveBeenCalled();
|
|
}
|
|
} finally {
|
|
await client.stop();
|
|
}
|
|
},
|
|
);
|
|
});
|