diff --git a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx index c65106dff460f..858bc5f5bcbdb 100644 --- a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx +++ b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx @@ -12,7 +12,10 @@ import { activeGatewayProfileKey, closeSecondaryGateways, configureGatewayRegistry, - setPrimaryGateway + ensureGatewayForAgent, + requestGatewayForAgent, + setPrimaryGateway, + setPrimaryGatewayConnection } from '@/store/gateway' import { $activeGatewayProfile, @@ -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) => { backend: string } +}>('../../../../electron/connection-config') + +const { resolveRegistryLocalRoute } = await vi.importActual<{ + resolveRegistryLocalRoute: (profile: unknown, options: Record) => { delegate: boolean } +}>('../../../../electron/connection-registry') + // ── The real profile-rail reproduction (#94071, Sessions mode) ─────────────── // // primary / ambient source = a remote gateway on `default` @@ -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 / @@ -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') @@ -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({ @@ -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( (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' diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index d84208b5963e6..8861f5462ccd4 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -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 } diff --git a/apps/desktop/src/store/profile-select-source.test.ts b/apps/desktop/src/store/profile-select-source.test.ts index 2c223965a6ac4..00c686217de77 100644 --- a/apps/desktop/src/store/profile-select-source.test.ts +++ b/apps/desktop/src/store/profile-select-source.test.ts @@ -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) @@ -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 () => { @@ -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 () => { diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 61595ba8a9263..8df6d07b70e92 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -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' @@ -271,45 +271,16 @@ export const $newChatRoute = atom(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) /** 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, @@ -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) { @@ -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() @@ -925,14 +894,10 @@ async function isLocalDesktopProfile(target: string): Promise { } // 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 { - const connectionId = profilePickConnectionId(target) + const connectionId = activeGatewayConnectionId() return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target) } @@ -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 }