Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
activeGatewayProfileKey,
closeSecondaryGateways,
configureGatewayRegistry,
setPrimaryGateway
ensureGatewayForAgent,
requestGatewayForAgent,
setPrimaryGateway,
setPrimaryGatewayConnection
} from '@/store/gateway'
import {
$activeGatewayProfile,
Expand Down Expand Up @@ -51,6 +54,16 @@ import type { SubmitTextOptions } from './use-prompt-actions/utils'
import { useSessionActions } from './use-session-actions'
import { useSessionStateCache } from './use-session-state-cache'

// Electron's legacy module is compiled with its own (non-strict) tsconfig.
const { profileHasRemoteConnection, resolveProfileBackendRoute } = await vi.importActual<{
profileHasRemoteConnection: (config: unknown, profile: unknown) => boolean
resolveProfileBackendRoute: (profile: unknown, options: Record<string, unknown>) => { backend: string }
}>('../../../../electron/connection-config')

const { resolveRegistryLocalRoute } = await vi.importActual<{
resolveRegistryLocalRoute: (profile: unknown, options: Record<string, unknown>) => { delegate: boolean }
}>('../../../../electron/connection-registry')

// ── The real profile-rail reproduction (#94071, Sessions mode) ───────────────
//
// primary / ambient source = a remote gateway on `default`
Expand All @@ -68,11 +81,8 @@ import { useSessionStateCache } from './use-session-state-cache'
// held the runtime — and 4001'd "session not found" while the orphaned omar
// runtime was left to be ws-orphan-reaped.
//
// The explicit `local` source (This device) is different by design: a profile
// pick made there takes the legacy profile-only door (ensureGatewayProfile,
// so a per-profile remote override still resolves), and the draft's owner is
// that v1 profile socket — the last case pins that the same one-socket
// continuity holds there too.
// The ambient source still uses the legacy profile-only door; the last case
// pins that its one-socket continuity holds too.
//
// This suite drives the ACTUAL code path: the real registry store with mocked
// sockets, the real store/profile switch, the real useSessionStateCache /
Expand Down Expand Up @@ -507,11 +517,7 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across
expect($newChatConnectionId.get()).toBe(SOURCE_ID)
})

it('keeps the legacy profile door when boot published `local` on the active primary gateway', async () => {
// The published identity survives the reload, but a pick on the explicit
// local source is still a legacy profile pick (per-profile remote
// overrides resolve through getConnection), so the draft's owner is the
// v1 profile socket, never the registry entry local::omar.
it('keeps the local registry source published at boot', async () => {
const primary = makePrimary()

setPrimaryGateway(primary as never, 'default')
Expand All @@ -522,9 +528,45 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across

const desktop = window.hermesDesktop!

await waitFor(() => expect(desktop.getConnection).toHaveBeenCalledWith('omar'))
expect(desktop.getConnectionFor).not.toHaveBeenCalledWith({ connectionId: 'local', profile: 'omar' })
expect($newChatConnectionId.get()).toBeNull()
await waitFor(() =>
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'local', profile: 'omar' })
)
expect(desktop.getConnection).not.toHaveBeenCalledWith('omar')
expect($newChatConnectionId.get()).toBe('local')
})

it('does not publish a named local profile or send to primary when its descriptor rejects', async () => {
const primary = makePrimary()
primary.connectUrl = 'ws://127.0.0.1:4242/ws'
setPrimaryGateway(primary as never, 'default')
const connection = { connectionId: 'local', mode: 'local', profile: 'default' } as never
setConnection(connection)
setPrimaryGatewayConnection(connection)
$activeGatewayProfile.set('default')
vi.mocked(window.hermesDesktop!.getConnectionFor!).mockRejectedValue(new Error('local descriptor unavailable'))

const activated = await ensureGatewayForAgent('local', 'omar')
await ensureGatewayAgent('local', 'omar')

const witness = {
activated,
publishedProfile: $activeGatewayProfile.get(),
socketProfile: activeGatewayProfileKey(),
descriptorProfile: $connection.get()?.profile,
primaryStillActive: activeGateway() === (primary as never)
}

expect(witness).toEqual({
activated: false,
publishedProfile: 'default',
socketProfile: 'default',
descriptorProfile: 'default',
primaryStillActive: true
})
await expect(requestGatewayForAgent('local', 'omar', 'session.create', {})).rejects.toThrow(
'local descriptor unavailable'
)
expect(primary.request).not.toHaveBeenCalled()
})

