mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 11:51:03 +00:00
test: clear talk realtime relay broad matchers
This commit is contained in:
@@ -86,6 +86,57 @@ describe("talk realtime gateway relay", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
||||
expect(record).toBeDefined();
|
||||
const actual = record as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(actual[key]).toEqual(value);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
function mockCallArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0) {
|
||||
const call = mock.mock.calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function findEventPayload(
|
||||
events: Array<{ payload: unknown }>,
|
||||
predicate: (payload: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
const event = events.find((entry) => {
|
||||
const payload = entry.payload;
|
||||
return (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
predicate(payload as Record<string, unknown>)
|
||||
);
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error("Expected matching relay event");
|
||||
}
|
||||
return event.payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectChatAbortPayload(mock: ReturnType<typeof vi.fn>, stopReason: string) {
|
||||
expect(mockCallArg(mock)).toBe("chat");
|
||||
expectRecordFields(mockCallArg(mock, 0, 1), {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
stopReason,
|
||||
});
|
||||
}
|
||||
|
||||
function expectNodeAbortPayload(mock: ReturnType<typeof vi.fn>) {
|
||||
expect(mockCallArg(mock)).toBe("main");
|
||||
expect(mockCallArg(mock, 0, 1)).toBe("chat");
|
||||
expectRecordFields(mockCallArg(mock, 0, 2), { runId: "run-1", state: "aborted" });
|
||||
}
|
||||
|
||||
it("bridges browser audio, transcripts, and tool results through a backend provider", async () => {
|
||||
let bridgeRequest: RealtimeVoiceBridgeCreateRequest | undefined;
|
||||
const bridge = {
|
||||
@@ -140,91 +191,93 @@ describe("talk realtime gateway relay", () => {
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(session).toMatchObject({
|
||||
const sessionFields = expectRecordFields(session, {
|
||||
provider: "relay-test",
|
||||
transport: "gateway-relay",
|
||||
model: "browser-model",
|
||||
voice: "voice-a",
|
||||
audio: {
|
||||
inputEncoding: "pcm16",
|
||||
inputSampleRateHz: 24000,
|
||||
outputEncoding: "pcm16",
|
||||
outputSampleRateHz: 24000,
|
||||
},
|
||||
});
|
||||
expect(bridgeRequest).toMatchObject({
|
||||
expectRecordFields(sessionFields.audio, {
|
||||
inputEncoding: "pcm16",
|
||||
inputSampleRateHz: 24000,
|
||||
outputEncoding: "pcm16",
|
||||
outputSampleRateHz: 24000,
|
||||
});
|
||||
expectRecordFields(bridgeRequest, {
|
||||
providerConfig: { model: "provider-model" },
|
||||
audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
instructions: "be brief",
|
||||
});
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
event: "talk.event",
|
||||
connIds: ["conn-1"],
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "ready",
|
||||
talkEvent: expect.objectContaining({
|
||||
sessionId: session.relaySessionId,
|
||||
type: "session.ready",
|
||||
seq: 1,
|
||||
mode: "realtime",
|
||||
transport: "gateway-relay",
|
||||
brain: "agent-consult",
|
||||
provider: "relay-test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "audio",
|
||||
audioBase64: Buffer.from("audio-out").toString("base64"),
|
||||
talkEvent: expect.objectContaining({ type: "output.audio.delta" }),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "transcript",
|
||||
role: "user",
|
||||
text: "hello",
|
||||
final: true,
|
||||
talkEvent: expect.objectContaining({ type: "transcript.done", final: true }),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "transcript",
|
||||
role: "assistant",
|
||||
text: "hi there",
|
||||
final: true,
|
||||
talkEvent: expect.objectContaining({
|
||||
type: "output.text.done",
|
||||
final: true,
|
||||
payload: { text: "hi there" },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolCall",
|
||||
itemId: "item-1",
|
||||
callId: "call-1",
|
||||
name: "openclaw_agent_consult",
|
||||
args: { question: "what now" },
|
||||
talkEvent: expect.objectContaining({
|
||||
type: "tool.call",
|
||||
itemId: "item-1",
|
||||
callId: "call-1",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
const readyPayload = findEventPayload(events, (payload) => payload.type === "ready");
|
||||
expectRecordFields(readyPayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "ready",
|
||||
});
|
||||
expectRecordFields(readyPayload.talkEvent, {
|
||||
sessionId: session.relaySessionId,
|
||||
type: "session.ready",
|
||||
seq: 1,
|
||||
mode: "realtime",
|
||||
transport: "gateway-relay",
|
||||
brain: "agent-consult",
|
||||
provider: "relay-test",
|
||||
});
|
||||
const readyEvent = events.find((entry) => entry.payload === readyPayload);
|
||||
expectRecordFields(readyEvent, { event: "talk.event", connIds: ["conn-1"] });
|
||||
|
||||
const audioPayload = findEventPayload(events, (payload) => payload.type === "audio");
|
||||
expectRecordFields(audioPayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "audio",
|
||||
audioBase64: Buffer.from("audio-out").toString("base64"),
|
||||
});
|
||||
expectRecordFields(audioPayload.talkEvent, { type: "output.audio.delta" });
|
||||
|
||||
const userTranscript = findEventPayload(
|
||||
events,
|
||||
(payload) => payload.type === "transcript" && payload.role === "user",
|
||||
);
|
||||
expectRecordFields(userTranscript, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "transcript",
|
||||
role: "user",
|
||||
text: "hello",
|
||||
final: true,
|
||||
});
|
||||
expectRecordFields(userTranscript.talkEvent, { type: "transcript.done", final: true });
|
||||
|
||||
const assistantTranscript = findEventPayload(
|
||||
events,
|
||||
(payload) => payload.type === "transcript" && payload.role === "assistant",
|
||||
);
|
||||
expectRecordFields(assistantTranscript, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "transcript",
|
||||
role: "assistant",
|
||||
text: "hi there",
|
||||
final: true,
|
||||
});
|
||||
expectRecordFields(assistantTranscript.talkEvent, {
|
||||
type: "output.text.done",
|
||||
final: true,
|
||||
payload: { text: "hi there" },
|
||||
});
|
||||
|
||||
const toolCallPayload = findEventPayload(events, (payload) => payload.type === "toolCall");
|
||||
expectRecordFields(toolCallPayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolCall",
|
||||
itemId: "item-1",
|
||||
callId: "call-1",
|
||||
name: "openclaw_agent_consult",
|
||||
args: { question: "what now" },
|
||||
});
|
||||
expectRecordFields(toolCallPayload.talkEvent, {
|
||||
type: "tool.call",
|
||||
itemId: "item-1",
|
||||
callId: "call-1",
|
||||
});
|
||||
|
||||
sendTalkRealtimeRelayAudio({
|
||||
relaySessionId: session.relaySessionId,
|
||||
@@ -276,65 +329,67 @@ describe("talk realtime gateway relay", () => {
|
||||
);
|
||||
expect(bridge.handleBargeIn).toHaveBeenCalledWith({ audioPlaybackActive: true });
|
||||
expect(bridge.close).toHaveBeenCalled();
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "inputAudio",
|
||||
byteLength: Buffer.from("audio-in").byteLength,
|
||||
talkEvent: expect.objectContaining({ type: "input.audio.delta" }),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "clear",
|
||||
talkEvent: expect.objectContaining({
|
||||
type: "turn.cancelled",
|
||||
payload: { reason: "barge-in" },
|
||||
final: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolResult",
|
||||
callId: "call-1",
|
||||
talkEvent: expect.objectContaining({
|
||||
type: "tool.result",
|
||||
callId: "call-1",
|
||||
final: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolResult",
|
||||
callId: "call-1",
|
||||
talkEvent: expect.objectContaining({
|
||||
type: "tool.result",
|
||||
callId: "call-1",
|
||||
final: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "close",
|
||||
reason: "completed",
|
||||
talkEvent: expect.objectContaining({ type: "session.closed", final: true }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
const inputAudioPayload = findEventPayload(
|
||||
events,
|
||||
(payload) =>
|
||||
payload.type === "inputAudio" && payload.byteLength === Buffer.from("audio-in").byteLength,
|
||||
);
|
||||
expectRecordFields(inputAudioPayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "inputAudio",
|
||||
byteLength: Buffer.from("audio-in").byteLength,
|
||||
});
|
||||
expectRecordFields(inputAudioPayload.talkEvent, { type: "input.audio.delta" });
|
||||
|
||||
const clearPayload = findEventPayload(events, (payload) => payload.type === "clear");
|
||||
expectRecordFields(clearPayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "clear",
|
||||
});
|
||||
expectRecordFields(clearPayload.talkEvent, {
|
||||
type: "turn.cancelled",
|
||||
payload: { reason: "barge-in" },
|
||||
final: true,
|
||||
});
|
||||
|
||||
const toolResultPayloads = events
|
||||
.map((entry) => entry.payload)
|
||||
.filter(
|
||||
(payload): payload is Record<string, unknown> =>
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
(payload as Record<string, unknown>).type === "toolResult" &&
|
||||
(payload as Record<string, unknown>).callId === "call-1",
|
||||
);
|
||||
expect(toolResultPayloads).toHaveLength(2);
|
||||
expectRecordFields(toolResultPayloads[0], {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolResult",
|
||||
callId: "call-1",
|
||||
});
|
||||
expectRecordFields(toolResultPayloads[0]?.talkEvent, {
|
||||
type: "tool.result",
|
||||
callId: "call-1",
|
||||
final: false,
|
||||
});
|
||||
expectRecordFields(toolResultPayloads[1], {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "toolResult",
|
||||
callId: "call-1",
|
||||
});
|
||||
expectRecordFields(toolResultPayloads[1]?.talkEvent, {
|
||||
type: "tool.result",
|
||||
callId: "call-1",
|
||||
final: true,
|
||||
});
|
||||
|
||||
const closePayload = findEventPayload(events, (payload) => payload.type === "close");
|
||||
expectRecordFields(closePayload, {
|
||||
relaySessionId: session.relaySessionId,
|
||||
type: "close",
|
||||
reason: "completed",
|
||||
});
|
||||
expectRecordFields(closePayload.talkEvent, { type: "session.closed", final: true });
|
||||
});
|
||||
|
||||
it("rejects relay control from a different connection", () => {
|
||||
@@ -439,20 +494,8 @@ describe("talk realtime gateway relay", () => {
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(removeChatRun).toHaveBeenCalledWith("run-1", "run-1", "main");
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
stopReason: "barge-in",
|
||||
}),
|
||||
);
|
||||
expect(nodeSendToSession).toHaveBeenCalledWith(
|
||||
"main",
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "run-1", state: "aborted" }),
|
||||
);
|
||||
expectChatAbortPayload(broadcast, "barge-in");
|
||||
expectNodeAbortPayload(nodeSendToSession);
|
||||
});
|
||||
|
||||
it("aborts linked agent consult runs when the relay session closes", () => {
|
||||
@@ -461,20 +504,8 @@ describe("talk realtime gateway relay", () => {
|
||||
stopTalkRealtimeRelaySession({ relaySessionId: session.relaySessionId, connId: "conn-1" });
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
stopReason: "relay-closed",
|
||||
}),
|
||||
);
|
||||
expect(nodeSendToSession).toHaveBeenCalledWith(
|
||||
"main",
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "run-1", state: "aborted" }),
|
||||
);
|
||||
expectChatAbortPayload(broadcast, "relay-closed");
|
||||
expectNodeAbortPayload(nodeSendToSession);
|
||||
});
|
||||
|
||||
it("aborts linked agent consult runs when the provider closes the relay", () => {
|
||||
@@ -542,20 +573,8 @@ describe("talk realtime gateway relay", () => {
|
||||
bridgeRequest?.onClose?.("error");
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
expect(broadcast).toHaveBeenCalledWith(
|
||||
"chat",
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
stopReason: "relay-closed",
|
||||
}),
|
||||
);
|
||||
expect(nodeSendToSession).toHaveBeenCalledWith(
|
||||
"main",
|
||||
"chat",
|
||||
expect.objectContaining({ runId: "run-1", state: "aborted" }),
|
||||
);
|
||||
expectChatAbortPayload(broadcast, "relay-closed");
|
||||
expectNodeAbortPayload(nodeSendToSession);
|
||||
});
|
||||
|
||||
it("caps active relay sessions per browser connection", () => {
|
||||
@@ -590,13 +609,13 @@ describe("talk realtime gateway relay", () => {
|
||||
expect(() => createSession("conn-1")).toThrow(
|
||||
"Too many active realtime relay sessions for this connection",
|
||||
);
|
||||
expect(createSession("conn-2")).toMatchObject({
|
||||
const session = expectRecordFields(createSession("conn-2"), {
|
||||
provider: "relay-test",
|
||||
transport: "gateway-relay",
|
||||
audio: {
|
||||
inputEncoding: "pcm16",
|
||||
outputEncoding: "pcm16",
|
||||
},
|
||||
});
|
||||
expectRecordFields(session.audio, {
|
||||
inputEncoding: "pcm16",
|
||||
outputEncoding: "pcm16",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user