mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 12:01:11 +00:00
* feat(xai): add realtime voice provider Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> * fix(xai): satisfy realtime voice validation * fix(xai): harden realtime relay boundaries * chore: drop release-owned changelog edit * chore(i18n): refresh native voice catalogs * chore(i18n): refresh Control UI raw-copy baseline * chore(plugin-sdk): refresh API baseline hashes * chore(i18n): use canonical main catalogs * chore(i18n): refresh realtime voice source inventory * chore: refresh xai voice landing head [skip ci] * chore: refresh integrated protocol artifacts [skip ci] --------- Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
347 lines
14 KiB
Swift
347 lines
14 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@MainActor
|
|
private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
|
|
func play(stream: AsyncThrowingStream<Data, Error>, sampleRate: Double) async -> StreamingPlaybackResult {
|
|
fatalError("Playback is not used by this test")
|
|
}
|
|
|
|
func stop() -> Double? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private actor RealtimeRelayStartupBarrier {
|
|
private var entered = false
|
|
private var enteredWaiter: CheckedContinuation<Void, Never>?
|
|
private var releaseWaiter: CheckedContinuation<Void, Never>?
|
|
|
|
func suspend() async {
|
|
self.entered = true
|
|
self.enteredWaiter?.resume()
|
|
self.enteredWaiter = nil
|
|
await withCheckedContinuation { self.releaseWaiter = $0 }
|
|
}
|
|
|
|
func waitUntilEntered() async {
|
|
if self.entered {
|
|
return
|
|
}
|
|
await withCheckedContinuation { self.enteredWaiter = $0 }
|
|
}
|
|
|
|
func release() {
|
|
self.releaseWaiter?.resume()
|
|
self.releaseWaiter = nil
|
|
}
|
|
}
|
|
|
|
private struct RealtimeRelayStartupRequest: Sendable {
|
|
let method: String
|
|
let paramsJSON: String?
|
|
}
|
|
|
|
private actor RealtimeRelayStartupRequestLog {
|
|
private var requests: [RealtimeRelayStartupRequest] = []
|
|
|
|
func record(method: String, paramsJSON: String?) {
|
|
self.requests.append(RealtimeRelayStartupRequest(method: method, paramsJSON: paramsJSON))
|
|
}
|
|
|
|
func snapshot() -> [RealtimeRelayStartupRequest] {
|
|
self.requests
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct RealtimeTalkRelaySessionTests {
|
|
@Test func `output playback finish clears barge in start time`() {
|
|
var speakingStates: [Bool] = []
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: nil, model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { _ in },
|
|
onSpeakingChanged: { speakingStates.append($0) })
|
|
|
|
session._test_markOutputAudioStarted(nowMs: 100)
|
|
#expect(session._test_isOutputPlaying())
|
|
#expect(session._test_outputStartedAtMs() == 100)
|
|
|
|
session._test_markOutputPlaybackFinished()
|
|
#expect(!session._test_isOutputPlaying())
|
|
#expect(session._test_outputStartedAtMs() == nil)
|
|
#expect(speakingStates == [false])
|
|
|
|
session._test_markOutputAudioStarted(nowMs: 500)
|
|
#expect(session._test_outputStartedAtMs() == 500)
|
|
}
|
|
|
|
@Test func `playback mark is acknowledged after output finishes`() async throws {
|
|
let requests = RealtimeRelayStartupRequestLog()
|
|
let transport = RealtimeTalkRelaySession.StartupTransport(
|
|
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
|
|
request: { method, paramsJSON, _ in
|
|
await requests.record(method: method, paramsJSON: paramsJSON)
|
|
return Data("{\"ok\":true}".utf8)
|
|
})
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "xai", model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { _ in },
|
|
onSpeakingChanged: { _ in },
|
|
startupTransport: transport)
|
|
session._test_setRelaySessionId("relay-1")
|
|
session._test_markOutputAudioStarted(nowMs: 100)
|
|
|
|
await session._test_handleGatewayEvent(EventFrame(
|
|
type: "event",
|
|
event: "talk.event",
|
|
payload: AnyCodable([
|
|
"relaySessionId": "relay-1",
|
|
"type": "mark",
|
|
"markName": "audio-1",
|
|
]),
|
|
seq: nil,
|
|
stateversion: nil))
|
|
await Task.yield()
|
|
#expect(await requests.snapshot().isEmpty)
|
|
|
|
session._test_markOutputPlaybackFinished()
|
|
for _ in 0..<10 {
|
|
if !(await requests.snapshot()).isEmpty { break }
|
|
await Task.yield()
|
|
}
|
|
|
|
let recorded = await requests.snapshot()
|
|
#expect(recorded.count == 1)
|
|
let request = try #require(recorded.first)
|
|
#expect(request.method == "talk.session.acknowledgeMark")
|
|
let paramsData = try #require(request.paramsJSON?.data(using: .utf8))
|
|
let params = try #require(JSONSerialization.jsonObject(with: paramsData) as? [String: String])
|
|
#expect(params == ["sessionId": "relay-1", "markName": "audio-1"])
|
|
}
|
|
|
|
@Test func `close after classified error does not replace issue`() async {
|
|
var issues: [TalkRuntimeIssue] = []
|
|
var statuses: [String] = []
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { statuses.append($0) },
|
|
onIssue: { issues.append($0) },
|
|
onSpeakingChanged: { _ in })
|
|
session._test_setRelaySessionId("relay-1")
|
|
|
|
await session._test_handleGatewayEvent(EventFrame(
|
|
type: "event",
|
|
event: "talk.event",
|
|
payload: AnyCodable([
|
|
"relaySessionId": "relay-1",
|
|
"type": "error",
|
|
"message": "OpenAI API key rejected with 401",
|
|
"code": "realtime_unavailable",
|
|
"provider": "openai",
|
|
"model": "gpt-realtime-2",
|
|
"transport": "gateway-relay",
|
|
"phase": "connect",
|
|
]),
|
|
seq: nil,
|
|
stateversion: nil))
|
|
await session._test_handleGatewayEvent(EventFrame(
|
|
type: "event",
|
|
event: "talk.event",
|
|
payload: AnyCodable([
|
|
"relaySessionId": "relay-1",
|
|
"type": "close",
|
|
"reason": "error",
|
|
]),
|
|
seq: nil,
|
|
stateversion: nil))
|
|
|
|
#expect(issues.map(\.code) == [.realtimeUnavailable])
|
|
#expect(statuses == ["OpenAI API key rejected with 401"])
|
|
}
|
|
|
|
@Test func `closed relay does not wait for startup ready`() async {
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { _ in },
|
|
onSpeakingChanged: { _ in })
|
|
|
|
session.stop()
|
|
|
|
#expect(await session._test_waitForStartupCancelled(timeoutSeconds: 1))
|
|
}
|
|
|
|
@Test func `startup ready wait covers gateway connect budget`() {
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { _ in },
|
|
onSpeakingChanged: { _ in })
|
|
|
|
#expect(session._test_startupReadyTimeoutSeconds() >= 12)
|
|
}
|
|
|
|
@Test func `stop during event subscription prevents relay creation`() async throws {
|
|
let barrier = RealtimeRelayStartupBarrier()
|
|
let requests = RealtimeRelayStartupRequestLog()
|
|
var statuses: [String] = []
|
|
var speakingStates: [Bool] = []
|
|
let transport = RealtimeTalkRelaySession.StartupTransport(
|
|
subscribeServerEvents: { _ in
|
|
await barrier.suspend()
|
|
return AsyncStream { $0.finish() }
|
|
},
|
|
request: { method, paramsJSON, _ in
|
|
await requests.record(method: method, paramsJSON: paramsJSON)
|
|
throw URLError(.badServerResponse)
|
|
})
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { statuses.append($0) },
|
|
onSpeakingChanged: { speakingStates.append($0) },
|
|
startupTransport: transport)
|
|
let start = Task { @MainActor in try await session.start() }
|
|
await barrier.waitUntilEntered()
|
|
|
|
session.stop()
|
|
await barrier.release()
|
|
try await start.value
|
|
|
|
#expect(await requests.snapshot().isEmpty)
|
|
#expect(statuses == ["Connecting realtime…"])
|
|
#expect(!speakingStates.contains(true))
|
|
}
|
|
|
|
@Test func `stop during relay creation closes late session once`() async throws {
|
|
let barrier = RealtimeRelayStartupBarrier()
|
|
let requests = RealtimeRelayStartupRequestLog()
|
|
var statuses: [String] = []
|
|
var speakingStates: [Bool] = []
|
|
let result = TalkSessionCreateResult(
|
|
sessionid: "talk-session",
|
|
mode: AnyCodable("realtime"),
|
|
transport: AnyCodable("gateway-relay"),
|
|
brain: AnyCodable("agent-consult"),
|
|
relaysessionid: "relay-1")
|
|
let resultData = try JSONEncoder().encode(result)
|
|
let transport = RealtimeTalkRelaySession.StartupTransport(
|
|
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
|
|
request: { method, paramsJSON, _ in
|
|
await requests.record(method: method, paramsJSON: paramsJSON)
|
|
if method == "talk.session.create" {
|
|
await barrier.suspend()
|
|
return resultData
|
|
}
|
|
return Data("{}".utf8)
|
|
})
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { statuses.append($0) },
|
|
onSpeakingChanged: { speakingStates.append($0) },
|
|
startupTransport: transport)
|
|
let start = Task { @MainActor in try await session.start() }
|
|
await barrier.waitUntilEntered()
|
|
|
|
session.stop()
|
|
await barrier.release()
|
|
try await start.value
|
|
|
|
let recorded = await requests.snapshot()
|
|
#expect(recorded.map(\.method) == ["talk.session.create", "talk.session.close"])
|
|
let closeJSON = try #require(recorded.last?.paramsJSON?.data(using: .utf8))
|
|
let closeParams = try #require(JSONSerialization.jsonObject(with: closeJSON) as? [String: String])
|
|
#expect(closeParams["sessionId"] == "relay-1")
|
|
#expect(!statuses.contains("Waiting for realtime…"))
|
|
#expect(!speakingStates.contains(true))
|
|
}
|
|
|
|
@Test func `stop during buffered tool call prevents late relay side effects`() async {
|
|
let barrier = RealtimeRelayStartupBarrier()
|
|
let requests = RealtimeRelayStartupRequestLog()
|
|
var statuses: [String] = []
|
|
let transport = RealtimeTalkRelaySession.StartupTransport(
|
|
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
|
|
request: { method, paramsJSON, _ in
|
|
await requests.record(method: method, paramsJSON: paramsJSON)
|
|
if method == "talk.client.toolCall" {
|
|
await barrier.suspend()
|
|
return Data("{\"runId\":\"run-1\"}".utf8)
|
|
}
|
|
return Data("{}".utf8)
|
|
})
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { statuses.append($0) },
|
|
onSpeakingChanged: { _ in },
|
|
startupTransport: transport)
|
|
session._test_setRelaySessionId("relay-1")
|
|
let handling = Task { @MainActor in
|
|
await session._test_handleGatewayEvent(EventFrame(
|
|
type: "event",
|
|
event: "talk.event",
|
|
payload: AnyCodable([
|
|
"relaySessionId": "relay-1",
|
|
"type": "toolCall",
|
|
"callId": "call-1",
|
|
"name": "lookup",
|
|
"args": [:],
|
|
]),
|
|
seq: nil,
|
|
stateversion: nil))
|
|
}
|
|
await barrier.waitUntilEntered()
|
|
|
|
session.stop()
|
|
await barrier.release()
|
|
await handling.value
|
|
await session._test_waitForToolCalls()
|
|
|
|
let methods = await requests.snapshot().map(\.method)
|
|
#expect(methods.first == "talk.client.toolCall")
|
|
#expect(!methods.contains("talk.session.submitToolResult"))
|
|
#expect(statuses == ["Thinking…"])
|
|
}
|
|
|
|
@Test func `stop cancels buffered microphone audio before dispatch`() async throws {
|
|
let requests = RealtimeRelayStartupRequestLog()
|
|
let transport = RealtimeTalkRelaySession.StartupTransport(
|
|
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
|
|
request: { method, paramsJSON, _ in
|
|
await requests.record(method: method, paramsJSON: paramsJSON)
|
|
return Data("{\"ok\":true}".utf8)
|
|
})
|
|
let session = RealtimeTalkRelaySession(
|
|
gateway: GatewayNodeSession(),
|
|
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
|
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
|
onStatus: { _ in },
|
|
onSpeakingChanged: { _ in },
|
|
startupTransport: transport)
|
|
session._test_prepareAudioSender(relaySessionId: "relay-1")
|
|
let send = try #require(session._test_enqueueMicrophoneFrame(Data([0x01, 0x02])))
|
|
|
|
session.stop()
|
|
await send.value
|
|
|
|
#expect(await requests.snapshot().isEmpty)
|
|
}
|
|
}
|