From df7e6f1c44da23513fe41cf693f0a12da5be2405 Mon Sep 17 00:00:00 2001 From: zhilong1115 <123119628+zhilong1115@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:34:23 -0700 Subject: [PATCH] macOS: surface realtime Talk settings (#118505) * feat(talk): link realtime settings surfaces Co-authored-by: Zhilong Zheng * fix(talk): clear forced routing for GPT-Live relay Co-authored-by: Zhilong Zheng --------- Co-authored-by: Vincent Koc --- apps/.i18n/native-source.json | 37 ++ .../Sources/OpenClaw/MenuContentView.swift | 5 + .../Sources/OpenClaw/VoiceWakeSettings.swift | 14 + .../OpenClawKit/DashboardRouteMap.swift | 1 + .../DashboardRouteMapTests.swift | 3 +- docs/nodes/talk.md | 6 +- ui/src/pages/config/talk-page.ts | 78 +++- ui/src/pages/config/talk-schema.ts | 21 +- ui/src/pages/config/talk.test.ts | 363 +++++++++++++++++- ui/src/pages/config/talk.ts | 2 +- 10 files changed, 503 insertions(+), 27 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index c78097d42af6..79c017bd1259 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -23842,6 +23842,17 @@ } ] }, + { + "id": "native.apple.55154d93c122c1ac", + "source": "Choose the realtime provider, model, voice, and transport in the Control UI.", + "surface": "apple", + "sites": [ + { + "kind": "ui-named-argument", + "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift" + } + ] + }, { "id": "native.apple.e4b13f49bb435884", "source": "Choose where the Gateway runs and how this Mac app reaches it.", @@ -36503,6 +36514,10 @@ { "kind": "ui-call", "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift" + }, + { + "kind": "ui-call", + "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift" } ] }, @@ -45630,6 +45645,17 @@ } ] }, + { + "id": "native.apple.c7d73b77de59d303", + "source": "Talk configuration", + "surface": "apple", + "sites": [ + { + "kind": "ui-named-argument", + "path": "apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift" + } + ] + }, { "id": "native.apple.1da1f8b0f2cf232d", "source": "Talk failed: %@", @@ -48915,6 +48941,17 @@ } ] }, + { + "id": "native.apple.2ece20cb05d073cd", + "source": "Voice & Talk Settings…", + "surface": "apple", + "sites": [ + { + "kind": "ui-call", + "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift" + } + ] + }, { "id": "native.apple.18b5679912dac598", "source": "Voice Mode", diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index 8729e030c064..c7ab14f564d2 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -131,6 +131,11 @@ struct MenuContent: View { if self.showVoiceWakeMicPicker { self.voiceWakeMicMenu } + Button { + self.open(tab: .voiceWake) + } label: { + Label("Voice & Talk Settings…", systemImage: "slider.horizontal.3") + } Divider() Button { AppNavigationActions.openDashboard() diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift index 195839891861..186b2c2cb94e 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift @@ -1,6 +1,7 @@ import AppKit import AVFoundation import Observation +import OpenClawKit import Speech import SwabbleKit import SwiftUI @@ -184,6 +185,19 @@ struct VoiceWakeSettings: View { self.realtimeRelayToggle + SettingsCardRow( + title: "Talk configuration", + subtitle: "Choose the realtime provider, model, voice, and transport in the Control UI.") + { + Button("Open in Dashboard") { + Task { + await DashboardManager.shared.show( + atPath: DashboardRouteMap.talkSettingsPath) + } + } + .buttonStyle(.link) + } + if self.state.voicePushToTalkEnabled, self.state.talkEnabled { SettingsCardRow( title: "Push-to-talk paused", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift index f466beaf5ea6..2c7cd02354f0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DashboardRouteMap.swift @@ -2,6 +2,7 @@ import Foundation public enum DashboardRouteMap { public static let channelsSettingsPath = "/settings/channels" + public static let talkSettingsPath = "/settings/talk" public static let skillsPagePath = "/skills" public static let cronJobsPagePath = "/cron" public static let sessionsPagePath = "/sessions" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift index b6d00ea29051..5c0b053813af 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DashboardRouteMapTests.swift @@ -5,13 +5,14 @@ import Testing struct DashboardRouteMapTests { @Test func `route constants match Control UI paths`() { #expect(DashboardRouteMap.channelsSettingsPath == "/settings/channels") + #expect(DashboardRouteMap.talkSettingsPath == "/settings/talk") #expect(DashboardRouteMap.skillsPagePath == "/skills") #expect(DashboardRouteMap.cronJobsPagePath == "/cron") #expect(DashboardRouteMap.sessionsPagePath == "/sessions") #expect(DashboardRouteMap.devicesSettingsPath == "/settings/devices") } - @Test(arguments: ["/settings/channels", "/skills", "/cron"]) + @Test(arguments: ["/settings/channels", "/settings/talk", "/skills", "/cron"]) func `same-app path validation accepts rooted paths`(_ path: String) { #expect(DashboardRouteMap.isValidSameAppPath(path)) } diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index 4fe2449348a4..1f8e892373e1 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -268,8 +268,10 @@ to waitlist-enabled Platform access. ## macOS UI -- Menu bar toggle: **Talk** -- Config tab: **Talk Mode** group (voice id + interrupt toggle) +- Menu bar: **Voice & Talk Settings…** opens the native **Voice & Talk** settings page. +- Native settings: **Use realtime Gateway relay** is a local, default-off opt-in for this Mac. +- **Open in Dashboard** hands provider, model, voice, and transport setup to Control UI **Settings → Talk** under **Connections**. +- Menu bar: **Talk Mode** starts or stops the current Talk session. - Overlay: the orb renders the universal talk waveform (shared with iOS, watchOS, and Android). Listening follows the live mic level, Speaking follows the actual TTS playback envelope, Thinking breathes softly. Click the orb to pause/resume, double-click to stop speaking, click X to exit Talk mode. ## Android UI diff --git a/ui/src/pages/config/talk-page.ts b/ui/src/pages/config/talk-page.ts index a3ea5f3fe276..91aad6a8a8f4 100644 --- a/ui/src/pages/config/talk-page.ts +++ b/ui/src/pages/config/talk-page.ts @@ -8,8 +8,13 @@ import { property, state } from "lit/decorators.js"; import { applicationContext, type ApplicationContext } from "../../app/context.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; -import { isTalkGptLiveModel, resolveTalkRealtimeSelection } from "./talk-schema.ts"; import { + isTalkGptLiveModel, + resolveTalkRealtimeSelection, + talkProviderRejectsTransport, +} from "./talk-schema.ts"; +import { + effectiveTalkValues, renderTalk, selectedTalkProviderOption, talkProviderConfigKeys, @@ -54,6 +59,10 @@ function toProviderOption( /** Transports whose sessions are client-owned (`talk.client.create`). */ const TALK_CLIENT_OWNED_TRANSPORTS = new Set(["webrtc", "provider-websocket"]); +function gptLiveRejectsTransport(model: string | null, transport: string): boolean { + return isTalkGptLiveModel(model) && transport === "provider-websocket"; +} + class TalkSettingsPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -196,12 +205,24 @@ class TalkSettingsPage extends OpenClawLightDomElement { const runtimeConfig = this.context.runtimeConfig; if (model !== null) { runtimeConfig.patchForm(["talk", "realtime", "model"], model); - // GPT-Live is WebRTC-only; a configured relay or provider-websocket - // transport would make the just-picked model fail at session create, so - // clearing it lets the default client-owned WebRTC path apply. - const transport = this.liveSelection().transport; - if (isTalkGptLiveModel(model) && transport && transport !== "webrtc") { + const selection = this.liveSelection(); + const transport = selection.transport; + const provider = selectedTalkProviderOption(this.catalog, selection); + const rejectsTransport = + transport !== null && + (gptLiveRejectsTransport(model, transport) || + talkProviderRejectsTransport(provider?.transports, transport)); + // Preserve configured transports unless the selected provider positively + // advertises that it cannot serve them. + if (isTalkGptLiveModel(model) && rejectsTransport) { runtimeConfig.removeFormValue(["talk", "realtime", "transport"]); + } else if ( + provider?.id === "openai" && + isTalkGptLiveModel(model) && + transport === "gateway-relay" && + selection.consultRouting === "force-agent-consult" + ) { + runtimeConfig.removeFormValue(["talk", "realtime", "consultRouting"]); } return; } @@ -247,17 +268,17 @@ class TalkSettingsPage extends OpenClawLightDomElement { } /** - * Model, voice, and transport picks are provider-coupled (an xAI session - * cannot use a gpt-live model, marin, or webrtc), so a provider switch - * clears the top-level overrides instead of carrying them across. Each - * provider's own `talk.realtime.providers.` entry survives untouched and - * supplies that provider's fallback values. + * Model and voice picks are provider-coupled, so a provider switch clears + * those top-level overrides. Transport survives when the target provider + * advertises it; an unavailable catalog is not evidence of incompatibility. + * Each provider's own entry survives and supplies its fallback values. */ private changeProvider(providerId: string | null) { if (this.mutationDisabled) { return; } const runtimeConfig = this.context.runtimeConfig; + const selection = this.liveSelection(); for (const key of ["model", "speakerVoice", "speakerVoiceId"]) { runtimeConfig.removeFormValue(["talk", "realtime", key]); } @@ -268,21 +289,42 @@ class TalkSettingsPage extends OpenClawLightDomElement { runtimeConfig.removeFormValue(["talk", "realtime", "provider"]); return; } - runtimeConfig.removeFormValue(["talk", "realtime", "transport"]); - runtimeConfig.patchForm(["talk", "realtime", "provider"], providerId); - // A relay-only provider (no client-owned transport) needs the transport - // written explicitly: an unset transport routes to talk.client.create, - // which such a provider cannot serve. + const configuredTransport = selection.transport; const option = this.catalog.kind === "ready" ? this.catalog.providers.find((provider) => provider.id === providerId) : undefined; + const targetModel = + effectiveTalkValues( + { ...selection, provider: providerId, model: null, speakerVoice: null }, + option, + ).model ?? option?.defaultModel; + const rejectsTransport = + configuredTransport !== null && + (gptLiveRejectsTransport(targetModel ?? null, configuredTransport) || + talkProviderRejectsTransport(option?.transports, configuredTransport)); + if (rejectsTransport) { + runtimeConfig.removeFormValue(["talk", "realtime", "transport"]); + } + runtimeConfig.patchForm(["talk", "realtime", "provider"], providerId); + // A relay-only provider (no client-owned transport) needs the transport + // written explicitly when the current selection cannot carry across. const relayOnly = option !== undefined && option.transports.length > 0 && - !option.transports.some((transport) => TALK_CLIENT_OWNED_TRANSPORTS.has(transport)); - if (relayOnly) { + !option.transports.some((candidate) => TALK_CLIENT_OWNED_TRANSPORTS.has(candidate)); + let resultingTransport = rejectsTransport ? null : configuredTransport; + if (relayOnly && configuredTransport !== "gateway-relay") { runtimeConfig.patchForm(["talk", "realtime", "transport"], "gateway-relay"); + resultingTransport = "gateway-relay"; + } + if ( + option?.id === "openai" && + isTalkGptLiveModel(targetModel ?? null) && + resultingTransport === "gateway-relay" && + selection.consultRouting === "force-agent-consult" + ) { + runtimeConfig.removeFormValue(["talk", "realtime", "consultRouting"]); } } diff --git a/ui/src/pages/config/talk-schema.ts b/ui/src/pages/config/talk-schema.ts index e2890e5450e1..fb0ff3c130b7 100644 --- a/ui/src/pages/config/talk-schema.ts +++ b/ui/src/pages/config/talk-schema.ts @@ -19,6 +19,8 @@ export type TalkRealtimeSelection = { speakerVoice: string | null; /** Raw configured `talk.realtime.transport`. */ transport: string | null; + /** Normalized configured `talk.realtime.consultRouting`. */ + consultRouting: string | null; /** Per-provider fallback values keyed by the raw config map key. */ providerEntries: Record; }; @@ -52,15 +54,26 @@ export function resolveTalkRealtimeSelection( speakerVoice: readTrimmedString(realtime?.speakerVoice) ?? readTrimmedString(realtime?.speakerVoiceId), transport: readTrimmedString(realtime?.transport), + consultRouting: readTrimmedString(realtime?.consultRouting)?.toLowerCase() ?? null, providerEntries, }; } /** - * Mirrors the server-side gpt-live prefix contract - * (extensions/openai/realtime-quicksilver.ts); the UI only uses it to decide - * whether to show the ChatGPT sign-in hint, never to gate a session. + * Mirrors the server-side gpt-live family contract + * (extensions/openai/realtime-quicksilver.ts); the UI uses it for the ChatGPT + * sign-in hint and to avoid retaining a transport the selected provider + * positively rejects. It never gates a session. */ export function isTalkGptLiveModel(model: string | null): boolean { - return model !== null && model.toLowerCase().startsWith("gpt-live"); + const normalized = model?.trim().toLowerCase(); + return normalized === "gpt-live" || normalized?.startsWith("gpt-live-") === true; +} + +/** An empty or unavailable transport catalog is not evidence of incompatibility. */ +export function talkProviderRejectsTransport( + transports: readonly string[] | undefined, + transport: string, +): boolean { + return transports !== undefined && transports.length > 0 && !transports.includes(transport); } diff --git a/ui/src/pages/config/talk.test.ts b/ui/src/pages/config/talk.test.ts index ae92d4f79520..b204c0f576f1 100644 --- a/ui/src/pages/config/talk.test.ts +++ b/ui/src/pages/config/talk.test.ts @@ -1,9 +1,178 @@ /* @vitest-environment jsdom */ +import type { TalkCatalogResult } from "@openclaw/gateway-protocol"; import { html, render } from "lit"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { t } from "../../i18n/index.ts"; +import "./talk-page.ts"; +import { isTalkGptLiveModel, resolveTalkRealtimeSelection } from "./talk-schema.ts"; import { renderTalk } from "./talk.ts"; +type TalkSettingsPageTestElement = HTMLElement & { + context: ApplicationContext; + configObject: Record; + updateComplete: Promise; + changeModel: (model: string | null) => void; + changeProvider: (providerId: string | null) => void; +}; + +type TalkMutationHarnessOptions = { + activeProvider?: string | null; + aliases?: string[]; + consultRouting?: string | null; + defaultModel?: string; + openAIProviderModel?: string; + provider?: string | null; + transport?: string | null; + transports?: string[]; + unavailable?: boolean; +}; + +function createTalkMutationHarness(options: TalkMutationHarnessOptions = {}) { + const request = options.unavailable + ? vi.fn(async () => { + throw new Error("talk.catalog unavailable"); + }) + : vi.fn(async () => { + return { + realtime: { + ready: true, + activeProvider: options.activeProvider ?? "openai", + providers: [ + { + id: "openai", + label: "OpenAI", + configured: true, + aliases: options.aliases ?? [], + models: ["gpt-live-1-boulder-alpha"], + voices: ["marin"], + transports: options.transports ?? ["gateway-relay"], + defaultModel: options.defaultModel ?? "gpt-live-1-boulder-alpha", + }, + { + id: "xai", + label: "xAI", + configured: true, + aliases: [], + models: ["grok-voice"], + voices: ["ara"], + transports: ["gateway-relay"], + defaultModel: "grok-voice", + }, + ], + }, + } as TalkCatalogResult; + }); + const snapshot: ApplicationGatewaySnapshot = { + client: { request } as unknown as GatewayBrowserClient, + phase: "connected", + offlineStable: false, + canvasPluginSurfaceUrl: null, + hello: null, + assistantAgentId: "main", + sessionKey: "main", + lastError: null, + lastErrorCode: null, + }; + const subscribe = () => () => undefined; + const configForm = { + talk: { + realtime: { + provider: options.provider === undefined ? "openai" : options.provider, + model: "gpt-realtime-2.1", + transport: options.transport === undefined ? "gateway-relay" : options.transport, + consultRouting: options.consultRouting, + providers: options.openAIProviderModel + ? { openai: { model: options.openAIProviderModel } } + : undefined, + }, + }, + }; + const runtimeConfig = { + state: { + configForm, + configSnapshot: { hash: "hash" }, + configLoading: false, + configSaving: false, + configApplying: false, + }, + patchForm: vi.fn(), + removeFormValue: vi.fn(), + subscribe, + }; + const context = { + gateway: { snapshot, subscribe }, + runtimeConfig, + } as unknown as ApplicationContext; + const page = document.createElement("openclaw-talk-settings") as TalkSettingsPageTestElement; + page.context = context; + page.configObject = configForm; + document.body.append(page); + return { page, request, runtimeConfig }; +} + +async function selectModel(model: string, options: TalkMutationHarnessOptions = {}) { + const harness = createTalkMutationHarness(options); + await vi.waitFor(() => expect(harness.request).toHaveBeenCalledWith("talk.catalog", {})); + await harness.page.updateComplete; + harness.page.changeModel(model); + expect(harness.runtimeConfig.patchForm).toHaveBeenCalledWith( + ["talk", "realtime", "model"], + model, + ); + return harness.runtimeConfig.removeFormValue; +} + +async function selectProvider(providerId: string, options: TalkMutationHarnessOptions = {}) { + const harness = createTalkMutationHarness(options); + await vi.waitFor(() => expect(harness.request).toHaveBeenCalledWith("talk.catalog", {})); + await harness.page.updateComplete; + harness.page.changeProvider(providerId); + expect(harness.runtimeConfig.patchForm).toHaveBeenCalledWith( + ["talk", "realtime", "provider"], + providerId, + ); + return harness.runtimeConfig.removeFormValue; +} + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe("isTalkGptLiveModel", () => { + it.each(["gpt-live", "gpt-live-1-codex", " GPT-Live-1-Boulder-Alpha "])( + "accepts the GPT-Live family: %s", + (model) => { + expect(isTalkGptLiveModel(model)).toBe(true); + }, + ); + + it.each([null, "", "gpt-liveish", "gpt-lively", "gpt-realtime"])( + "rejects GPT-Live lookalikes: %s", + (model) => { + expect(isTalkGptLiveModel(model)).toBe(false); + }, + ); +}); + +describe("resolveTalkRealtimeSelection", () => { + it.each([ + [" force-agent-consult ", "force-agent-consult"], + [" Provider-Direct ", "provider-direct"], + [" ", null], + [null, null], + ])("normalizes consult routing: %s", (consultRouting, expected) => { + expect( + resolveTalkRealtimeSelection({ + talk: { realtime: { consultRouting } }, + }).consultRouting, + ).toBe(expected); + }); +}); + describe("renderTalk", () => { it("locks every curated picker when config mutation is unavailable", () => { const container = document.createElement("div"); @@ -14,6 +183,7 @@ describe("renderTalk", () => { model: "gpt-live", speakerVoice: "marin", transport: "webrtc", + consultRouting: null, providerEntries: {}, }, catalog: { @@ -64,6 +234,7 @@ describe("renderTalk", () => { model: "gpt-live", speakerVoice: null, transport: "webrtc", + consultRouting: null, providerEntries: {}, }, catalog: { @@ -103,4 +274,194 @@ describe("renderTalk", () => { } expect(onModelChange).toHaveBeenCalledWith("gpt-realtime"); }); + + it.each([ + ["gpt-liveish", false], + ["gpt-lively", false], + ["gpt-live-1-codex", true], + ] as const)("renders the GPT-Live hint only for the exact family: %s", (model, showsHint) => { + const container = document.createElement("div"); + render( + renderTalk({ + selection: { + provider: "openai", + model, + speakerVoice: null, + transport: "gateway-relay", + consultRouting: null, + providerEntries: {}, + }, + catalog: { + kind: "ready", + ready: true, + activeProvider: "openai", + providers: [ + { + id: "openai", + label: "OpenAI", + configured: true, + aliases: [], + models: [model], + voices: [], + transports: ["gateway-relay"], + defaultModel: model, + }, + ], + }, + configBusy: false, + onProviderChange: vi.fn(), + onModelChange: vi.fn(), + onVoiceChange: vi.fn(), + editor: html``, + }), + container, + ); + + expect(container.textContent?.includes(t("talkPage.gptLive.hint"))).toBe(showsHint); + }); +}); + +describe("TalkSettingsPage realtime transport mutation", () => { + it("removes forced consult routing when OpenAI GPT-Live keeps gateway relay", async () => { + const removeFormValue = await selectModel("gpt-live-1-boulder-alpha", { + consultRouting: " Force-Agent-Consult ", + transports: ["gateway-relay"], + }); + + expect(removeFormValue).toHaveBeenCalledTimes(1); + expect(removeFormValue).toHaveBeenCalledWith(["talk", "realtime", "consultRouting"]); + expect(removeFormValue).not.toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }); + + it.each([ + [ + "provider-direct routing", + "gpt-live-1-boulder-alpha", + "provider-direct", + "openai", + "gateway-relay", + ], + ["another model", "gpt-realtime", "force-agent-consult", "openai", "gateway-relay"], + ["another provider", "gpt-live-1-boulder-alpha", "force-agent-consult", "xai", "gateway-relay"], + ["another transport", "gpt-live-1-boulder-alpha", "force-agent-consult", "openai", "webrtc"], + ] as const)( + "preserves consult routing for %s", + async (_label, model, consultRouting, provider, transport) => { + const removeFormValue = await selectModel(model, { + consultRouting, + provider, + transport, + transports: ["gateway-relay", "webrtc"], + }); + + expect(removeFormValue).not.toHaveBeenCalledWith(["talk", "realtime", "consultRouting"]); + }, + ); + + it("preserves transport when switching to a provider that advertises it", async () => { + const removeFormValue = await selectProvider("openai", { + provider: "xai", + transports: ["gateway-relay", "webrtc"], + }); + + expect(removeFormValue).not.toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }); + + it("removes provider websocket when switching to a GPT-Live provider", async () => { + const removeFormValue = await selectProvider("openai", { + provider: "xai", + transport: "provider-websocket", + transports: ["provider-websocket", "webrtc"], + }); + + expect(removeFormValue).toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }); + + it.each([ + ["catalog default", "gpt-live-1-boulder-alpha", undefined], + ["provider fallback", "gpt-realtime-2.1", "gpt-live-1-boulder-alpha"], + ])( + "removes forced consult when a provider switch activates a GPT-Live %s", + async (_label, defaultModel, openAIProviderModel) => { + const removeFormValue = await selectProvider("openai", { + consultRouting: "force-agent-consult", + defaultModel, + openAIProviderModel, + provider: "xai", + transports: ["gateway-relay", "webrtc"], + }); + + expect(removeFormValue).toHaveBeenCalledWith(["talk", "realtime", "consultRouting"]); + expect(removeFormValue).not.toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }, + ); + + it("removes transport when switching to a provider that positively rejects it", async () => { + const removeFormValue = await selectProvider("openai", { + provider: "xai", + transports: ["webrtc"], + }); + + expect(removeFormValue).toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }); + + it("preserves transport when the catalog is unavailable", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { unavailable: true }), + ).not.toHaveBeenCalled(); + }); + + it("preserves transport when the provider advertises no transport capabilities", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { transports: [] }), + ).not.toHaveBeenCalled(); + }); + + it("removes provider websocket from a selected GPT-Live model", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { + transport: "provider-websocket", + transports: ["provider-websocket", "webrtc"], + }), + ).toHaveBeenCalledWith(["talk", "realtime", "transport"]); + }); + + it("preserves a transport advertised by the explicit provider", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { transports: ["gateway-relay"] }), + ).not.toHaveBeenCalled(); + }); + + it("resolves an explicit provider alias before preserving transport", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { + aliases: ["openai-preview"], + provider: "openai-preview", + transports: ["gateway-relay"], + }), + ).not.toHaveBeenCalled(); + }); + + it("uses the auto-selected provider before preserving transport", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { + activeProvider: "openai", + provider: null, + transports: ["gateway-relay"], + }), + ).not.toHaveBeenCalled(); + }); + + it("removes transport only when the resolved provider positively excludes it", async () => { + expect( + await selectModel("gpt-live-1-boulder-alpha", { transports: ["webrtc"] }), + ).toHaveBeenCalledOnce(); + }); + + it.each(["gpt-liveish", "gpt-lively"])( + "preserves transport for GPT-Live lookalikes: %s", + async (model) => { + expect(await selectModel(model, { transports: ["webrtc"] })).not.toHaveBeenCalled(); + }, + ); }); diff --git a/ui/src/pages/config/talk.ts b/ui/src/pages/config/talk.ts index 9bd6887d63a2..e413cb878b8b 100644 --- a/ui/src/pages/config/talk.ts +++ b/ui/src/pages/config/talk.ts @@ -110,7 +110,7 @@ export function talkProviderConfigKeys( } /** Effective model/voice: top-level override, else the provider entry value. */ -function effectiveTalkValues( +export function effectiveTalkValues( selection: TalkRealtimeSelection, option: TalkRealtimeProviderOption | undefined, ): { model: string | null; speakerVoice: string | null } {