refactor: require canonical talk resolved payload

This commit is contained in:
Peter Steinberger
2026-03-08 16:08:22 +00:00
parent 87640f9a61
commit 8d3d742c6a
8 changed files with 18 additions and 72 deletions

View File

@@ -93,29 +93,7 @@ class TalkModeManager(
val rawProviders = talk["providers"].asObjectOrNull() val rawProviders = talk["providers"].asObjectOrNull()
val hasNormalizedPayload = rawProvider != null || rawProviders != null val hasNormalizedPayload = rawProvider != null || rawProviders != null
if (hasNormalizedPayload) { if (hasNormalizedPayload) {
val providers = return null
rawProviders?.entries?.mapNotNull { (key, value) ->
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
providerId to providerConfig
}?.toMap().orEmpty()
val explicitProviderId = normalizeTalkProviderId(rawProvider)
if (explicitProviderId != null) {
if (providers.isNotEmpty() && providers[explicitProviderId] == null) {
return null
}
return TalkProviderConfigSelection(
provider = explicitProviderId,
config = providers[explicitProviderId] ?: buildJsonObject {},
normalizedPayload = true,
)
}
val providerId = providers.keys.singleOrNull() ?: return null
return TalkProviderConfigSelection(
provider = providerId,
config = providers[providerId] ?: buildJsonObject {},
normalizedPayload = true,
)
} }
return TalkProviderConfigSelection( return TalkProviderConfigSelection(
provider = defaultTalkProvider, provider = defaultTalkProvider,
@@ -1425,6 +1403,9 @@ class TalkModeManager(
val config = root?.get("config").asObjectOrNull() val config = root?.get("config").asObjectOrNull()
val talk = config?.get("talk").asObjectOrNull() val talk = config?.get("talk").asObjectOrNull()
val selection = selectTalkProviderConfig(talk) val selection = selectTalkProviderConfig(talk)
if (talk != null && selection == null) {
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
}
val activeProvider = selection?.provider ?: defaultTalkProvider val activeProvider = selection?.provider ?: defaultTalkProvider
val activeConfig = selection?.config val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull() val sessionCfg = config?.get("session").asObjectOrNull()

View File

@@ -62,10 +62,7 @@ class TalkModeConfigParsingTest {
.jsonObject .jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk) val selection = TalkModeManager.selectTalkProviderConfig(talk)
assertNotNull(selection) assertEquals(null, selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
} }
@Test @Test

View File

@@ -1996,7 +1996,7 @@ extension TalkModeManager {
let selection = Self.selectTalkProviderConfig(talk) let selection = Self.selectTalkProviderConfig(talk)
if talk != nil, selection == nil { if talk != nil, selection == nil {
GatewayDiagnostics.log( GatewayDiagnostics.log(
"talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers") "talk config ignored: normalized payload missing talk.resolved")
} }
let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config let activeConfig = selection?.config

View File

@@ -5,7 +5,7 @@ import Testing
private let iOSSilenceTimeoutMs = 900 private let iOSSilenceTimeoutMs = 900
@Suite struct TalkConfigParsingTests { @Suite struct TalkConfigParsingTests {
@Test func prefersNormalizedTalkProviderPayload() { @Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: Any] = [ let talk: [String: Any] = [
"provider": "elevenlabs", "provider": "elevenlabs",
"providers": [ "providers": [
@@ -20,8 +20,7 @@ private let iOSSilenceTimeoutMs = 900
TalkConfigParsing.bridgeFoundationDictionary(talk), TalkConfigParsing.bridgeFoundationDictionary(talk),
defaultProvider: "elevenlabs", defaultProvider: "elevenlabs",
allowLegacyFallback: false) allowLegacyFallback: false)
#expect(selection?.provider == "elevenlabs") #expect(selection == nil)
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
} }
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() { @Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {

View File

@@ -831,6 +831,9 @@ extension TalkModeRuntime {
timeoutMs: 8000) timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue let talk = snap.config?["talk"]?.dictionaryValue
let selection = Self.selectTalkProviderConfig(talk) let selection = Self.selectTalkProviderConfig(talk)
if talk != nil, selection == nil {
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
}
let activeProvider = selection?.provider ?? Self.defaultTalkProvider let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config let activeConfig = selection?.config
let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk) let silenceTimeoutMs = Self.resolvedSilenceTimeoutMs(talk)
@@ -870,7 +873,7 @@ extension TalkModeRuntime {
self.ttsLogger self.ttsLogger
.info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice") .info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice")
} else if selection?.normalizedPayload == true { } else if selection?.normalizedPayload == true {
self.ttsLogger.info("talk config provider elevenlabs") self.ttsLogger.info("talk config provider from talk.resolved")
} }
return TalkRuntimeConfig( return TalkRuntimeConfig(
voiceId: resolvedVoice, voiceId: resolvedVoice,

View File

@@ -3,7 +3,7 @@ import Testing
@testable import OpenClaw @testable import OpenClaw
struct TalkModeConfigParsingTests { struct TalkModeConfigParsingTests {
@Test func `prefers normalized talk provider payload`() { @Test func `rejects normalized talk provider payload without resolved`() {
let talk: [String: AnyCodable] = [ let talk: [String: AnyCodable] = [
"provider": AnyCodable("elevenlabs"), "provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([ "providers": AnyCodable([
@@ -15,9 +15,7 @@ struct TalkModeConfigParsingTests {
] ]
let selection = TalkModeRuntime.selectTalkProviderConfig(talk) let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
#expect(selection?.provider == "elevenlabs") #expect(selection == nil)
#expect(selection?.normalizedPayload == true)
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
} }
@Test func `falls back to legacy talk fields when normalized payload missing`() { @Test func `falls back to legacy talk fields when normalized payload missing`() {

View File

@@ -26,28 +26,9 @@ public enum TalkConfigParsing {
if let resolvedSelection = self.resolvedProviderConfig(talk) { if let resolvedSelection = self.resolvedProviderConfig(talk) {
return resolvedSelection return resolvedSelection
} }
let rawProvider = talk["provider"]?.stringValue let hasNormalizedPayload = talk["provider"] != nil || talk["providers"] != nil
let rawProviders = talk["providers"]
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
if hasNormalizedPayload { if hasNormalizedPayload {
let normalizedProviders = self.normalizedTalkProviders(rawProviders) return nil
let explicitProviderID = self.normalizedTalkProviderID(rawProvider)
if let explicitProviderID {
if !normalizedProviders.isEmpty, normalizedProviders[explicitProviderID] == nil {
return nil
}
return TalkProviderConfigSelection(
provider: explicitProviderID,
config: normalizedProviders[explicitProviderID] ?? [:],
normalizedPayload: true)
}
guard normalizedProviders.count == 1, let providerID = normalizedProviders.keys.first else {
return nil
}
return TalkProviderConfigSelection(
provider: providerID,
config: normalizedProviders[providerID] ?? [:],
normalizedPayload: true)
} }
guard allowLegacyFallback else { return nil } guard allowLegacyFallback else { return nil }
return TalkProviderConfigSelection( return TalkProviderConfigSelection(
@@ -92,15 +73,4 @@ public enum TalkConfigParsing {
config: resolved["config"]?.dictionaryValue ?? [:], config: resolved["config"]?.dictionaryValue ?? [:],
normalizedPayload: true) normalizedPayload: true)
} }
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
guard let providerMap = raw?.dictionaryValue else { return [:] }
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
guard
let providerID = self.normalizedTalkProviderID(entry.key),
let providerConfig = entry.value.dictionaryValue
else { return }
acc[providerID] = providerConfig
}
}
} }

View File

@@ -24,7 +24,7 @@ struct TalkConfigParsingTests {
#expect(selection?.config["voiceId"]?.stringValue == "voice-resolved") #expect(selection?.config["voiceId"]?.stringValue == "voice-resolved")
} }
@Test func prefersNormalizedTalkProviderPayload() { @Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
let talk: [String: AnyCodable] = [ let talk: [String: AnyCodable] = [
"provider": AnyCodable("elevenlabs"), "provider": AnyCodable("elevenlabs"),
"providers": AnyCodable([ "providers": AnyCodable([
@@ -36,9 +36,7 @@ struct TalkConfigParsingTests {
] ]
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs") let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
#expect(selection?.provider == "elevenlabs") #expect(selection == nil)
#expect(selection?.normalizedPayload == true)
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
} }
@Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() { @Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {