diff --git a/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts b/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts new file mode 100644 index 0000000000..958fa32d4b --- /dev/null +++ b/apps/desktop/src/app/contrib/session-rpc-dispatcher.ts @@ -0,0 +1,93 @@ +/** + * The window's ONE session-scoped RPC dispatcher, factored out of the contrib + * wiring controller so the exact production routing (not a re-implementation) + * can be driven by integration tests alongside the real session/prompt hooks. + * + * Route each RPC by the session IT targets, not by whatever tile is focused. + * `requestGateway` is one shared closure used for every session RPC in the + * window; keying the owner off $focusedStoredSessionId sent a NON-focused + * tile's RPC (any bot chat while another pane is active) to the focused tile's + * backend. That is the Bot Mode bug: a bot's prompt.submit carried its own + * session_id but ran on the default backend (served via ?profile= from the + * default's state.db), or 4001'd when the default backend didn't hold the + * runtime session. + * + * params.session_id is a RUNTIME id, while tiles and session rows key on the + * STORED id, so translate first (state cache, then a reverse scan of the + * stored->runtime map, then the persisted tile map — the same ladder + * use-session-tile-delegate uses, plus the tile rung that survives a reload + * when the state cache is cold). A miss on ALL rungs means the id is already a + * stored id (several RPCs pass stored ids directly), so use it as-is. Only an + * RPC with no session_id at all (ambient/config calls) keeps the focused-tile + * route. + * + * Session-scoped RPCs route to the backend that OWNS the session — never to + * whatever is "active" (active is presentation only). The owner ladder is + * resolveSessionRpcOwner (tile route → exact unique owner hint → row profile), + * then a cross-profile REST probe for a hidden/unlisted session. Only a + * request with NO session at all falls to the ambient socket. + */ +import type { MutableRefObject } from 'react' + +import { resolveSessionProfile } from '@/app/session/hooks/use-session-actions/utils' +import type { ClientSessionState } from '@/app/types' +import { $sessions, getSessionOwnerHint, knownSessionOwner } from '@/store/session' +import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router' +import { $focusedStoredSessionId, sessionTileOwnerRoute, storedSessionIdForRuntimeId } from '@/store/session-states' + +import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing' + +export type AmbientGatewayRequest = ( + method: string, + params?: Record, + timeoutMs?: number, + signal?: AbortSignal +) => Promise + +export interface SessionRpcDispatcherDeps { + ambientRequest: AmbientGatewayRequest + runtimeIdByStoredSessionIdRef: MutableRefObject> + selectedStoredSessionIdRef: MutableRefObject + sessionStateByRuntimeIdRef: MutableRefObject> +} + +export function createSessionRpcDispatcher(deps: SessionRpcDispatcherDeps): AmbientGatewayRequest { + const { ambientRequest, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef } = deps + + return async (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal) => { + const paramSessionId = typeof params?.session_id === 'string' && params.session_id ? params.session_id : undefined + + const routingSessionId = resolveRoutingSessionId({ + focusedStoredSessionId: $focusedStoredSessionId.get(), + paramSessionId, + selectedStoredSessionId: selectedStoredSessionIdRef.current, + storedIdForRuntime: runtimeId => + sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId ?? + findStoredIdForRuntimeId(runtimeIdByStoredSessionIdRef.current, runtimeId) ?? + storedSessionIdForRuntimeId(runtimeId) ?? + undefined + }) + + let owner: SessionOwnerScope = resolveSessionRpcOwner({ + routingSessionId, + sessionOwnerHint: storedSessionId => getSessionOwnerHint(storedSessionId), + sessionRowOwner: storedSessionId => knownSessionOwner($sessions.get(), storedSessionId), + tileOwnerRoute: sessionTileOwnerRoute + }) + + if (!owner && routingSessionId) { + // Unknown owner for a REAL session: probe across profiles (REST, not the + // gateway socket, so no recursion) rather than defaulting to active. A + // hit stamps ownership + caches a hint; a miss leaves owner undefined + // and the request falls to ambient, exactly as an unroutable session did + // before — but only after we tried, never as a silent active fallback. + const probed = await resolveSessionProfile(routingSessionId) + + if (probed) { + owner = probed + } + } + + return requestForSessionProfile(owner, ambientRequest, method, params ?? {}, timeoutMs, signal) + } +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b466879bd3..bcf466ab28 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -14,7 +14,6 @@ import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEff import { useLocation, useNavigate } from 'react-router' import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill' -import { resolveSessionProfile } from '@/app/session/hooks/use-session-actions/utils' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { ConfirmHost } from '@/components/confirm-host' @@ -72,16 +71,12 @@ import { $selectedStoredSessionId, $sessionResumeRequest, $sessions, - getSessionOwnerHint, - knownSessionOwner, sessionMatchesStoredId, sessionPinId, setAwaitingResponse, setBusy, setMessages } from '@/store/session' -import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router' -import { $focusedStoredSessionId, sessionTileOwnerRoute, storedSessionIdForRuntimeId } from '@/store/session-states' import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos' import { armWakeWord, stopClientCapture } from '@/store/wake-word' import { isAuxiliaryWindow, isBrowserWindow, isHudWindow } from '@/store/windows' @@ -152,9 +147,9 @@ import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge' import { useSessionTileDelegate } from './hooks/use-session-tile-delegate' import { McpInstallDeepLinkDialog } from './mcp-install-deeplink-dialog' import { $restartPreviewServer, useTitlebarToolContributions } from './panes' +import { createSessionRpcDispatcher } from './session-rpc-dispatcher' import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces' import type { WiringActions, WiringApi } from './types' -import { findStoredIdForRuntimeId, resolveRoutingSessionId, resolveSessionRpcOwner } from './wiring-routing' // Overlay views the controller mounts over the shell — lazy, load on demand. // The workspace-route full-page views (skills/messaging/artifacts) are the @@ -298,93 +293,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { // When chrome stays on the launch backend (Bot Mode / all-profiles // navigation), session-owned RPCs still have to hit the session's backend. - // - // Route by the SESSION THIS RPC TARGETS first: a session-scoped RPC carries - // its target in params.session_id, and dispatching it by the WINDOW's - // focused tile instead sends a background bot's prompt.submit to whichever - // backend the focused pane happens to own — the bot then runs on the - // default backend (its store, its logs), or 4001s when default doesn't - // hold the session. params.session_id is a RUNTIME id while tile routes - // key on the STORED id, so translate via the tile map before resolving. - // Only when the RPC names no session (config reads, list refreshes, cron) - // does the focused-tile key apply — those are genuinely window-ambient. - // - // A bot chat is a persisted TILE that already records the EXACT owning route - // (connectionId + profile) it was opened with — the same authoritative owner - // Sessions mode reads off the session row. Prefer it. The canonical Bot Chat - // is hidden, so it never appears in $sessions and rememberedSessionProfile's - // row lookup misses and falls back to the ACTIVE profile — the Bot Mode - // "session not found" / hang. The tile route is per-session, survives - // relaunch, and needs no list membership, so it fixes an already-open chat - // too. Fall back to the list-derived profile only when no tile route exists. - // Session-scoped RPCs route to the backend that OWNS the session — its - // profile's own local gateway — never to whatever is "active" (active is - // presentation only). Resolve the owner from, in order: the tile's persisted - // route (bot chats carry an exact connectionId+profile), the exact UNIQUE - // session owner hint (stamped the moment a routed session.create returns, - // or at plugin open time), the session row's profile, then a cross-profile - // REST probe that stamps ownership for a hidden/unlisted session. The hint - // outranks the row: a row is presentation state that can be stamped from - // the AMBIENT profile and carries no connection, so a fresh chat created on - // local::omar while `default` stayed active ran turn one on omar and then - // 4001'd on turn two when the row's `default` won the route. Only a request - // with NO session at all (a fresh draft, global chrome) falls to the ambient - // socket. The probe result is cached as an owner hint so the next call is - // sync — see resolveSessionRpcOwner for the ladder. - const requestGateway = useCallback( - async (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal) => { - // Route each RPC by the session IT targets, not by whatever tile is - // focused. `requestGateway` is one shared closure used for every session - // RPC in the window; keying the owner off $focusedStoredSessionId sent a - // NON-focused tile's RPC (any bot chat while another pane is active) to - // the focused tile's backend. That is the Bot Mode bug: a bot's - // prompt.submit carried its own session_id but ran on the default backend - // (served via ?profile= from the default's state.db), or 4001'd when the - // default backend didn't hold the runtime session. - // - // params.session_id is a RUNTIME id, while tiles and session rows key on - // the STORED id, so translate first (state cache, then a reverse scan of - // the stored->runtime map, then the persisted tile map — the same ladder - // use-session-tile-delegate uses, plus the tile rung that survives a - // reload when the state cache is cold). A miss on ALL rungs means the id - // is already a stored id (several RPCs pass stored ids directly), so use - // it as-is. Only an RPC with no session_id at all (ambient/config calls) - // keeps the focused-tile route. - const paramSessionId = typeof params?.session_id === 'string' && params.session_id ? params.session_id : undefined - - const routingSessionId = resolveRoutingSessionId({ - focusedStoredSessionId: $focusedStoredSessionId.get(), - paramSessionId, - selectedStoredSessionId: selectedStoredSessionIdRef.current, - storedIdForRuntime: runtimeId => - sessionStateByRuntimeIdRef.current.get(runtimeId)?.storedSessionId ?? - findStoredIdForRuntimeId(runtimeIdByStoredSessionIdRef.current, runtimeId) ?? - storedSessionIdForRuntimeId(runtimeId) ?? - undefined - }) - - let owner: SessionOwnerScope = resolveSessionRpcOwner({ - routingSessionId, - sessionOwnerHint: storedSessionId => getSessionOwnerHint(storedSessionId), - sessionRowOwner: storedSessionId => knownSessionOwner($sessions.get(), storedSessionId), - tileOwnerRoute: sessionTileOwnerRoute - }) - - if (!owner && routingSessionId) { - // Unknown owner for a REAL session: probe across profiles (REST, not the - // gateway socket, so no recursion) rather than defaulting to active. A - // hit stamps ownership + caches a hint; a miss leaves owner undefined - // and the request falls to ambient, exactly as an unroutable session did - // before — but only after we tried, never as a silent active fallback. - const probed = await resolveSessionProfile(routingSessionId) - - if (probed) { - owner = probed - } - } - - return requestForSessionProfile(owner, ambientRequestGateway, method, params ?? {}, timeoutMs, signal) - }, + // The routing itself lives in createSessionRpcDispatcher (routed by the + // session the RPC targets, owner ladder in resolveSessionRpcOwner) so the + // exact production dispatcher is what the integration tests drive. + const requestGateway = useMemo( + () => + createSessionRpcDispatcher({ + ambientRequest: ambientRequestGateway, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef + }), [ambientRequestGateway, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef] ) 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 new file mode 100644 index 0000000000..d2a6e44791 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx @@ -0,0 +1,573 @@ +import { registryBackendScopeKey } from '@hermes/shared' +import { useStore } from '@nanostores/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useMemo, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +import { createSessionRpcDispatcher } from '@/app/contrib/session-rpc-dispatcher' +import { getSession } from '@/hermes' +import { + activeGateway, + activeGatewayConnectionId, + activeGatewayProfileKey, + closeSecondaryGateways, + configureGatewayRegistry, + setPrimaryGateway +} from '@/store/gateway' +import { + $activeGatewayProfile, + $newChatConnectionId, + $newChatProfile, + $newChatRoute, + ensureGatewayAgent, + selectProfile +} from '@/store/profile' +import { + $activeSessionId, + $selectedStoredSessionId, + $sessions, + getSessionOwnerHint, + sessionMatchesStoredId, + setActiveSessionId, + setAwaitingResponse, + setBusy, + setMessages, + setSelectedStoredSessionId, + setSessions +} from '@/store/session' + +import type { ClientSessionState } from '../../types' + +import { usePromptActions } from './use-prompt-actions' +import { clearSingleFlightSessionResumeState } from './use-prompt-actions/single-flight-resume' +import type { SubmitTextOptions } from './use-prompt-actions/utils' +import { useSessionActions } from './use-session-actions' +import { useSessionStateCache } from './use-session-state-cache' + +// ── The real profile-rail reproduction (#94071, Sessions mode) ─────────────── +// +// primary / ambient source = a remote gateway on `default` +// active registry source = `homelab` (a remote registry source) +// user action = selectProfile("omar") in the profile rail +// +// selectProfile sets $newChatProfile = "omar" and deliberately CLEARS +// $newChatRoute, so nothing explicit names the source. The draft's real owner +// is the registry entry homelab::omar (scope `conn:homelab::omar`) — the +// socket whose WebSocket mints the runtime. Before the fix the create rode +// that socket ambiently, but the durable owner degraded to the bare string +// "omar": the optimistic row was stamped from the ambient profile with no +// connection, no owner hint was recorded, and every follow-up RPC dialed +// requestGatewayForProfile("omar") — a DIFFERENT v1 socket/backend that never +// 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 second case pins that the same one-socket +// continuity holds there too. +// +// This suite drives the ACTUAL code path: the real registry store with mocked +// sockets, the real store/profile switch, the real useSessionStateCache / +// useSessionActions / usePromptActions hooks, and the production session-RPC +// dispatcher. It never supplies an owner by hand. + +const SOURCE_ID = 'homelab' +const OMAR_PORT = 7171 +const SOURCE_DEFAULT_PORT = 7070 +const V1_PORT = 5151 +const RUNTIME_ID = 'rt-omar-fresh-1' +const STORED_ID = 'stored-omar-fresh-1' + +type GatewayRequestMock = Mock<(method: string, params?: Record) => Promise> + +interface MockGateway { + connectUrl: null | string + connectionState: string + connect: Mock<(url: string) => Promise> + close: Mock<() => void> + onEvent: Mock<() => () => void> + onState: Mock<() => () => void> + request: GatewayRequestMock +} + +const sockets: MockGateway[] = [] +/** The port of the ONE socket allowed to mint (and then own) the runtime. */ +let ownerPort = OMAR_PORT +/** The ids the owner socket mints — per case, so one case's owner records + * (the hint map is module state) can never satisfy another's assertions. */ +let mintedRuntimeId = RUNTIME_ID +let mintedStoredId = STORED_ID + +const sessionScoped = (params: unknown) => + typeof (params as { session_id?: unknown } | undefined)?.session_id === 'string' + +/** The owner socket (the registry entry homelab::omar, or the v1 omar socket + * for a legacy pick) answers; every other socket is a backend that never + * held the runtime, exactly as in the field. */ +function answer(socket: MockGateway, method: string, params: Record) { + const isOmar = socket.connectUrl?.includes(`:${ownerPort}`) ?? false + + if (method === 'session.create') { + if (!isOmar) { + throw new Error(`session.create landed on the wrong socket: ${socket.connectUrl}`) + } + + return { info: {}, session_id: mintedRuntimeId, stored_session_id: mintedStoredId } + } + + if (sessionScoped(params) && !isOmar) { + throw new Error(`Session not found: ${String(params.session_id)} (socket ${socket.connectUrl}, ${method})`) + } + + if (method === 'prompt.submit') { + return { ok: true } + } + + if (method === 'session.resume' || method === 'session.activate') { + // The runtime is alive on this socket: a resume re-binds the SAME id. + return { + info: {}, + message_count: 1, + messages: [], + resumed: mintedStoredId, + running: false, + session_id: mintedRuntimeId, + session_key: mintedStoredId + } + } + + return {} +} + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal>()), + HermesGateway: class { + connectUrl: null | string = null + connectionState = 'closed' + connect = vi.fn(async (url: string) => { + this.connectUrl = url + this.connectionState = 'open' + }) + request = vi.fn(async (method: string, params: Record = {}) => { + if (this.connectionState !== 'open') { + throw new Error('gateway is not connected') + } + + return answer(this as unknown as MockGateway, method, params) + }) + close = vi.fn(() => { + this.connectionState = 'closed' + }) + onEvent = vi.fn(() => () => {}) + onState = vi.fn(() => () => {}) + + constructor() { + sockets.push(this as unknown as MockGateway) + } + }, + getSession: vi.fn(async () => { + throw new Error('REST cross-profile probe must not be needed: the owner is known') + }), + setApiRequestConnection: vi.fn(), + setApiRequestProfile: vi.fn() +})) + +function installDesktop(): void { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + // v1 profile path (requestGatewayForProfile / ensureGatewayProfile): a + // per-profile local backend that is NOT the registry entry. + getConnection: vi.fn(async (profile: null | string) => { + const port = profile ? V1_PORT : 4242 + + return { port, profile, token: profile ? 'v1-token' : 'primary-token', wsUrl: `ws://127.0.0.1:${port}/ws` } + }), + getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => { + const port = + connectionId === SOURCE_ID || connectionId === 'local' + ? profile === 'omar' + ? OMAR_PORT + : SOURCE_DEFAULT_PORT + : 9999 + + return { port, profile, token: `${connectionId}-${profile}-token`, wsUrl: `ws://127.0.0.1:${port}/ws` } + }), + touchBackend: vi.fn(async () => undefined) + } +} + +/** The remote primary. Session-scoped traffic here is the bug. */ +function makePrimary(): MockGateway { + const primary: MockGateway = { + connectUrl: 'ws://remote-primary:4242', + connectionState: 'open', + connect: vi.fn(), + close: vi.fn(), + onEvent: vi.fn(() => () => {}), + onState: vi.fn(() => () => {}), + request: vi.fn(async (method: string, params: Record = {}) => answer(primary, method, params)) + } + + return primary +} + +interface HarnessHandle { + busyRef: { current: boolean } + bindings: () => { runtimeForStored: null | string; storedForRuntime: null | string } + submitText: (text: string, options?: SubmitTextOptions) => Promise + updateSessionState: ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: null | string + ) => ClientSessionState +} + +/** The window's real hook stack, wired the way contrib/wiring wires it. */ +function Harness({ + ambientRequest, + onReady +}: { + ambientRequest: MockGateway['request'] + onReady: (h: HarnessHandle) => void +}) { + const activeSessionId = useStore($activeSessionId) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const busyRef = useRef(false) + const creatingSessionRef = useRef(false) + + const cache = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId, + setAwaitingResponse, + setBusy, + setMessages + }) + + const requestGateway = useMemo( + () => + createSessionRpcDispatcher({ + ambientRequest: ambientRequest as never, + runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef + }), + [ + ambientRequest, + cache.runtimeIdByStoredSessionIdRef, + cache.selectedStoredSessionIdRef, + cache.sessionStateByRuntimeIdRef + ] + ) + + const sessionActions = useSessionActions({ + activeSessionId, + activeSessionIdRef: cache.activeSessionIdRef, + busyRef, + creatingSessionRef, + ensureSessionState: cache.ensureSessionState, + getRouteToken: () => 'token', + getRoutedStoredSessionId: () => null, + navigate: vi.fn() as never, + requestGateway, + resetViewSync: cache.resetViewSync, + runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef, + selectedStoredSessionId, + selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef, + syncSessionStateToView: cache.syncSessionStateToView, + updateSessionState: cache.updateSessionState + }) + + const promptActions = usePromptActions({ + activeSessionId, + activeSessionIdRef: cache.activeSessionIdRef, + branchCurrentSession: async () => true, + busyRef, + createBackendSessionForSend: sessionActions.createBackendSessionForSend, + getRoutedStoredSessionId: () => null, + getRuntimeIdForStoredSession: cache.getRuntimeIdForStoredSession, + getRouteToken: () => 'token', + handleSkinCommand: () => '', + openMemoryGraph: () => undefined, + refreshSessions: async () => undefined, + requestGateway, + resumeStoredSession: sessionActions.resumeSession, + runtimeIdByStoredSessionIdRef: cache.runtimeIdByStoredSessionIdRef, + selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, + startFreshSessionDraft: sessionActions.startFreshSessionDraft, + sttEnabled: false, + updateSessionState: cache.updateSessionState + }) + + const { submitText } = promptActions + + useEffect(() => { + onReady({ + busyRef, + bindings: () => ({ + runtimeForStored: cache.runtimeIdByStoredSessionIdRef.current.get(mintedStoredId) ?? null, + storedForRuntime: cache.sessionStateByRuntimeIdRef.current.get(mintedRuntimeId)?.storedSessionId ?? null + }), + submitText: (...args) => act(async () => submitText(...args)) as Promise, + updateSessionState: cache.updateSessionState as HarnessHandle['updateSessionState'] + }) + }, [ + cache.runtimeIdByStoredSessionIdRef, + cache.sessionStateByRuntimeIdRef, + cache.updateSessionState, + onReady, + submitText + ]) + + return null +} + +const omarScope = registryBackendScopeKey(SOURCE_ID, 'omar') + +describe('profile rail: a fresh Omar chat keeps its exact registry owner across turns (#94071)', () => { + beforeEach(() => { + sockets.length = 0 + ownerPort = OMAR_PORT + mintedRuntimeId = RUNTIME_ID + mintedStoredId = STORED_ID + clearSingleFlightSessionResumeState() + configureGatewayRegistry({ onEvent: vi.fn() }) + closeSecondaryGateways() + installDesktop() + setSessions([]) + setMessages([]) + setActiveSessionId(null) + setSelectedStoredSessionId(null) + setBusy(false) + setAwaitingResponse(false) + $newChatProfile.set(null) + $newChatRoute.set(null) + $newChatConnectionId.set(null) + }) + + afterEach(() => { + cleanup() + closeSecondaryGateways() + setSessions([]) + setActiveSessionId(null) + setSelectedStoredSessionId(null) + $newChatProfile.set(null) + $newChatRoute.set(null) + $newChatConnectionId.set(null) + $activeGatewayProfile.set('default') + vi.clearAllMocks() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('session.create and both prompt.submit calls ride the SAME conn:homelab::omar socket', async () => { + // Primary / ambient source: a remote gateway on `default`. + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + + // Active registry source: `homelab` (a remote source), on its default + // profile — the state a connection-rail click leaves the window in. + await ensureGatewayAgent(SOURCE_ID, 'default') + expect(activeGatewayConnectionId()).toBe(SOURCE_ID) + + // The profile rail: selectProfile("omar"). + selectProfile('omar') + expect($newChatProfile.get()).toBe('omar') + expect($newChatRoute.get()).toBeNull() + await waitFor(() => expect(activeGatewayProfileKey()).toBe('omar')) + expect(activeGatewayConnectionId()).toBe(SOURCE_ID) + + // The socket the registry dialed for homelab::omar (mocked HermesGateway + // instances register themselves on construction). + expect(sockets.length).toBeGreaterThan(0) + const omarSocket = sockets.find(socket => socket.connectUrl?.includes(`:${OMAR_PORT}`)) + expect( + omarSocket, + `no socket dialed port ${OMAR_PORT}; dialed: ${sockets.map(s => s.connectUrl).join(', ')}` + ).toBeDefined() + expect(activeGateway()).toBe(omarSocket as never) + + // Ambient dispatcher = whatever socket is active, as useGatewayRequest does. + const ambientRequest = vi.fn(async (method: string, params?: Record) => + (activeGateway() as unknown as MockGateway).request(method, params) + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} />) + await waitFor(() => expect(handle).not.toBeNull()) + + // Turn one: no session yet → createBackendSessionForSend → prompt.submit. + await expect(handle!.submitText('first prompt')).resolves.toBe(true) + await waitFor(() => expect($activeSessionId.get()).toBe(RUNTIME_ID)) + + // The stored↔runtime binding minted by the create must survive the first + // turn: submit used to seed its optimistic bubble with the PRE-create + // (null) stored id, which the state cache read as a detach — after which + // no session-scoped RPC could translate the runtime id back to the stored + // id, so tile route / owner hint / row were all bypassed. + expect(handle!.bindings()).toEqual({ runtimeForStored: RUNTIME_ID, storedForRuntime: STORED_ID }) + + // Answer one arrives: the turn settles (what the gateway's stream end does). + await act(async () => { + handle!.updateSessionState(RUNTIME_ID, state => ({ + ...state, + awaitingResponse: false, + busy: false, + streamId: null, + turnStartedAt: null + })) + handle!.busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + }) + + // Turn two on the now-existing session. + await expect(handle!.submitText('second prompt')).resolves.toBe(true) + expect(handle!.bindings()).toEqual({ runtimeForStored: RUNTIME_ID, storedForRuntime: STORED_ID }) + + // Every session-scoped RPC (create + both submits) hit ONE socket: the + // registry entry conn:homelab::omar that minted the runtime. + const calls = (socket: MockGateway) => socket.request.mock.calls.map(call => call[0] as string) + const omarCalls = calls(omarSocket!) + + expect(omarCalls).toContain('session.create') + expect(omarCalls.filter(method => method === 'prompt.submit')).toHaveLength(2) + expect( + omarSocket!.request.mock.calls + .filter(call => call[0] === 'prompt.submit') + .map(call => [(call[1] as { session_id: string }).session_id, (call[1] as { text: string }).text]) + ).toEqual([ + [RUNTIME_ID, 'first prompt'], + [RUNTIME_ID, 'second prompt'] + ]) + expect(activeGateway()).toBe(omarSocket as never) + expect(registryBackendScopeKey(activeGatewayConnectionId(), activeGatewayProfileKey())).toBe(omarScope) + + // Nothing session-scoped reached the remote primary, the source's default + // socket, or a v1 requestGatewayForProfile("omar") socket. + expect(calls(primary).filter(method => method === 'session.create' || method === 'prompt.submit')).toEqual([]) + + for (const socket of sockets) { + if (socket !== omarSocket) { + expect( + socket.request.mock.calls.filter(call => sessionScoped(call[1]) || call[0] === 'session.create') + ).toEqual([]) + } + } + + expect(sockets.some(socket => socket.connectUrl?.includes(`:${V1_PORT}`))).toBe(false) + + // No session-not-found: any misrouted RPC would have thrown out of + // submitText (asserted true above) — and no REST probe was needed. + expect(vi.mocked(getSession)).not.toHaveBeenCalled() + + // No ws_orphan_reap precondition: the client never closed, re-created or + // abandoned the runtime it minted; the durable owner is the exact entry. + for (const socket of [primary, ...sockets]) { + expect(calls(socket).filter(method => method === 'session.close')).toEqual([]) + } + + expect(omarCalls.filter(method => method === 'session.create')).toHaveLength(1) + expect(getSessionOwnerHint(STORED_ID)).toEqual({ connectionId: SOURCE_ID, profile: 'omar' }) + expect($sessions.get().find(session => sessionMatchesStoredId(session, STORED_ID))).toMatchObject({ + connection_id: SOURCE_ID, + profile: 'omar' + }) + expect($newChatConnectionId.get()).toBe(SOURCE_ID) + }) + + it('a pick on the explicit `local` source is a legacy profile pick: create and both turns ride the ONE v1 omar socket', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + + // Active registry source: `local` (This device), on its default profile. + await ensureGatewayAgent('local', 'default') + expect(activeGatewayConnectionId()).toBe('local') + + // 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 LEGACY_RUNTIME_ID = 'rt-omar-legacy-1' + const LEGACY_STORED_ID = 'stored-omar-legacy-1' + + ownerPort = V1_PORT + mintedRuntimeId = LEGACY_RUNTIME_ID + mintedStoredId = LEGACY_STORED_ID + selectProfile('omar') + expect($newChatProfile.get()).toBe('omar') + expect($newChatRoute.get()).toBeNull() + expect($newChatConnectionId.get()).toBeNull() + await waitFor(() => expect(activeGatewayProfileKey()).toBe('omar')) + expect(activeGatewayConnectionId()).toBeNull() + + const desktop = window.hermesDesktop! + + expect(desktop.getConnection).toHaveBeenCalledWith('omar') + expect(desktop.getConnectionFor).not.toHaveBeenCalledWith({ connectionId: 'local', profile: 'omar' }) + + const v1Socket = sockets.find(socket => socket.connectUrl?.includes(`:${V1_PORT}`)) + expect(v1Socket, `no v1 socket dialed; dialed: ${sockets.map(s => s.connectUrl).join(', ')}`).toBeDefined() + expect(activeGateway()).toBe(v1Socket as never) + expect(sockets.some(socket => socket.connectUrl?.includes(`:${OMAR_PORT}`))).toBe(false) + + const ambientRequest = vi.fn(async (method: string, params?: Record) => + (activeGateway() as unknown as MockGateway).request(method, params) + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} />) + await waitFor(() => expect(handle).not.toBeNull()) + + await expect(handle!.submitText('first prompt')).resolves.toBe(true) + await waitFor(() => expect($activeSessionId.get()).toBe(LEGACY_RUNTIME_ID)) + expect(handle!.bindings()).toEqual({ runtimeForStored: LEGACY_RUNTIME_ID, storedForRuntime: LEGACY_STORED_ID }) + + await act(async () => { + handle!.updateSessionState(LEGACY_RUNTIME_ID, state => ({ + ...state, + awaitingResponse: false, + busy: false, + streamId: null, + turnStartedAt: null + })) + handle!.busyRef.current = false + setBusy(false) + setAwaitingResponse(false) + }) + + await expect(handle!.submitText('second prompt')).resolves.toBe(true) + + // The legacy owner is the bare profile: no registry route, no hint — the + // row's profile names the same v1 pool entry that minted the runtime. + const calls = (socket: MockGateway) => socket.request.mock.calls.map(call => call[0] as string) + + expect(calls(v1Socket!).filter(method => method === 'session.create')).toHaveLength(1) + expect( + v1Socket!.request.mock.calls + .filter(call => call[0] === 'prompt.submit') + .map(call => (call[1] as { text: string }).text) + ).toEqual(['first prompt', 'second prompt']) + expect(calls(primary).filter(method => method === 'session.create' || method === 'prompt.submit')).toEqual([]) + + for (const socket of sockets) { + if (socket !== v1Socket) { + expect( + socket.request.mock.calls.filter(call => sessionScoped(call[1]) || call[0] === 'session.create') + ).toEqual([]) + } + } + + expect(sockets.some(socket => socket.connectUrl?.includes(`:${OMAR_PORT}`))).toBe(false) + expect(vi.mocked(getSession)).not.toHaveBeenCalled() + + for (const socket of [primary, ...sockets]) { + expect(calls(socket).filter(method => method === 'session.close')).toEqual([]) + } + + expect(getSessionOwnerHint(LEGACY_STORED_ID)).toBeUndefined() + expect($sessions.get().find(session => sessionMatchesStoredId(session, LEGACY_STORED_ID))).toMatchObject({ + profile: 'omar' + }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 3d80e420e3..096e482c64 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -23,7 +23,13 @@ import { applyGoalStatusText } from '@/store/goals' import { dismissNotification, notify, notifyError } from '@/store/notifications' import { setPetScale } from '@/store/pet-gallery' import { $petGenInput, openPetGenerate } from '@/store/pet-generate' -import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { + $activeGatewayProfile, + $newChatProfile, + captureNewChatSource, + ensureGatewayProfile, + normalizeProfileKey +} from '@/store/profile' import { $connection, $sessions, @@ -802,6 +808,9 @@ export function useSlashCommand(deps: SlashCommandDeps) { $newChatProfile.set(key) await ensureGatewayProfile(key) + // Capture the source the swap landed on (null on the v1 profile path) + // so the draft's owner matches the socket that will mint it. + captureNewChatSource() notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) } catch (err) { notifyError(err, copy.setProfileFailed) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 2325d9d98c..4a0ad3b146 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -698,6 +698,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { startingStoredSessionId = selectedStoredSessionIdRef.current startingSelectedStoredSessionId = selectedStoredSessionIdRef.current startingRouteToken = getRouteToken() + // The target too: it was captured BEFORE the create (null for a fresh + // draft) and seedOptimistic hands it to updateSessionState as the + // stored id, which the state cache reads as a deliberate DETACH — so + // the freshly bound stored↔runtime mapping was severed the moment the + // chat existed. Every later session-scoped RPC then failed to + // translate the runtime id to the stored id, never saw the session's + // tile route / owner hint / row, probed REST by a runtime id, and fell + // to the ambient socket — the fresh-chat owner loss behind #94071. + targetStoredSessionId = selectedStoredSessionIdRef.current seedOptimistic(sessionId) } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 27092046e9..b454cc8730 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -30,13 +30,13 @@ import { $activeGatewayProfile, $gatewaySwapTarget, $newChatProfile, - $newChatRoute, $profiles, $showAllProfiles, type AgentProfileRoute, ensureGatewayAgent, ensureGatewayProfile, - normalizeProfileKey + normalizeProfileKey, + resolveNewChatOwnerRoute } from '@/store/profile' import { $projectScope, @@ -218,7 +218,7 @@ function reconcileAuthoritativeMessages( // never the profile default (that lives in Settings → Model). async function desktopSessionCreateParams( cwd: string, - capturedRoute = $newChatRoute.get() + capturedRoute = resolveNewChatOwnerRoute() ): Promise> { // Treat Send as the linearization point for the visible selector state. The // profile handshake below can yield long enough for background config/model @@ -474,7 +474,14 @@ export function useSessionActions({ ? workspaceTarget.trim() : $currentCwd.get().trim() || resolveNewSessionCwd() - const capturedRoute = $newChatRoute.get() + // The EXACT owner for this create: an explicit agent route, else the + // (registry source, profile) pair the draft was made on. Read ONCE at + // the send linearization point and threaded through the create RPC, + // the owner hint, the optimistic row and the failure cleanup, so the + // profile-rail path (selectProfile clears $newChatRoute) can no longer + // reduce the owner to a bare profile name that later RPCs dial on a + // different socket than the one that minted the runtime. + const capturedRoute = resolveNewChatOwnerRoute() const params = await desktopSessionCreateParams(cwd, capturedRoute) const created = capturedRoute @@ -637,7 +644,7 @@ export function useSessionActions({ // `options?.cwd || resolve…` is wrong for Home: null is falsy and used // to fall through into the last project folder while main chat was // occupied (openTab path for "New session in Home"). - const capturedRoute = options?.route === undefined ? $newChatRoute.get() : options.route + const capturedRoute = options?.route === undefined ? resolveNewChatOwnerRoute() : options.route const workspaceScope = options?.workspaceScope ?? { workspaceMode: 'sessions' } const cwd = diff --git a/apps/desktop/src/store/connections.test.ts b/apps/desktop/src/store/connections.test.ts index 9af6bd0ebf..9d5ab7cb0e 100644 --- a/apps/desktop/src/store/connections.test.ts +++ b/apps/desktop/src/store/connections.test.ts @@ -70,6 +70,7 @@ vi.mock('@/store/profile', () => ({ $activeGatewayProfile, $newChatProfile, $showAllProfiles, + captureNewChatSource: vi.fn(), ensureGatewayAgent, normalizeProfileKey: (name: null | string | undefined) => (name ?? '').trim() || 'default', openGatewayAgent, diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index aa6d220dc1..59e26cdda0 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -13,6 +13,7 @@ import { $activeGatewayProfile, $newChatProfile, $showAllProfiles, + captureNewChatSource, ensureGatewayAgent, normalizeProfileKey, openGatewayAgent, @@ -245,6 +246,10 @@ export async function selectConnection(connectionId: string): Promise { if (pendingTarget === null && currentConnectionId === connectionId && currentProfile === targetProfile) { $showAllProfiles.set(false) $newChatProfile.set(targetProfile) + // A connection switch is a new-chat intent on THAT source: keep the + // registry identity with the profile so the next create names local::x / + // ::x exactly, never a bare profile string. + captureNewChatSource() requestFreshSession() await rememberConnection(connectionId) @@ -359,6 +364,7 @@ export async function selectConnection(connectionId: string): Promise { } $newChatProfile.set(targetProfile) + captureNewChatSource() requestFreshSession() await refreshActiveProfile() } diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index de71b8138a..7a274b049f 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -261,6 +261,74 @@ export interface AgentProfileRoute { // change before the first Send; the draft's owner must not change with it. export const $newChatRoute = atom(null) +// The registry source captured TOGETHER with a $newChatProfile intent +// (selectProfile / newSessionInProfile / a connection switch / `/profile`). +// A profile is not a machine-global name: "omar" picked while the remote +// registry source `homelab` is active means homelab::omar — the exact registry +// entry whose WebSocket will mint the runtime. Without this the profile-rail +// path (which deliberately clears $newChatRoute) reduced the owner to the bare +// 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 profile +// pick on the explicit `local` source — see profilePickConnectionId). +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. */ +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, while the primary and + * the explicit `local` source 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. The draft's owner must name the socket that activation + * dials — capturing `local` for a pick would mint the session on the registry + * entry local::x while the window shows the override's socket. + */ +function profilePickConnectionId(): null | string { + const connectionId = activeGatewayConnectionId() + + return connectionId && connectionId !== LOCAL_CONNECTION_ID ? connectionId : 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, + * whenever a registry source is live, the (connection, profile) pair is + * derived from the source captured with the profile intent — falling back to + * the source a profile pick would dial (an uncaptured intent), or to the + * active source when there is no profile intent at all — so session.create, + * the owner hint, the optimistic row and every later session-scoped RPC name + * the same registry entry. A legacy profile-only activation yields null. + */ +export function resolveNewChatOwnerRoute(): AgentProfileRoute | null { + const explicit = $newChatRoute.get() + + if (explicit) { + return explicit + } + + const intentProfile = $newChatProfile.get() + + const connectionId = ( + (intentProfile ? ($newChatConnectionId.get() ?? profilePickConnectionId()) : activeGatewayConnectionId()) ?? '' + ).trim() + + if (!connectionId) { + return null + } + + return { + connectionId, + profile: normalizeProfileKey(intentProfile || $activeGatewayProfile.get()) + } +} + // Bumped whenever the open session should be dropped for a fresh new-session // draft: a profile switch/create (below), or deleting the project that owns the // currently-open session (store/projects). The chat controller subscribes and @@ -681,6 +749,11 @@ export function selectProfile(name: string): void { $showAllProfiles.set(false) $newChatProfile.set(target) $newChatRoute.set(null) + // Clearing the agent route must NOT discard the registry identity: the pick + // 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()) if (switching) { requestFreshSession() @@ -723,11 +796,9 @@ export function selectProfile(name: string): void { // the main process can resolve a per-profile remote override before falling // back to a local backend. function activateOnCurrentSource(target: string): Promise { - const connectionId = activeGatewayConnectionId() + const connectionId = profilePickConnectionId() - return connectionId && connectionId !== LOCAL_CONNECTION_ID - ? ensureGatewayAgent(connectionId, target) - : ensureGatewayProfile(target) + return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target) } // Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse @@ -740,6 +811,7 @@ export function newSessionInProfile(name: string): void { const target = normalizeProfileKey(name) $newChatProfile.set(target) $newChatRoute.set(null) + captureNewChatSource(profilePickConnectionId()) requestFreshSession() // #81094: surface the failed dial instead of failing silently. void activateOnCurrentSource(target).catch((error: unknown) => { @@ -766,6 +838,7 @@ export function newSessionInAgent(route: AgentProfileRoute): void { $newChatProfile.set(captured.profile) $newChatRoute.set(captured) + $newChatConnectionId.set(captured.connectionId) requestFreshSession() // #81094: surface the failed dial instead of failing silently. void ensureGatewayAgent(captured.connectionId, captured.profile).catch((error: unknown) => {