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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user