mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix(talk): type output cancellation results
This commit is contained in:
@@ -325,6 +325,13 @@ data class SessionGitHubPublicationFailed(
|
||||
@JsonClassDiscriminator("status")
|
||||
sealed interface SessionGitHubPublicationResult
|
||||
|
||||
@Serializable
|
||||
data class TalkSessionCancelOutputResult(
|
||||
val ok: Boolean,
|
||||
val status: String? = null,
|
||||
val turnId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayEventFrameStateVersion(
|
||||
val presence: Long,
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.voice
|
||||
import ai.openclaw.app.gateway.ChatSendAck
|
||||
import ai.openclaw.app.gateway.GatewayRequestRejected
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.TalkSessionCancelOutputResult
|
||||
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
|
||||
import ai.openclaw.app.gateway.parseChatSendAck
|
||||
import ai.openclaw.app.i18n.NativeText
|
||||
@@ -60,6 +61,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
@@ -2934,9 +2936,8 @@ class TalkModeManager internal constructor(
|
||||
put("reason", JsonPrimitive(reason))
|
||||
if (turnId != null) put("turnId", JsonPrimitive(turnId))
|
||||
}
|
||||
val result =
|
||||
json.parseToJsonElement(requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)).asObjectOrNull()
|
||||
if (result?.get("status").asStringOrNull() != "applied") clear.complete(Unit)
|
||||
val response = requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
|
||||
if (shouldRetireRealtimeOutputCancellation(response)) clear.complete(Unit)
|
||||
// The response confirms provider cancellation; clear confirms that the
|
||||
// old playback boundary reached Android before capture can resume.
|
||||
withTimeout(2_000) { clear.await() }
|
||||
@@ -3335,6 +3336,16 @@ class TalkModeManager internal constructor(
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
internal fun shouldRetireRealtimeOutputCancellation(response: String): Boolean {
|
||||
val result = Json.decodeFromString<TalkSessionCancelOutputResult>(response)
|
||||
check(result.ok) { "talk.session.cancelOutput was not accepted" }
|
||||
return when (result.status) {
|
||||
null, "applied" -> false
|
||||
"stale", "idle" -> true
|
||||
else -> error("unknown talk.session.cancelOutput status")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun JsonElement?.asDoubleOrNull(): Double? {
|
||||
|
||||
@@ -850,6 +850,21 @@ class TalkModeManagerTest {
|
||||
assertFalse(stoppedByRelay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputCancellationResultRetainsOnlyAcceptedClearFences() {
|
||||
assertFalse(shouldRetireRealtimeOutputCancellation("""{"ok":true}"""))
|
||||
assertFalse(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"applied"}"""))
|
||||
assertTrue(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"stale"}"""))
|
||||
assertTrue(shouldRetireRealtimeOutputCancellation("""{"ok":true,"status":"idle"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedOutputCancellationResultFailsClosed() {
|
||||
for (response in listOf("""{"status":"stale"}""", """{"ok":true,"status":"unknown"}""", """{"ok":true,"extra":1}""")) {
|
||||
assertTrue(runCatching { shouldRetireRealtimeOutputCancellation(response) }.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stalePushToTalkCompletionCannotResumeNewerPause() =
|
||||
runTest {
|
||||
|
||||
@@ -1195,12 +1195,20 @@ extension RealtimeTalkRelaySession {
|
||||
]
|
||||
do {
|
||||
let response = try await transport.request("talk.session.cancelOutput", payload, 8000)
|
||||
let status = try? JSONDecoder().decode([String: String].self, from: response)["status"]
|
||||
let result = try JSONDecoder().decode(TalkSessionCancelOutputResult.self, from: response)
|
||||
guard result.ok else { throw URLError(.badServerResponse) }
|
||||
guard let self, self.isCurrentOutputCancellation(cancellationGeneration) else { return }
|
||||
if status == "stale" || status == "idle" || !self.awaitingOutputClear {
|
||||
switch result.status?.stringValue {
|
||||
case "stale", "idle":
|
||||
self.retireOutputCancellation()
|
||||
} else {
|
||||
self.outputCancellationTask = nil
|
||||
case nil, "applied":
|
||||
if self.awaitingOutputClear {
|
||||
self.outputCancellationTask = nil
|
||||
} else {
|
||||
self.retireOutputCancellation()
|
||||
}
|
||||
default:
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
} catch {
|
||||
guard let self, self.isCurrentOutputCancellation(cancellationGeneration) else { return }
|
||||
|
||||
@@ -13111,6 +13111,28 @@ public struct TalkSessionCancelOutputParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSessionCancelOutputResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let status: AnyCodable?
|
||||
public let turnid: String?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
status: AnyCodable? = nil,
|
||||
turnid: String? = nil)
|
||||
{
|
||||
self.ok = ok
|
||||
self.status = status
|
||||
self.turnid = turnid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case status
|
||||
case turnid = "turnId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSessionCreateParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let spawnedby: String?
|
||||
|
||||
@@ -982,7 +982,7 @@ extension RealtimeTalkRelaySessionTests {
|
||||
request: { method, params, _ in
|
||||
await requests.record(method: method, params: params)
|
||||
await barrier.suspend()
|
||||
return Data("{\"status\":\"\(status)\"}".utf8)
|
||||
return Data("{\"ok\":true,\"status\":\"\(status)\"}".utf8)
|
||||
}),
|
||||
options: .init(sessionKey: "main", provider: "openai", model: nil, voice: nil),
|
||||
audioCapture: TestRealtimeTalkAudioCapture(),
|
||||
@@ -1288,7 +1288,7 @@ extension RealtimeTalkRelaySessionTests {
|
||||
await requests.record(method: method, params: params)
|
||||
if method == "talk.session.cancelOutput" {
|
||||
await barrier.suspend()
|
||||
return Data("{\"status\":\"applied\",\"turnId\":\"turn-1\"}".utf8)
|
||||
return Data("{\"ok\":true,\"status\":\"applied\",\"turnId\":\"turn-1\"}".utf8)
|
||||
}
|
||||
return Data("{\"ok\":true}".utf8)
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Gateway Protocol tests cover channels.schema behavior.
|
||||
import { Compile } from "typebox/compile";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChannelsStatusResultSchema, WebLoginWaitParamsSchema } from "./schema/channels.js";
|
||||
import {
|
||||
ChannelsStatusResultSchema,
|
||||
TalkSessionCancelOutputResultSchema,
|
||||
WebLoginWaitParamsSchema,
|
||||
} from "./schema/channels.js";
|
||||
|
||||
/**
|
||||
* Channel schema regressions for browser login and status diagnostics.
|
||||
@@ -33,6 +37,30 @@ describe("WebLoginWaitParamsSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TalkSessionCancelOutputResultSchema", () => {
|
||||
const validate = Compile(TalkSessionCancelOutputResultSchema);
|
||||
|
||||
it("accepts only closed cancellation outcomes with an explicit ok field", () => {
|
||||
for (const value of [
|
||||
{ ok: true },
|
||||
{ ok: true, status: "applied", turnId: "turn-7" },
|
||||
{ ok: true, status: "stale" },
|
||||
{ ok: true, status: "idle" },
|
||||
]) {
|
||||
expect(validate.Check(value)).toBe(true);
|
||||
}
|
||||
for (const value of [
|
||||
{},
|
||||
{ status: "applied" },
|
||||
{ ok: true, status: "unknown" },
|
||||
{ ok: true, turnId: "" },
|
||||
{ ok: true, extra: true },
|
||||
]) {
|
||||
expect(validate.Check(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChannelsStatusResultSchema", () => {
|
||||
/** Compiled status validator for channel docking diagnostics. */
|
||||
const validate = Compile(ChannelsStatusResultSchema);
|
||||
|
||||
@@ -413,6 +413,7 @@ export {
|
||||
TalkSessionAppendAudioParamsSchema,
|
||||
TalkSessionAcknowledgeMarkParamsSchema,
|
||||
TalkSessionCancelOutputParamsSchema,
|
||||
TalkSessionCancelOutputResultSchema,
|
||||
TalkSessionCreateParamsSchema,
|
||||
TalkSessionCreateResultSchema,
|
||||
TalkSessionSteerParamsSchema,
|
||||
|
||||
@@ -317,6 +317,15 @@ export const TalkSessionCancelOutputParamsSchema = closedObject({
|
||||
reason: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
/** Reports whether a Talk output cancellation applied to the requested turn. */
|
||||
export const TalkSessionCancelOutputResultSchema = closedObject({
|
||||
ok: Type.Boolean(),
|
||||
status: Type.Optional(
|
||||
Type.Union([Type.Literal("applied"), Type.Literal("stale"), Type.Literal("idle")]),
|
||||
),
|
||||
turnId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Submits a tool result back to a Talk provider session. */
|
||||
export const TalkSessionSubmitToolResultParamsSchema = closedObject({
|
||||
sessionId: NonEmptyString,
|
||||
@@ -741,6 +750,7 @@ export type TalkSessionCreateParams = Static<typeof TalkSessionCreateParamsSchem
|
||||
export type TalkSessionCreateResult = Static<typeof TalkSessionCreateResultSchema>;
|
||||
export type TalkSessionAppendAudioParams = Static<typeof TalkSessionAppendAudioParamsSchema>;
|
||||
export type TalkSessionCancelOutputParams = Static<typeof TalkSessionCancelOutputParamsSchema>;
|
||||
export type TalkSessionCancelOutputResult = Static<typeof TalkSessionCancelOutputResultSchema>;
|
||||
export type TalkSessionSteerParams = Static<typeof TalkSessionSteerParamsSchema>;
|
||||
export type TalkSessionSubmitToolResultParams = Static<
|
||||
typeof TalkSessionSubmitToolResultParamsSchema
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ChannelProtocolSchemas = {
|
||||
TalkSessionAppendAudioParams: channels.TalkSessionAppendAudioParamsSchema,
|
||||
TalkSessionAcknowledgeMarkParams: talkMarks.TalkSessionAcknowledgeMarkParamsSchema,
|
||||
TalkSessionCancelOutputParams: channels.TalkSessionCancelOutputParamsSchema,
|
||||
TalkSessionCancelOutputResult: channels.TalkSessionCancelOutputResultSchema,
|
||||
TalkSessionCreateParams: channels.TalkSessionCreateParamsSchema,
|
||||
TalkSessionCreateResult: channels.TalkSessionCreateResultSchema,
|
||||
TalkSessionSteerParams: channels.TalkSessionSteerParamsSchema,
|
||||
|
||||
@@ -330,6 +330,7 @@ export const validateTalkSessionAcknowledgeMarkParams = compile(
|
||||
S.TalkSessionAcknowledgeMarkParamsSchema,
|
||||
);
|
||||
export const validateTalkSessionCancelOutputParams = compile(S.TalkSessionCancelOutputParamsSchema);
|
||||
export const validateTalkSessionCancelOutputResult = compile(S.TalkSessionCancelOutputResultSchema);
|
||||
export const validateTalkSessionSteerParams = compile(S.TalkSessionSteerParamsSchema);
|
||||
export const validateTalkSessionSubmitToolResultParams = compile(
|
||||
S.TalkSessionSubmitToolResultParamsSchema,
|
||||
|
||||
@@ -87,6 +87,7 @@ const schemaNames = new Map<string, string>([
|
||||
["SessionGitHubPublicationPublished", "SessionGitHubPublicationPublished"],
|
||||
["SessionGitHubPublicationFailed", "SessionGitHubPublicationFailed"],
|
||||
["SessionGitHubPublicationResult", "SessionGitHubPublicationResult"],
|
||||
["TalkSessionCancelOutputResult", "TalkSessionCancelOutputResult"],
|
||||
]);
|
||||
|
||||
const androidEnums: EnumSpec[] = [
|
||||
|
||||
@@ -432,7 +432,7 @@ export const talkSessionHandlers: GatewayRequestHandlers = {
|
||||
reason: normalizeOptionalString(params.reason) ?? "output-cancelled",
|
||||
turnId: normalizeOptionalString(params.turnId),
|
||||
});
|
||||
respondOk(respond, result);
|
||||
respondOk(respond, { ok: true, ...result });
|
||||
} catch (err) {
|
||||
respondUnavailable(respond, err);
|
||||
}
|
||||
|
||||
@@ -1748,7 +1748,7 @@ describe("talk.session unified handlers", () => {
|
||||
reason: "barge-in",
|
||||
turnId: "turn-7",
|
||||
});
|
||||
expectRespondOk(cancelRespond, { status: "applied", turnId: "turn-7" });
|
||||
expectRespondOk(cancelRespond, { ok: true, status: "applied", turnId: "turn-7" });
|
||||
for (const status of ["stale", "idle"] as const) {
|
||||
const nonAppliedRespond = vi.fn();
|
||||
mocks.cancelTalkRealtimeRelayTurn.mockResolvedValueOnce({ status });
|
||||
@@ -1758,7 +1758,7 @@ describe("talk.session unified handlers", () => {
|
||||
respond: nonAppliedRespond,
|
||||
context: {},
|
||||
});
|
||||
expectRespondOk(nonAppliedRespond, { status });
|
||||
expectRespondOk(nonAppliedRespond, { ok: true, status });
|
||||
}
|
||||
expect(mocks.cancelTalkRealtimeRelayTurn).toHaveBeenLastCalledWith({
|
||||
relaySessionId: "relay-unified-1",
|
||||
|
||||
Reference in New Issue
Block a user