c012a364eb
The speak-stream WebSocket resolved its URL through the bare v1 getConnection/getGatewayWsUrl pair, which answers for the PRIMARY backend. When a registry remote connection rides over a machine that also has a local Hermes install (the common case — the installer always installs the full agent), spoken replies dialed the LOCAL backend and hit its unconfigured TTS, while chat (connectionScoped REST) correctly went remote. Users saw 'configure STT/TTS' although their remote gateway had voice fully configured. Resolve the PCM socket through the same (connectionId, profile) bridges store/gateway's openSecondary uses, and never overwrite a backend-namespace profile the registry mint already wrote into the URL (SSH remoteProfile aliasing, sharedRemote scoping). Every REST audio call already carried connectionScoped(); this was the one remaining self-built audio URL. Contract pinned by voice-playback.routing.test.ts (sabotage-verified: 2/4 fail on the old resolver).
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { setApiRequestConnection, setApiRequestProfile } from '@/hermes'
|
|
|
|
import { resolveSpeakStreamUrl } from './voice-playback'
|
|
|
|
// The speak-stream WebSocket must dial the ACTIVE (connection, profile)
|
|
// backend — the same one chat and every REST audio call use. Before this
|
|
// contract was pinned it resolved through the bare v1 getConnection path,
|
|
// so a registry remote riding over a local install synthesized replies with
|
|
// the LOCAL machine's (often unconfigured) TTS while chat correctly went
|
|
// remote (desktop-remote voice report, Aug 2026).
|
|
describe('resolveSpeakStreamUrl', () => {
|
|
const remoteWsUrl = 'wss://gateway.example/api/ws?ticket=fresh'
|
|
const localWsUrl = 'ws://127.0.0.1:5151/api/ws?token=local'
|
|
|
|
let getConnection: ReturnType<typeof vi.fn>
|
|
let getConnectionFor: ReturnType<typeof vi.fn>
|
|
let getGatewayWsUrl: ReturnType<typeof vi.fn>
|
|
let getGatewayWsUrlFor: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
getConnection = vi.fn(async () => ({ authMode: 'token', baseUrl: 'http://127.0.0.1:5151', wsUrl: localWsUrl }))
|
|
|
|
getConnectionFor = vi.fn(async () => ({
|
|
authMode: 'token',
|
|
baseUrl: 'https://gateway.example',
|
|
wsUrl: remoteWsUrl
|
|
}))
|
|
|
|
getGatewayWsUrl = vi.fn(async () => ({ ok: true, wsUrl: localWsUrl }))
|
|
getGatewayWsUrlFor = vi.fn(async () => ({ ok: true, wsUrl: remoteWsUrl }))
|
|
|
|
Object.defineProperty(window, 'hermesDesktop', {
|
|
configurable: true,
|
|
value: { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
setApiRequestConnection(null)
|
|
setApiRequestProfile(null)
|
|
Reflect.deleteProperty(window, 'hermesDesktop')
|
|
})
|
|
|
|
it('resolves through the registry (connection, profile) bridges when a registry connection is active', async () => {
|
|
setApiRequestConnection('gw-tailscale')
|
|
setApiRequestProfile('research')
|
|
|
|
const url = await resolveSpeakStreamUrl()
|
|
|
|
expect(url).toContain('wss://gateway.example')
|
|
expect(url).toContain('/api/audio/speak-stream')
|
|
expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'gw-tailscale', profile: 'research' })
|
|
expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'gw-tailscale', profile: 'research' })
|
|
// The v1 primary path must NOT be consulted — that's the local machine.
|
|
expect(getConnection).not.toHaveBeenCalled()
|
|
expect(getGatewayWsUrl).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the legacy profile path byte-identical when no registry connection is active', async () => {
|
|
setApiRequestProfile('coder')
|
|
|
|
const url = await resolveSpeakStreamUrl()
|
|
|
|
expect(url).toContain('ws://127.0.0.1:5151')
|
|
expect(url).toContain('/api/audio/speak-stream')
|
|
expect(url).toContain('profile=coder')
|
|
expect(getConnection).toHaveBeenCalledWith('coder')
|
|
expect(getConnectionFor).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a backend-namespace profile already minted into the ws URL', async () => {
|
|
// SSH remoteProfile aliasing / sharedRemote scoping: the registry mint
|
|
// writes the BACKEND's profile name into the URL. The desktop-side
|
|
// routing alias must not overwrite it.
|
|
setApiRequestConnection('gw-ssh')
|
|
setApiRequestProfile('mara')
|
|
getGatewayWsUrlFor.mockResolvedValue({
|
|
ok: true,
|
|
wsUrl: 'wss://gateway.example/api/ws?ticket=fresh&profile=default'
|
|
})
|
|
|
|
const url = await resolveSpeakStreamUrl()
|
|
|
|
expect(url).toContain('profile=default')
|
|
expect(url).not.toContain('profile=mara')
|
|
})
|
|
|
|
it('falls back to the plain connection descriptor when the *For bridges are absent (older main)', async () => {
|
|
setApiRequestConnection('gw-tailscale')
|
|
setApiRequestProfile('research')
|
|
Object.defineProperty(window, 'hermesDesktop', {
|
|
configurable: true,
|
|
value: { getConnection, getGatewayWsUrl }
|
|
})
|
|
|
|
const url = await resolveSpeakStreamUrl()
|
|
|
|
// Best available answer without the bridges: the profile-scoped pool
|
|
// descriptor's own wsUrl (no cross-scope re-mint).
|
|
expect(url).toContain('/api/audio/speak-stream')
|
|
expect(getConnection).toHaveBeenCalledWith('research')
|
|
})
|
|
})
|