Skip to content

Commit b6fd32e

Browse files
committed
fix(ios): simplify talk fallback issue
1 parent 6b84b98 commit b6fd32e

10 files changed

Lines changed: 49 additions & 201 deletions

apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,27 +67,11 @@ struct TalkRuntimeIssueBanner: View {
6767
}
6868

6969
private var iconName: String {
70-
switch self.issue.code {
71-
case .credentialInvalid, .credentialMissing, .gatewayConfigInvalid:
72-
"key.slash.fill"
73-
case .networkError:
74-
"wifi.exclamationmark"
75-
case .modelUnavailable, .providerUnavailable:
76-
"waveform.badge.exclamationmark"
77-
case .providerClosedBeforeReady, .unknown:
78-
"exclamationmark.triangle.fill"
79-
}
70+
"exclamationmark.triangle.fill"
8071
}
8172

8273
private var tint: Color {
83-
switch self.issue.code {
84-
case .networkError, .providerClosedBeforeReady:
85-
.yellow
86-
case .providerUnavailable:
87-
.orange
88-
default:
89-
.red
90-
}
74+
.orange
9175
}
9276
}
9377

apps/ios/Sources/Voice/RealtimeTalkRelaySession.swift

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ final class RealtimeTalkRelaySession {
388388
self.onStatus("Ready")
389389
} else if !self.hasReceivedFailure {
390390
let issue = TalkRuntimeIssue(
391-
code: .providerClosedBeforeReady,
391+
code: .realtimeUnavailable,
392392
message: "Realtime closed before it became ready.",
393393
provider: self.options.provider,
394394
model: self.options.model,
@@ -441,7 +441,7 @@ final class RealtimeTalkRelaySession {
441441
return
442442
}
443443
let issue = TalkRuntimeIssue(
444-
code: .providerUnavailable,
444+
code: .realtimeUnavailable,
445445
message: "Realtime did not become ready in time.",
446446
provider: self.options.provider,
447447
model: self.options.model,
@@ -460,22 +460,11 @@ final class RealtimeTalkRelaySession {
460460
fallbackProvider: String?,
461461
fallbackModel: String?) -> TalkRuntimeIssue
462462
{
463-
let code = payload["code"]?.stringValue
464-
.flatMap { TalkRuntimeIssue.Code(rawValue: $0.trimmingCharacters(in: .whitespacesAndNewlines)) }
465463
let provider = payload["provider"]?.stringValue ?? fallbackProvider
466464
let model = payload["model"]?.stringValue ?? fallbackModel
467465
let transport = payload["transport"]?.stringValue ?? "gateway-relay"
468466
let phase = payload["phase"]?.stringValue
469-
if let code {
470-
return TalkRuntimeIssue(
471-
code: code,
472-
message: fallbackMessage,
473-
provider: provider,
474-
model: model,
475-
transport: transport,
476-
phase: phase)
477-
}
478-
return TalkRuntimeIssue.classify(
467+
return TalkRuntimeIssue.realtimeUnavailable(
479468
message: fallbackMessage,
480469
provider: provider,
481470
model: model,

apps/ios/Sources/Voice/TalkModeGatewayConfig.swift

Lines changed: 7 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ enum TalkModeExecutionMode {
99

1010
struct TalkRuntimeIssue: Equatable {
1111
enum Code: String {
12-
case credentialInvalid = "credential_invalid"
13-
case credentialMissing = "credential_missing"
14-
case providerUnavailable = "provider_unavailable"
15-
case modelUnavailable = "model_unavailable"
16-
case gatewayConfigInvalid = "gateway_config_invalid"
17-
case networkError = "network_error"
18-
case providerClosedBeforeReady = "provider_closed_before_ready"
19-
case unknown
12+
case realtimeUnavailable = "realtime_unavailable"
2013
}
2114

2215
let code: Code
@@ -47,24 +40,7 @@ struct TalkRuntimeIssue: Equatable {
4740

4841
var displayMessage: String {
4942
if !self.message.isEmpty { return self.message }
50-
switch self.code {
51-
case .credentialInvalid:
52-
return "Realtime credentials were rejected."
53-
case .credentialMissing:
54-
return "Realtime credentials are missing."
55-
case .providerUnavailable:
56-
return "The realtime provider is unavailable."
57-
case .modelUnavailable:
58-
return "The realtime model is unavailable."
59-
case .gatewayConfigInvalid:
60-
return "Gateway realtime configuration is invalid."
61-
case .networkError:
62-
return "Realtime network connection failed."
63-
case .providerClosedBeforeReady:
64-
return "Realtime closed before it became ready."
65-
case .unknown:
66-
return "Realtime voice failed."
67-
}
43+
return "Realtime voice did not start."
6844
}
6945

7046
var fallbackStatusText: String {
@@ -76,18 +52,11 @@ struct TalkRuntimeIssue: Equatable {
7652
}
7753

7854
var fallbackBannerOwnerLabel: String {
79-
switch self.code {
80-
case .credentialInvalid, .credentialMissing, .providerUnavailable, .modelUnavailable, .gatewayConfigInvalid:
81-
"Fix on gateway"
82-
case .networkError:
83-
"Check network"
84-
case .providerClosedBeforeReady, .unknown:
85-
"Needs attention"
86-
}
55+
"Fallback active"
8756
}
8857

8958
var fallbackBannerMessage: String {
90-
"Realtime voice is unavailable. Talk is still running with iOS speech recognition and TTS."
59+
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS."
9160
}
9261

9362
var technicalDetails: String {
@@ -111,34 +80,15 @@ struct TalkRuntimeIssue: Equatable {
11180
return parts.joined(separator: "")
11281
}
11382

114-
static func classify(
83+
static func realtimeUnavailable(
11584
message: String,
11685
provider: String? = nil,
11786
model: String? = nil,
11887
transport: String? = nil,
11988
phase: String? = nil) -> TalkRuntimeIssue
12089
{
121-
let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
122-
let code: Code = if normalized.contains("api key") || normalized.contains("unauthorized") || normalized
123-
.contains("401")
124-
{
125-
normalized.contains("missing") ? .credentialMissing : .credentialInvalid
126-
} else if normalized.contains("credential") || normalized.contains("auth") {
127-
normalized.contains("missing") ? .credentialMissing : .credentialInvalid
128-
} else if normalized.contains("model") && (
129-
normalized.contains("not found") || normalized.contains("unsupported") || normalized.contains(
130-
"unavailable"))
131-
{
132-
.modelUnavailable
133-
} else if normalized.contains("network") || normalized.contains("socket") || normalized.contains("connection") {
134-
.networkError
135-
} else if normalized.contains("provider") || normalized.contains("unavailable") {
136-
.providerUnavailable
137-
} else {
138-
.unknown
139-
}
140-
return TalkRuntimeIssue(
141-
code: code,
90+
TalkRuntimeIssue(
91+
code: .realtimeUnavailable,
14292
message: message,
14393
provider: provider,
14494
model: model,

apps/ios/Sources/Voice/TalkModeManager.swift

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2466,7 +2466,7 @@ extension TalkModeManager {
24662466
}
24672467

24682468
private func realtimeIssue(message: String, phase: String) -> TalkRuntimeIssue {
2469-
TalkRuntimeIssue.classify(
2469+
TalkRuntimeIssue.realtimeUnavailable(
24702470
message: message,
24712471
provider: self.realtimeProvider,
24722472
model: self.realtimeModelId,
@@ -2496,23 +2496,12 @@ extension TalkModeManager {
24962496
fallbackPhase: String) -> TalkRuntimeIssue?
24972497
{
24982498
guard let rawIssue = gatewayError.details["talkIssue"]?.dictionaryValue else { return nil }
2499-
let rawCode = rawIssue["code"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
2500-
let code = rawCode.flatMap(TalkRuntimeIssue.Code.init(rawValue:))
25012499
let message = rawIssue["message"]?.stringValue ?? gatewayError.message
25022500
let provider = rawIssue["provider"]?.stringValue ?? fallbackProvider
25032501
let model = rawIssue["model"]?.stringValue ?? fallbackModel
25042502
let transport = rawIssue["transport"]?.stringValue ?? fallbackTransport
25052503
let phase = rawIssue["phase"]?.stringValue ?? fallbackPhase
2506-
guard let code else {
2507-
return TalkRuntimeIssue.classify(
2508-
message: message,
2509-
provider: provider,
2510-
model: model,
2511-
transport: transport,
2512-
phase: phase)
2513-
}
2514-
return TalkRuntimeIssue(
2515-
code: code,
2504+
return TalkRuntimeIssue.realtimeUnavailable(
25162505
message: message,
25172506
provider: provider,
25182507
model: model,

apps/ios/Tests/RealtimeTalkRelaySessionTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
5858
"relaySessionId": "relay-1",
5959
"type": "error",
6060
"message": "OpenAI API key rejected with 401",
61-
"code": "credential_invalid",
61+
"code": "realtime_unavailable",
6262
"provider": "openai",
6363
"model": "gpt-realtime-2",
6464
"transport": "gateway-relay",
@@ -77,7 +77,7 @@ private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
7777
seq: nil,
7878
stateversion: nil))
7979

80-
#expect(issues.map(\.code) == [.credentialInvalid])
80+
#expect(issues.map(\.code) == [.realtimeUnavailable])
8181
#expect(statuses == ["OpenAI API key rejected with 401"])
8282
}
8383

apps/ios/Tests/TalkModeConfigParsingTests.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,28 +184,31 @@ import Testing
184184
#expect(manager._test_gatewayTalkUsesRealtimeRelay())
185185
}
186186

187-
@Test func classifiesRealtimeAuthFailureForDisplay() {
188-
let issue = TalkRuntimeIssue.classify(
187+
@Test func buildsGenericRealtimeFallbackIssueForDisplay() {
188+
let issue = TalkRuntimeIssue.realtimeUnavailable(
189189
message: "OpenAI API key rejected with 401",
190190
provider: "openai",
191191
model: "gpt-realtime-2",
192192
transport: "gateway-relay",
193193
phase: "start")
194194

195-
#expect(issue.code == .credentialInvalid)
195+
#expect(issue.code == .realtimeUnavailable)
196196
#expect(issue.displayMessage == "OpenAI API key rejected with 401")
197197
#expect(issue.diagnosticSummary.contains("provider: openai"))
198198
#expect(issue.diagnosticSummary.contains("model: gpt-realtime-2"))
199199
#expect(issue.fallbackStatusText == "Listening (iOS Speech fallback)")
200200
#expect(issue.fallbackBannerTitle == "Using iOS Speech fallback")
201-
#expect(issue.fallbackBannerOwnerLabel == "Fix on gateway")
202-
#expect(issue.technicalDetails.contains("code: credential_invalid"))
201+
#expect(issue.fallbackBannerOwnerLabel == "Fallback active")
202+
#expect(issue
203+
.fallbackBannerMessage ==
204+
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS.")
205+
#expect(issue.technicalDetails.contains("code: realtime_unavailable"))
203206
}
204207

205208
@Test func nativeFallbackKeepsRealtimeIssueVisible() {
206209
let manager = TalkModeManager(allowSimulatorCapture: true)
207210
let issue = TalkRuntimeIssue(
208-
code: .providerClosedBeforeReady,
211+
code: .realtimeUnavailable,
209212
message: "Realtime closed before it became ready.",
210213
provider: "openai",
211214
model: "gpt-realtime-2",
@@ -229,7 +232,7 @@ import Testing
229232
message: "Error: OpenAI API key rejected with 401",
230233
details: [
231234
"talkIssue": AnyCodable([
232-
"code": "credential_invalid",
235+
"code": "realtime_unavailable",
233236
"message": "OpenAI API key rejected with 401",
234237
"provider": "openai",
235238
"model": "gpt-realtime-2",
@@ -240,7 +243,7 @@ import Testing
240243

241244
let issue = manager._test_realtimeIssue(from: error, phase: "start")
242245

243-
#expect(issue.code == .credentialInvalid)
246+
#expect(issue.code == .realtimeUnavailable)
244247
#expect(issue.displayMessage == "OpenAI API key rejected with 401")
245248
#expect(issue.provider == "openai")
246249
#expect(issue.model == "gpt-realtime-2")
@@ -251,7 +254,7 @@ import Testing
251254
@Test func relayStartupIssueSurvivesUntilReadyStatus() {
252255
let manager = TalkModeManager(allowSimulatorCapture: true)
253256
let issue = TalkRuntimeIssue(
254-
code: .credentialInvalid,
257+
code: .realtimeUnavailable,
255258
message: "OpenAI API key rejected with 401",
256259
provider: "openai",
257260
model: "gpt-realtime-2",
@@ -288,7 +291,7 @@ import Testing
288291
@Test func relayRetryClearsStaleFallbackTriggerButKeepsLastIssueVisible() {
289292
let manager = TalkModeManager(allowSimulatorCapture: true)
290293
let issue = TalkRuntimeIssue(
291-
code: .providerClosedBeforeReady,
294+
code: .realtimeUnavailable,
292295
message: "Realtime closed before it became ready.",
293296
provider: "openai",
294297
model: "gpt-realtime-2",

src/gateway/server-methods/talk-session.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ function respondUnavailable(respond: RespondFn, err: unknown) {
151151
errorShape(ErrorCodes.UNAVAILABLE, message, {
152152
details: {
153153
talkIssue: {
154-
code: classifyTalkUnavailableIssue(message),
154+
code: "realtime_unavailable",
155155
message,
156156
phase: "request",
157157
},
@@ -160,37 +160,6 @@ function respondUnavailable(respond: RespondFn, err: unknown) {
160160
);
161161
}
162162

163-
function classifyTalkUnavailableIssue(message: string): string {
164-
const lower = message.toLowerCase();
165-
if (
166-
lower.includes("api key") ||
167-
lower.includes("unauthorized") ||
168-
lower.includes("401") ||
169-
lower.includes("credential") ||
170-
lower.includes("auth")
171-
) {
172-
return lower.includes("missing") ? "credential_missing" : "credential_invalid";
173-
}
174-
if (
175-
lower.includes("model") &&
176-
(lower.includes("not found") || lower.includes("unsupported") || lower.includes("unavailable"))
177-
) {
178-
return "model_unavailable";
179-
}
180-
if (
181-
lower.includes("network") ||
182-
lower.includes("socket") ||
183-
lower.includes("connection") ||
184-
lower.includes("closed")
185-
) {
186-
return "network_error";
187-
}
188-
if (lower.includes("provider") || lower.includes("unavailable")) {
189-
return "provider_unavailable";
190-
}
191-
return "unknown";
192-
}
193-
194163
function respondOk(respond: RespondFn, payload: unknown = { ok: true }) {
195164
respond(true, payload, undefined);
196165
}

src/gateway/server-methods/talk.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ describe("talk.session unified handlers", () => {
788788
message: "Error: OpenAI API key rejected with 401",
789789
});
790790
expectRecordFields((error.details as Record<string, unknown>).talkIssue, {
791-
code: "credential_invalid",
791+
code: "realtime_unavailable",
792792
message: "Error: OpenAI API key rejected with 401",
793793
phase: "request",
794794
});

src/gateway/talk-realtime-relay.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ describe("talk realtime gateway relay", () => {
492492
expectRecordFields(closePayload.talkEvent, { type: "session.closed", final: true });
493493
});
494494

495-
it("emits classified issue details when relay connect fails", async () => {
495+
it("emits generic issue details when relay connect fails", async () => {
496496
const provider: RealtimeVoiceProviderPlugin = {
497497
id: "openai",
498498
label: "OpenAI Realtime",
@@ -533,7 +533,7 @@ describe("talk realtime gateway relay", () => {
533533
relaySessionId: session.relaySessionId,
534534
type: "error",
535535
message: "OpenAI API key rejected with 401",
536-
code: "credential_invalid",
536+
code: "realtime_unavailable",
537537
provider: "openai",
538538
model: "gpt-realtime-2",
539539
transport: "gateway-relay",
@@ -544,7 +544,7 @@ describe("talk realtime gateway relay", () => {
544544
final: true,
545545
});
546546
expectRecordFields((errorPayload.talkEvent as Record<string, unknown>).payload, {
547-
code: "credential_invalid",
547+
code: "realtime_unavailable",
548548
provider: "openai",
549549
model: "gpt-realtime-2",
550550
transport: "gateway-relay",
@@ -594,7 +594,7 @@ describe("talk realtime gateway relay", () => {
594594
expectRecordFields(errorPayload, {
595595
relaySessionId: session.relaySessionId,
596596
type: "error",
597-
code: "provider_closed_before_ready",
597+
code: "realtime_unavailable",
598598
provider: "openai",
599599
model: "gpt-realtime-2",
600600
transport: "gateway-relay",
@@ -658,7 +658,7 @@ describe("talk realtime gateway relay", () => {
658658
expect(errorPayloads).toHaveLength(1);
659659
expectRecordFields(errorPayloads[0], {
660660
type: "error",
661-
code: "credential_invalid",
661+
code: "realtime_unavailable",
662662
provider: "openai",
663663
model: "gpt-realtime-2",
664664
transport: "gateway-relay",

0 commit comments

Comments
 (0)