fix(desktop): profile-rail fresh chats keep their registry source as the exact owner

The Sessions / profile-rail path (selectProfile, newSessionInProfile, a
connection switch, `/profile`) sets $newChatProfile and deliberately clears
$newChatRoute, so a fresh chat had no explicit owner. The session was created
on the active registry gateway (conn:local::omar) but its durable owner
degraded to the bare string "omar": follow-up RPCs dialed
requestGatewayForProfile("omar") — a different socket than the one that
minted the WebSocket-scoped runtime — and 4001'd "session not found" while
the runtime was ws-orphan-reaped.

- store/profile: capture the active registry source together with the
  new-chat profile intent ($newChatConnectionId / captureNewChatSource) in
  selectProfile, newSessionInProfile, newSessionInAgent, connection switches
  and `/profile`; resolveNewChatOwnerRoute() derives the exact
  { connectionId, profile } route whenever a registry source is live, even
  with $newChatRoute null (legacy v1 primary still yields null).
- use-session-actions: session.create, the owner hint, the optimistic row's
  profile + connection_id, and the failed-create cleanup all use that
  effective owner (main chat and tile paths).
- use-prompt-actions/submit: re-pin targetStoredSessionId after a fresh
  create. It was captured before the create (null) and seedOptimistic handed
  it to updateSessionState, which the state cache read as a DETACH — the
  fresh stored↔runtime binding was severed the moment the chat existed, so
  every later session-scoped RPC failed to translate the runtime id, never
  saw the tile route / owner hint / row, probed REST by runtime id and fell
  to the ambient socket.
- contrib: the session-RPC dispatcher is factored out of wiring.tsx
  (createSessionRpcDispatcher) so the exact production routing is what the
  integration test drives.

Regression (profile-rail-fresh-chat-owner.test.tsx) drives the real path:
mocked sockets under the real registry store, primary = remote default,
active source = local, selectProfile("omar") ($newChatProfile = "omar",
$newChatRoute = null), real useSessionStateCache / useSessionActions /
usePromptActions and the production dispatcher; asserts session.create and
BOTH prompt.submit calls hit the same conn:local::omar gateway object, no
session-scoped RPC reached the primary or a v1 "omar" socket, no
session.close, the binding survives both turns, no REST probe.

Verified with the packaged Linux Desktop against the real ~/.hermes
(primary = remote OAuth gateway, "This device" as registry source, omar via
the profile rail, two prompts): both prompts persisted on one session in
profiles/omar/state.db, no ws_orphan_reap.

Refs #94071

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Zeus-Deus
2026-08-25 00:35:16 +02:00
committed by Teknium
parent 77edf1b4df
commit cb75983abf
9 changed files with 793 additions and 103 deletions
@@ -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 = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface SessionRpcDispatcherDeps {
ambientRequest: AmbientGatewayRequest
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionIdRef: MutableRefObject<null | string>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
}
export function createSessionRpcDispatcher(deps: SessionRpcDispatcherDeps): AmbientGatewayRequest {
const { ambientRequest, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef } = deps
return async <T>(method: string, params?: Record<string, unknown>, 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<T>(owner, ambientRequest, method, params ?? {}, timeoutMs, signal)
}
}
+12 -93
View File
@@ -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 <T,>(method: string, params?: Record<string, unknown>, 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<T>(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]
)
@@ -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<string, unknown>) => Promise<unknown>>
interface MockGateway {
connectUrl: null | string
connectionState: string
connect: Mock<(url: string) => Promise<void>>
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<string, unknown>) {
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<Record<string, unknown>>()),
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<string, unknown> = {}) => {
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<string, unknown> = {}) => answer(primary, method, params))
}
return primary
}
interface HarnessHandle {
busyRef: { current: boolean }
bindings: () => { runtimeForStored: null | string; storedForRuntime: null | string }
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
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<boolean>,
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<string, unknown>) =>
(activeGateway() as unknown as MockGateway).request(method, params)
)
let handle: HarnessHandle | null = null
render(<Harness ambientRequest={ambientRequest as never} onReady={h => (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<string, unknown>) =>
(activeGateway() as unknown as MockGateway).request(method, params)
)
let handle: HarnessHandle | null = null
render(<Harness ambientRequest={ambientRequest as never} onReady={h => (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'
})
})
})
@@ -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)
@@ -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)
}
@@ -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<Record<string, unknown>> {
// 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 =
@@ -70,6 +70,7 @@ vi.mock('@/store/profile', () => ({
$activeGatewayProfile,
$newChatProfile,
$showAllProfiles,
captureNewChatSource: vi.fn(),
ensureGatewayAgent,
normalizeProfileKey: (name: null | string | undefined) => (name ?? '').trim() || 'default',
openGatewayAgent,
+6
View File
@@ -13,6 +13,7 @@ import {
$activeGatewayProfile,
$newChatProfile,
$showAllProfiles,
captureNewChatSource,
ensureGatewayAgent,
normalizeProfileKey,
openGatewayAgent,
@@ -245,6 +246,10 @@ export async function selectConnection(connectionId: string): Promise<void> {
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 /
// <source>::x exactly, never a bare profile string.
captureNewChatSource()
requestFreshSession()
await rememberConnection(connectionId)
@@ -359,6 +364,7 @@ export async function selectConnection(connectionId: string): Promise<void> {
}
$newChatProfile.set(targetProfile)
captureNewChatSource()
requestFreshSession()
await refreshActiveProfile()
}
+77 -4
View File
@@ -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<AgentProfileRoute | null>(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 | 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. */
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<void> {
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) => {