mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(talk): require accepted cancellation results
This commit is contained in:
@@ -860,7 +860,7 @@ class TalkModeManagerTest {
|
||||
|
||||
@Test
|
||||
fun malformedOutputCancellationResultFailsClosed() {
|
||||
for (response in listOf("""{"status":"stale"}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) {
|
||||
for (response in listOf("""{"status":"stale"}""", """{"ok":false}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) {
|
||||
assertTrue(runCatching { shouldRetireRealtimeOutputCancellation(response) }.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
+68
-10
@@ -549,6 +549,19 @@ private func outputAudioEvent(
|
||||
stateversion: nil)
|
||||
}
|
||||
|
||||
private func outputClearEvent(turnId: String) -> EventFrame {
|
||||
EventFrame(
|
||||
type: "event",
|
||||
event: "talk.event",
|
||||
payload: AnyCodable([
|
||||
"relaySessionId": "relay-1",
|
||||
"type": "clear",
|
||||
"talkEvent": ["turnId": turnId],
|
||||
]),
|
||||
seq: nil,
|
||||
stateversion: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RealtimeTalkRelaySessionTests {
|
||||
private func makeIdleCancellationSession(
|
||||
@@ -1257,16 +1270,7 @@ extension RealtimeTalkRelaySessionTests {
|
||||
|
||||
#expect(session.cancelOutput())
|
||||
try await barrier.waitUntilEntered()
|
||||
await session._test_handleGatewayEvent(EventFrame(
|
||||
type: "event",
|
||||
event: "talk.event",
|
||||
payload: AnyCodable([
|
||||
"relaySessionId": "relay-1",
|
||||
"type": "clear",
|
||||
"talkEvent": ["turnId": "turn-1"],
|
||||
]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
await session._test_handleGatewayEvent(outputClearEvent(turnId: "turn-1"))
|
||||
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
|
||||
#expect(session.cancelOutput())
|
||||
await barrier.release()
|
||||
@@ -1324,6 +1328,60 @@ extension RealtimeTalkRelaySessionTests {
|
||||
])
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
#"{"ok":true}"#,
|
||||
#"{"ok":true,"status":"applied","turnId":"turn-1"}"#,
|
||||
])
|
||||
func `accepted cancellation response keeps fence until matching clear`(
|
||||
response: String) async throws
|
||||
{
|
||||
let barrier = RealtimeRelayStartupBarrier()
|
||||
var speakingStates: [Bool] = []
|
||||
let session = RealtimeTalkRelaySession(
|
||||
transport: RealtimeTalkRelayTransport(
|
||||
subscribeServerEvents: { _ in AsyncStream { $0.finish() } },
|
||||
request: { method, _, _ in
|
||||
if method == "talk.session.cancelOutput" {
|
||||
await barrier.suspend()
|
||||
return Data(response.utf8)
|
||||
}
|
||||
return Data(#"{"ok":true}"#.utf8)
|
||||
}),
|
||||
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
||||
audioCapture: TestRealtimeTalkAudioCapture(),
|
||||
pcmPlayer: DrainingPCMStreamingAudioPlayer(),
|
||||
onStatus: { _ in },
|
||||
onSpeakingChanged: { speakingStates.append($0) })
|
||||
defer { session.stop() }
|
||||
session._test_setRelaySessionId("relay-1")
|
||||
session._test_prepareAudioSender(relaySessionId: "relay-1")
|
||||
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-1"))
|
||||
#expect(session.cancelOutput())
|
||||
var cancellationTask: Task<Void, Never>?
|
||||
do {
|
||||
try await barrier.waitUntilEntered()
|
||||
guard let exactCancellationTask = session._test_outputCancellationTask() else {
|
||||
throw RealtimeRelayTestTimeout(operation: "cancellation task registration")
|
||||
}
|
||||
cancellationTask = exactCancellationTask
|
||||
await barrier.release()
|
||||
await exactCancellationTask.value
|
||||
#expect(session._test_enqueueMicrophoneFrame(Data([0x01])) == nil)
|
||||
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
|
||||
#expect(speakingStates == [true, false])
|
||||
|
||||
await session._test_handleGatewayEvent(outputClearEvent(turnId: "turn-1"))
|
||||
let admittedTask = try #require(session._test_enqueueMicrophoneFrame(Data([0x02])))
|
||||
await admittedTask.value
|
||||
await session._test_handleGatewayEvent(outputAudioEvent(turnId: "turn-2"))
|
||||
} catch {
|
||||
await barrier.release()
|
||||
await cancellationTask?.value
|
||||
throw error
|
||||
}
|
||||
#expect(speakingStates == [true, false, true])
|
||||
}
|
||||
|
||||
@Test func `close retires in flight cancellation failure`() async throws {
|
||||
let barrier = RealtimeRelayStartupBarrier()
|
||||
var issues: [RealtimeTalkRelayIssue] = []
|
||||
|
||||
@@ -52,6 +52,7 @@ describe("TalkSessionCancelOutputResultSchema", () => {
|
||||
for (const value of [
|
||||
{},
|
||||
{ status: "applied" },
|
||||
{ ok: false },
|
||||
{ ok: true, status: "unknown" },
|
||||
{ ok: true, turnId: "" },
|
||||
{ ok: true, extra: true },
|
||||
|
||||
@@ -319,7 +319,7 @@ export const TalkSessionCancelOutputParamsSchema = closedObject({
|
||||
|
||||
/** Reports whether a Talk output cancellation applied to the requested turn. */
|
||||
export const TalkSessionCancelOutputResultSchema = closedObject({
|
||||
ok: Type.Boolean(),
|
||||
ok: Type.Literal(true),
|
||||
status: Type.Optional(
|
||||
Type.Union([Type.Literal("applied"), Type.Literal("stale"), Type.Literal("idle")]),
|
||||
),
|
||||
|
||||
@@ -336,11 +336,13 @@ function emitWireModels(): string[] {
|
||||
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
|
||||
const literal = literalValue(propertySchema);
|
||||
const optional = !required.has(wireName);
|
||||
const useLiteralDefault =
|
||||
literal !== undefined && (optional || typeof literal !== "boolean");
|
||||
return {
|
||||
annotation:
|
||||
propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
|
||||
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
|
||||
literal !== undefined ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
|
||||
useLiteralDefault ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
|
||||
},`,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user