function expectUninterruptedOwner({
Expand Down Expand Up @@ -688,18 +730,93 @@ describe('profile rail: a fresh Omar chat keeps its exact registry owner across
expectUninterruptedOwner({ ambientRequest: ambientRequest as GatewayRequestMock, omarSocket, primary })
})

it('a pick on the explicit `local` source is a legacy profile pick: create and both turns ride the ONE v1 omar socket', async () => {
it.each([
['selectProfile', selectProfile, false],
['newSessionInProfile', newSessionInProfile, false],
['selectProfile with override', selectProfile, true],
['newSessionInProfile with override', newSessionInProfile, true]
] as const)('keeps This device named picks on their exact owner with saved cloud primary (%s)', async (_name, pick, override) => {
const saved = { mode: 'cloud', profiles: override ? { omar: { mode: 'remote', url: 'https://override.invalid' } } : {} }
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')
const desktop = window.hermesDesktop!

const localDescriptor = (profile: string) => ({
mode: 'local' as const,
profile,
port: OMAR_PORT,
token: 'test-local',
wsUrl: `ws://127.0.0.1:${OMAR_PORT}/ws`
})

// Active registry source: `local` (This device), on its default profile.
// Exercise Electron's real routing policies at the IPC boundary; only the
// backend/socket transport is synthetic, never dial the user's gateways.
vi.mocked(desktop.getConnection).mockImplementation(async profile => {
const route = resolveProfileBackendRoute(profile, {
globalRemote: saved.mode === 'cloud',
primaryProfile: 'default',
primaryRemoteActive: true,
ownEntry: false,
profileRemoteOverride: profileHasRemoteConnection(saved, profile)
})

expect(route.backend).toBe(override && profile === 'omar' ? 'pool' : 'primary')

return { mode: 'remote', profile, wsUrl: `ws://override.invalid:${OMAR_PORT}/ws`, token: 'test-override' } as never
})
vi.mocked(desktop.getConnectionFor!).mockImplementation(async ({ connectionId, profile }) => {
expect(connectionId).toBe('local')

const route = resolveRegistryLocalRoute(profile, {
globalRemote: saved.mode === 'cloud',
profileRemoteOverride: profileHasRemoteConnection(saved, profile)
})

expect(route.delegate).toBe(override && profile === 'omar')

return route.delegate ? desktop.getConnection(profile) : (localDescriptor(profile || 'default') as never)
})
await ensureGatewayAgent('local', 'default')
pick('omar')
await waitFor(() => expect(activeGatewayProfileKey()).toBe('omar'))
expect(activeGatewayConnectionId()).toBe('local')
expect($connection.get()?.mode).toBe(override ? 'remote' : 'local')
expect($newChatConnectionId.get()).toBe('local')

if (override) {
expect(desktop.getConnection).toHaveBeenCalledWith('omar')
} else {
expect(desktop.getConnection).not.toHaveBeenCalled()
}

const omarSocket = activeGateway() as unknown as MockGateway
expect(omarSocket.connectUrl).toContain(override ? 'override.invalid' : '127.0.0.1')

// A profile pick on the explicit local source takes the profile-only door
// so a per-profile remote override resolves (the main process answers
// getConnection("omar")), never the registry entry local::omar. The draft's
// owner must be the socket that door opens: the v1 omar socket.
const ambientRequest = vi.fn(async (method: string) => {
throw new Error(`explicit local traffic must not use ambient dispatcher: ${method}`)
})

let handle: HarnessHandle | null = null
render(<Harness ambientRequest={ambientRequest} onReady={h => (handle = h)} />)
await waitFor(() => expect(handle).not.toBeNull())
await expect(handle!.submitText('first prompt')).resolves.toBe(true)
await settleTurn(handle!)
await expect(handle!.submitText('second prompt')).resolves.toBe(true)
expect(handle!.bindings()).toEqual({ runtimeForStored: RUNTIME_ID, storedForRuntime: STORED_ID })
expect(calls(omarSocket).filter(method => method === 'session.create')).toHaveLength(1)
expect(omarSocket.request.mock.calls.filter(([method]) => method === 'prompt.submit').map(([, params]) => params?.text))
.toEqual(['first prompt', 'second prompt'])
expect(getSessionOwnerHint(STORED_ID)).toEqual({ connectionId: 'local', profile: 'omar' })
expectUninterruptedOwner({ ambientRequest, omarSocket, primary })
expect(primary.request).not.toHaveBeenCalled()
})

it('an ambient profile pick keeps create and both turns on the ONE v1 omar socket', async () => {
const primary = makePrimary()
setPrimaryGateway(primary as never, 'default')

// No explicit registry source: legacy per-profile routing still applies.
expect(activeGatewayConnectionId()).toBeNull()
const LEGACY_RUNTIME_ID = 'rt-omar-legacy-1'
const LEGACY_STORED_ID = 'stored-omar-legacy-1'

Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/store/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,9 @@ async function isAttachedSharedRemote(
const id = String(connectionId ?? '').trim()
const key = normKey(profile)

if (!id || !g.primaryConnectionId || id !== g.primaryConnectionId) {
// ponytail: local profiles (including saved overrides) need their own route,
// never the attached primary's shared-remote fallback on a failed lookup.
if (!id || id === 'local' || !g.primaryConnectionId || id !== g.primaryConnectionId) {
return false
}

Expand Down
17 changes: 8 additions & 9 deletions apps/desktop/src/store/profile-select-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
// names one of THAT source's profiles. Routing it through the profile-only
// path resolved the descriptor with a bare name, which the main process
// answers against the primary — the gateway snapped back home and the pick
// looked like it never took. Default on the explicit `local` source is the
// exception: that name is also the window primary's profile key, so the
// profile-only door would activate a remote-primary VPS.
// looked like it never took. This applies to the explicit `local` source too:
// a bare profile name would inherit the saved cloud primary.

const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => true)
Expand Down Expand Up @@ -66,13 +65,13 @@ describe('selectProfile', () => {
expect(ensureGatewayForAgent).not.toHaveBeenCalled()
})

it('keeps the legacy profile-only path when the explicit local source is live', async () => {
it('keeps named picks on the explicit local source', async () => {
activeGatewayConnectionId.mockReturnValue('local')

selectProfile('override-profile')

await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('override-profile'))
expect(ensureGatewayForAgent).not.toHaveBeenCalled()
await vi.waitFor(() => expect(ensureGatewayForAgent).toHaveBeenCalledWith('local', 'override-profile'))
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})

it('keeps Default on the explicit local source instead of the window primary', async () => {
Expand All @@ -95,13 +94,13 @@ describe('newSessionInProfile', () => {
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})

it('keeps the legacy profile-only path for a new chat on the explicit local source', async () => {
it('keeps named new chats on the explicit local source', async () => {
activeGatewayConnectionId.mockReturnValue('local')

newSessionInProfile('override-profile')

await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('override-profile'))
expect(ensureGatewayForAgent).not.toHaveBeenCalled()
await vi.waitFor(() => expect(ensureGatewayForAgent).toHaveBeenCalledWith('local', 'override-profile'))
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})

it('opens a Default new chat on the explicit local source, not the window primary', async () => {
Expand Down
53 changes: 9 additions & 44 deletions apps/desktop/src/store/profile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LOCAL_CONNECTION_ID, registryBackendScopeKey } from '@hermes/shared'
import { registryBackendScopeKey } from '@hermes/shared'
import { atom, batch, computed } from 'nanostores'

import type { HermesConnection } from '@/global'
Expand Down Expand Up @@ -271,45 +271,16 @@ export const $newChatRoute = atom<AgentProfileRoute | null>(null)
// string "omar", and every follow-up RPC dialed requestGatewayForProfile
// ("omar") — a DIFFERENT socket than the one that created the session —
// and 4001'd "session not found" (#94071). null = the intent dials the legacy
// profile-only path (a v1 primary with no registry identity, or a named
// profile pick on the explicit `local` source — see profilePickConnectionId).
// profile-only path (a v1 primary with no registry identity).
export const $newChatConnectionId = atom<null | string>(null)

/** Capture the registry source a new-chat profile intent lands on — by
* default the active one; callers that dial a different door (a profile
* pick, see profilePickConnectionId) pass the source that door uses. */
* pick) pass the source that door uses. */
export function captureNewChatSource(connectionId: null | string = activeGatewayConnectionId()): void {
$newChatConnectionId.set(connectionId)
}

/**
* The registry source a PROFILE PICK dials, mirroring activateOnCurrentSource:
* a live remote registry source keeps its connection id. Named picks on the
* explicit `local` source (and the window primary) take the legacy
* profile-only path (null) so the main process can resolve a per-profile
* remote override before falling back to a local backend (#94166).
*
* Default on that same `local` source is different: it is also the window
* primary's profile key. The legacy door would activate the remote primary on
* a VPS-primary desktop, and Bots would show the VPS as Current Gateway.
* Keep Default on `local` so This-device home stays on This device.
*/
function profilePickConnectionId(profile?: string): null | string {
const connectionId = activeGatewayConnectionId()

if (connectionId && connectionId !== LOCAL_CONNECTION_ID) {
return connectionId
}

if (connectionId === LOCAL_CONNECTION_ID) {
const key = normalizeProfileKey(profile ?? $newChatProfile.get())

return key === 'default' ? LOCAL_CONNECTION_ID : null
}

return null
}

/**
* The EXACT owner route the next new chat is created on, or null for the
* legacy ambient path. An explicit agent route ($newChatRoute) wins; else,
Expand All @@ -330,9 +301,7 @@ export function resolveNewChatOwnerRoute(forProfile?: string): AgentProfileRoute
const intentProfile = forProfile ? normalizeProfileKey(forProfile) : $newChatProfile.get()

const connectionId = (
(intentProfile
? ($newChatConnectionId.get() ?? profilePickConnectionId(intentProfile))
: activeGatewayConnectionId()) ?? ''
(intentProfile ? ($newChatConnectionId.get() ?? activeGatewayConnectionId()) : activeGatewayConnectionId()) ?? ''
).trim()

if (!connectionId) {
Expand Down Expand Up @@ -864,7 +833,7 @@ export function selectProfile(name: string): void {
// is made on the source the user is looking at (activateOnCurrentSource
// dials exactly that pair), so the draft's exact owner is that pair — or the
// legacy profile-only path when that is the door the pick takes.
captureNewChatSource(profilePickConnectionId(target))
captureNewChatSource()

if (switching) {
requestFreshSession()
Expand Down Expand Up @@ -925,14 +894,10 @@ async function isLocalDesktopProfile(target: string): Promise<boolean> {
}

// Route a profile pick at the source the user is LOOKING at. $profiles is the
// active gateway's list, so a pick made while a remote registry source is live
// names one of THAT source's profiles and must keep its connection id. Named
// picks on the primary and explicit "local" source stay on the legacy
// profile-only path so the main process can resolve a per-profile remote
// override before falling back to a local backend. Default on `local` stays
// on that source — see profilePickConnectionId.
// active gateway's list. ponytail: retain its source, including local;
// Electron's registry route already resolves per-profile remote overrides.
function activateOnCurrentSource(target: string): Promise<void> {
const connectionId = profilePickConnectionId(target)
const connectionId = activeGatewayConnectionId()

return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target)
}
Expand All @@ -944,7 +909,7 @@ export function pinNewChatProfile(name: string): string {
const target = normalizeProfileKey(name)
$newChatProfile.set(target)
$newChatRoute.set(null)
captureNewChatSource(profilePickConnectionId(target))
captureNewChatSource()

return target
}
Expand Down