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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ import {
findRemoteOwnerProfileForSession,
mergeProfileSessionWindow,
type RegistrySessionSource,
spliceRegistrySessionRows
spliceRegistrySessionRows,
tagRegistrySessionResponse
} from './profile-session-routing'
import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry'
import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
Expand Down Expand Up @@ -13998,12 +13999,16 @@ async function dispatchRegistryApiRequest(

const requestPath = pathForRegistryBackendRequest(request.path, requestProfile, connection)

return fetchJsonForBackend(connection, requestPath, {
const response = await fetchJsonForBackend(connection, requestPath, {
method: request?.method,
body: request?.body,
upload: request?.upload,
timeoutMs: resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
})

return (request?.method || 'GET').toUpperCase() === 'GET'
? tagRegistrySessionResponse(requestPath, response, registryConnectionId)
: response
}

function registryConnectionKind(connectionId) {
Expand Down
43 changes: 42 additions & 1 deletion apps/desktop/electron/profile-session-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
fetchRemoteProfileSessions,
findRemoteOwnerProfileForSession,
mergeProfileSessionWindow,
spliceRegistrySessionRows
spliceRegistrySessionRows,
tagRegistrySessionResponse
} from './profile-session-routing'

test('remote sidebar slices all follow the selected profile', () => {
Expand Down Expand Up @@ -301,6 +302,46 @@ test('registry sources: shared remote hosts read the cross-profile aggregate onc
)
})

test('registry-pinned session responses retain their owning connection', () => {
const sidebar = tagRegistrySessionResponse(
'/api/profiles/sessions/sidebar?recents_profile=default',
{
recents: { sessions: [{ id: 'remote-chat', profile: 'default' }] },
cron: { sessions: [{ id: 'remote-cron', profile: 'default' }] },
messaging: { sessions: [] }
},
'test-amnezia'
) as any

assert.equal(sidebar.recents.sessions[0].connection_id, 'test-amnezia')
assert.equal(sidebar.cron.sessions[0].connection_id, 'test-amnezia')

const aggregate = tagRegistrySessionResponse(
'/api/profiles/sessions?profile=all',
{ sessions: [{ id: 'remote-profile-chat', profile: 'research' }] },
'test-amnezia'
) as any

assert.equal(aggregate.sessions[0].connection_id, 'test-amnezia')

const single = tagRegistrySessionResponse(
'/api/sessions/remote-chat?profile=default',
{ id: 'remote-chat', profile: 'default' },
'test-amnezia'
) as any

assert.equal(single.connection_id, 'test-amnezia')
})

test('registry response ownership tagging ignores non-session payloads and transcript messages', () => {
const status = { ok: true }
const messages = { messages: [{ id: 'message-1' }], session_id: 'remote-chat' }

assert.equal(tagRegistrySessionResponse('/api/status', status, 'test-amnezia'), status)
assert.equal(tagRegistrySessionResponse('/api/sessions/remote-chat/messages', messages, 'test-amnezia'), messages)
assert.equal((messages.messages[0] as any).connection_id, undefined)
})

test('registry sources: an older shared host without the aggregator falls back to its flat list', async () => {
const calls: string[] = []

Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/electron/profile-session-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,52 @@ function rowsOf(data: unknown): unknown[] {
return Array.isArray(data.sessions) ? data.sessions : []
}

function tagRowsWithConnection(rows: unknown[], connectionId: string): void {
for (const row of rows) {
if (row && typeof row === 'object') {
const session = row as Record<string, unknown>
session.connection_id = connectionId
}
}
}

/** Preserve the registry source that served a session REST response.
*
* A registry-pinned request is dispatched directly to that remote host, so its
* own session rows naturally omit Desktop's synthetic `connection_id`. Without
* restoring that provenance, a `profile: "default"` row later resumes through
* the legacy local primary instead of the active registry gateway. */
export function tagRegistrySessionResponse(path: string, data: unknown, connectionId: string): unknown {
if (!data || typeof data !== 'object') {
return data
}

const pathname = path.split('?', 1)[0].replace(/\/+$/, '')

if (pathname === '/api/sessions' || pathname === '/api/profiles/sessions') {
tagRowsWithConnection(rowsOf(data), connectionId)

return data
}

if (pathname === '/api/profiles/sessions/sidebar') {
const response = data as Record<string, unknown>

for (const key of ['recents', 'cron', 'messaging']) {
tagRowsWithConnection(rowsOf(response[key]), connectionId)
}

return data
}

if (/^\/api\/sessions\/[^/]+$/.test(pathname)) {
const session = data as Record<string, unknown>
session.connection_id = connectionId
}

return data
}

function sessionId(row: unknown): string | null {
if (!row || typeof row !== 'object' || !('id' in row)) {
return null
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
$selectedStoredSessionId,
$sessionResumeRequest,
$sessions,
knownSessionProfile,
knownSessionOwner,
sessionMatchesStoredId,
sessionPinId,
setAwaitingResponse,
Expand Down Expand Up @@ -358,7 +358,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {

let owner: SessionOwnerScope =
(routingSessionId ? sessionTileOwnerRoute(routingSessionId) : undefined) ??
knownSessionProfile($sessions.get(), routingSessionId)
knownSessionOwner($sessions.get(), routingSessionId)

if (!owner && routingSessionId) {
// Unknown owner for a REAL session: probe across profiles (REST, not the
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $desktopBoot } from '@/store/boot'
import { closeSecondaryGateways, isActivePrimary } from '@/store/gateway'
import { closeSecondaryGateways, isActivePrimary, requestGatewayForAgent } from '@/store/gateway'
import { reconnectGateway } from '@/store/gateway-reconnect'
import { $activeGatewayProfile, $profiles, ensureGatewayProfile } from '@/store/profile'
import { $connection, $currentCwd, $gatewayState } from '@/store/session'
Expand Down Expand Up @@ -350,6 +350,17 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($gatewayState.get()).toBe('open')
})

it('publishes the cold-boot primary registry identity for owned session RPCs', async () => {
render(<Harness />)
await flushAsync()

expect($gatewayState.get()).toBe('open')
expect(FakeWebSocket.instances).toHaveLength(1)

await expect(requestGatewayForAgent('primary-vps', 'default', 'ping')).resolves.toEqual({ pong: true })
expect(FakeWebSocket.instances).toHaveLength(1)
})

it('re-fetches the profile rail from the NEW backend after a connection apply (#85731)', async () => {
// The reported repro: connected to backend A, the rail shows A's named
// profiles; the user applies a different remote/Cloud connection (soft
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
reconnectSecondaryGateways,
reportPrimaryGatewayState,
setPrimaryGateway,
setPrimaryGatewayConnection,
touchSecondaryGateways
} from '@/store/gateway'
import { registerGatewayReconnect } from '@/store/gateway-reconnect'
Expand Down Expand Up @@ -282,6 +283,8 @@ export function useGatewayBoot({
'Timed out reconnecting to Hermes backend'
)

setPrimaryGatewayConnection(conn)

if (cancelled) {
return
}
Expand Down Expand Up @@ -501,6 +504,7 @@ export function useGatewayBoot({
}

publish(conn)
setPrimaryGatewayConnection(conn)

// Bounded for the same reason as attemptReconnect() (#93454): a wedged
// ticket mint would otherwise hang the gateway switch forever.
Expand Down Expand Up @@ -782,6 +786,7 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
setPrimaryGatewayConnection(conn)

// Seed the workspace BEFORE the gateway opens: every session-restore
// path is gated on gatewayState === 'open', so nothing can be active yet
Expand Down Expand Up @@ -896,6 +901,7 @@ export function useGatewayBoot({

if (survivor?.connection) {
publish(survivor.connection)
setPrimaryGatewayConnection(survivor.connection)
}

const profile = survivor?.profile ?? $activeGatewayProfile.get()
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2093,6 +2093,49 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(ambientRequest).not.toHaveBeenCalled()
})

it('keeps a registry-tagged cached session on its owning connection without an explicit route', async () => {
setSessions([
storedSession({
connection_id: 'test-amnezia',
id: 'stored-registry',
profile: 'default'
})
])
vi.mocked(getSession).mockImplementation(async id =>
storedSession({ connection_id: 'test-amnezia', id, profile: 'default' })
)
vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-registry' } as never)
vi.mocked(requestGatewayForAgent).mockImplementation(async (_connectionId, _profile, method, params) => {
if (method === 'session.resume') {
return {
info: {},
messages: [],
resumed: params?.session_id,
session_id: 'runtime-registry'
} as never
}

return {} as never
})

const ambientRequest = vi.fn(async () => ({}) as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null

render(<ResumeHarness onReady={ready => (resume = ready)} requestGateway={ambientRequest} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-registry', true)

const restScope = { connectionId: 'test-amnezia', profile: 'default' }
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-registry', restScope)
expect(requestGatewayForAgent).toHaveBeenCalledWith(
'test-amnezia',
'default',
'session.resume',
expect.objectContaining({ session_id: 'stored-registry' })
)
expect(ambientRequest).not.toHaveBeenCalled()
})

it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => {
// A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its
// cached state actually belongs to a DIFFERENT session ('stored-B') — the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,12 @@ export function useSessionActions({
connectionId: ownerRoute.connectionId,
profile: ownerRoute.targetProfile || ownerRoute.profile
}
: sessionProfile
: storedForProfile?.connection_id
? {
connectionId: storedForProfile.connection_id,
profile: sessionProfile || 'default'
}
: sessionProfile

// Re-check after the profile-resolve / gateway-swap awaits above: the
// cache may have changed, and takeWarmCache re-validates belongs-to and
Expand Down
73 changes: 71 additions & 2 deletions apps/desktop/src/store/gateway-profile-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ vi.mock('@/hermes', () => ({
},
setApiRequestConnection: vi.fn()
}))
vi.mock('@/store/session', () => ({ setGatewayState: vi.fn() }))
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))

const {
Expand All @@ -58,7 +58,8 @@ const {
requestGatewayForAgent,
requestGatewayForProfile,
retainGatewayForAgent,
setPrimaryGateway
setPrimaryGateway,
setPrimaryGatewayConnection
} = await import('./gateway')

function installDesktop(getConnection: ReturnType<typeof vi.fn>): void {
Expand Down Expand Up @@ -191,6 +192,73 @@ describe('requestGatewayForProfile', () => {
})

describe('requestGatewayForAgent', () => {
it('reuses the active primary socket when its registry connection owns the session', async () => {
const primary = makePrimary()

const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: 5151,
profile,
token: 'secondary-token'
}))

setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'remote-primary' })

;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })),
touchBackend: vi.fn(async () => undefined)
}
await ensureGatewayForProfile('default')

await openGatewayForAgent('remote-primary', 'default')
await ensureGatewayForAgent('remote-primary', 'default')

const result = await requestGatewayForAgent('remote-primary', 'default', 'session.resume', {
session_id: 'stored-session'
})

expect(result).toEqual({
method: 'session.resume',
params: { session_id: 'stored-session' }
})
expect(primary.request).toHaveBeenCalledWith('session.resume', { session_id: 'stored-session' })
expect(getConnectionFor).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(0)
expect($gateway.get()).toBe(primary)
})

it('keeps another profile on the same registry source isolated from the primary', async () => {
const primary = makePrimary()

const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({
connectionId,
port: 5151,
profile,
token: 'secondary-token'
}))

setPrimaryGateway(primary as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'remote-primary' })

;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
getConnection: vi.fn(),
getConnectionFor,
getGatewayWsUrlFor: vi.fn(async () => ({ ok: true as const, wsUrl: 'wss://remote.invalid/api/ws' })),
touchBackend: vi.fn(async () => undefined)
}

await requestGatewayForAgent('remote-primary', 'research', 'session.resume', {
session_id: 'research-session'
})

expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'remote-primary', profile: 'research' })
expect(primary.request).not.toHaveBeenCalled()
expect(secondaryGateways).toHaveLength(1)
})

it('leases separate registry sockets for duplicate profile names without changing the active gateway', async () => {
const primary = makePrimary()
const getConnection = vi.fn(async (profile: null | string) => ({ port: 4242, profile, token: 'legacy-token' }))
Expand Down Expand Up @@ -273,6 +341,7 @@ describe('requestGatewayForAgent', () => {

it('evicts registry sockets when their source is edited or removed', async () => {
const primary = makePrimary()

const getConnectionFor = vi.fn(async ({ connectionId, profile }) => ({ connectionId, port: 5151, profile }))
const onActiveConnectionInvalidated = vi.fn()

Expand Down
Loading