Files
aiturk-hermes-ide/apps/desktop/src/store/session-states-runtime-map.test.ts
T

215 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { setPrimaryGateway, setPrimaryGatewayConnection } from '@/store/gateway'
import { $profiles } from '@/store/profile'
import { _resetSessionOwnerHintsForTests, setSessionOwnerHint, setSessions } from '@/store/session'
import { isSessionOwnerResolutionError } from '@/store/session-owner-resolution'
import {
$sessionTiles,
clearAllSessionStates,
dropSessionState,
knownOwnerForSession,
publishSessionState,
recordSessionEventScope,
requestForOwnedSession,
storedSessionIdForRuntimeId
} from '@/store/session-states'
import { makeSessionInfo } from '@/test/session-info'
// #92687-adjacent Bot Mode misroute: a session RPC (prompt.submit et al.)
// carries its target as a RUNTIME id, while tile owner routes key on the
// STORED id. requestGateway (contrib/wiring) routes by the RPC's own target
// session — which requires this translation. Before the fix it routed by the
// WINDOW's focused tile, so a background bot chat's submit dispatched on
// whatever backend the focused pane owned: the bot ran on the default
// backend, or 4001'd when default didn't hold the session.
describe('storedSessionIdForRuntimeId', () => {
afterEach(() => {
$sessionTiles.set([])
})
it('maps a runtime id to the stored id of the tile bound to it', () => {
$sessionTiles.set([
{ runtimeId: 'rt-default', storedSessionId: 'stored-default' },
{ runtimeId: 'rt-developer', storedSessionId: 'stored-developer' }
])
expect(storedSessionIdForRuntimeId('rt-developer')).toBe('stored-developer')
expect(storedSessionIdForRuntimeId('rt-default')).toBe('stored-default')
})
it('passes a stored id through unchanged (callers may hold either identity)', () => {
$sessionTiles.set([{ runtimeId: 'rt-a', storedSessionId: 'stored-a' }])
expect(storedSessionIdForRuntimeId('stored-a')).toBe('stored-a')
})
it('returns null for an unknown id so the caller falls back to ambient routing', () => {
$sessionTiles.set([{ runtimeId: 'rt-a', storedSessionId: 'stored-a' }])
expect(storedSessionIdForRuntimeId('rt-unknown')).toBeNull()
expect(storedSessionIdForRuntimeId('')).toBeNull()
})
it('ignores tiles with no runtime binding instead of matching undefined ids', () => {
// A drafted/never-resumed tile has no runtimeId. Looking up an undefined-ish
// id must not accidentally claim that tile.
$sessionTiles.set([{ storedSessionId: 'stored-unbound' }, { runtimeId: 'rt-b', storedSessionId: 'stored-b' }])
expect(storedSessionIdForRuntimeId('rt-b')).toBe('stored-b')
expect(storedSessionIdForRuntimeId('undefined')).toBeNull()
})
it('maps a MAIN-PANE runtime id through the per-runtime state mirror (no tile involved)', () => {
// approval.respond from a native notification, a queued send: the caller
// holds the runtime id of the primary thread, which no tile knows. The
// state mirror carries the stored id the wiring cache bound.
publishSessionState('rt-main', createClientSessionState('stored-main'))
expect(storedSessionIdForRuntimeId('rt-main')).toBe('stored-main')
// A detached runtime (null stored id) is still unknown.
publishSessionState('rt-detached', createClientSessionState(null))
expect(storedSessionIdForRuntimeId('rt-detached')).toBeNull()
clearAllSessionStates()
})
it('prefers the stored-id identity when one tile is stored-matched and another is runtime-matched', () => {
// Pathological but possible after a stale rebind: some other tile's dead
// runtimeId equals a live tile's storedSessionId. The stored-id claim is
// authoritative (durable identity wins).
$sessionTiles.set([
{ runtimeId: 'collision', storedSessionId: 'stored-other' },
{ runtimeId: 'rt-live', storedSessionId: 'collision' }
])
expect(storedSessionIdForRuntimeId('collision')).toBe('collision')
})
})
describe('knownOwnerForSession / requestForOwnedSession', () => {
afterEach(() => {
$sessionTiles.set([])
clearAllSessionStates()
setSessions([])
$profiles.set([])
_resetSessionOwnerHintsForTests({ storage: true })
})
it('resolves a main-pane runtime id to its EXACT owner via the mirror + the hint, then the tagged row', () => {
publishSessionState('rt-main', createClientSessionState('stored-main'))
setSessionOwnerHint('stored-main', { connectionId: 'local', profile: 'omar' })
expect(knownOwnerForSession('rt-main')).toEqual({ connectionId: 'local', profile: 'omar' })
_resetSessionOwnerHintsForTests()
setSessions([makeSessionInfo({ connection_id: 'local', id: 'stored-main', profile: 'omar' })])
expect(knownOwnerForSession('rt-main')).toEqual({ connectionId: 'local', profile: 'omar' })
setSessions([makeSessionInfo({ id: 'stored-main', profile: 'coder' })])
expect(knownOwnerForSession('rt-main')).toBe('coder')
})
it('fails closed with an explicit owner-resolution error instead of the ambient socket', async () => {
// Somewhere to misroute to: two profiles exist.
$profiles.set([{ name: 'default' }, { name: 'omar' }] as never)
const ambient = vi.fn(async () => ({ ok: true }))
await expect(
requestForOwnedSession('rt-orphan', ambient as never, 'approval.respond', { session_id: 'rt-orphan' })
).rejects.toSatisfy(isSessionOwnerResolutionError)
expect(ambient).not.toHaveBeenCalled()
// Legacy single backend: the ambient gateway IS the owner.
$profiles.set([{ name: 'default' }] as never)
await expect(
requestForOwnedSession('rt-orphan', ambient as never, 'approval.respond', { session_id: 'rt-orphan' })
).resolves.toEqual({ ok: true })
expect(ambient).toHaveBeenCalledWith('approval.respond', { session_id: 'rt-orphan' })
})
it('routes a connection-tagged orphan runtime through the owner its inbound event recorded (#97511)', () => {
// Registry topology, multiple profiles, no tile/hint/row binding for the
// runtime — the approval.request event itself proved the exact owner.
$profiles.set([{ name: 'default' }, { name: 'omar' }] as never)
recordSessionEventScope({ connectionId: 'homelab', profile: 'omar', session_id: 'rt-unbound' })
expect(knownOwnerForSession('rt-unbound')).toEqual({ connectionId: 'homelab', profile: 'omar' })
// An event without a profile tag still records the 'default' convention
// every other owner source uses.
recordSessionEventScope({ connectionId: 'homelab', session_id: 'rt-unprofiled' })
expect(knownOwnerForSession('rt-unprofiled')).toEqual({ connectionId: 'homelab', profile: 'default' })
})
it('still prefers the durable stored owner when a stale runtime ledger entry collides with a stored id (#97511)', () => {
// Pathological collision: some dead runtime's id equals a live stored id.
// The persisted hint (durable identity) must outrank the ledger entry.
setSessionOwnerHint('stored-live', { connectionId: 'local', profile: 'omar' })
recordSessionEventScope({ connectionId: 'spark', profile: 'default', session_id: 'stored-live' })
expect(knownOwnerForSession('stored-live')).toEqual({ connectionId: 'local', profile: 'omar' })
})
it('keeps failing closed for untagged or unknown runtimes in multi-profile topology (#97511)', () => {
$profiles.set([{ name: 'default' }, { name: 'omar' }] as never)
// Untagged events carry no connectionId and record nothing.
recordSessionEventScope({ profile: 'omar', session_id: 'rt-untagged' })
expect(knownOwnerForSession('rt-untagged')).toBeUndefined()
expect(knownOwnerForSession('rt-never-seen')).toBeUndefined()
})
it('drops the recorded event owner together with the runtime state (#97511)', () => {
recordSessionEventScope({ connectionId: 'homelab', profile: 'omar', session_id: 'rt-dropped' })
expect(knownOwnerForSession('rt-dropped')).toEqual({ connectionId: 'homelab', profile: 'omar' })
dropSessionState('rt-dropped')
expect(knownOwnerForSession('rt-dropped')).toBeUndefined()
})
it('answers an approval on a sole-local registry install through the primary socket (#96394)', async () => {
// The reported topology: a modern Desktop (connections bridge present,
// registry loaded with exactly one `local` connection), one profile, and
// an approval.request whose runtime id has no tile / hint / row binding.
// hasRegistryTopology() is true here, so the ambient escape hatch is
// closed by design — the exact owner must come from the event itself.
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { connections: { list: async () => null } }
$connectionsRegistry.set({
activeConnectionId: 'local',
connections: [{ id: 'local', kind: 'local', label: 'Local' }]
} as never)
$profiles.set([{ name: 'default' }] as never)
const primaryRequest = vi.fn(async (method: string, params: unknown) => ({ method, params, via: 'primary' }))
setPrimaryGateway({ onEvent: () => () => undefined, request: primaryRequest, state: 'open' } as never, 'default')
setPrimaryGatewayConnection({ connectionId: 'local' })
const ambient = vi.fn(async () => ({ via: 'ambient' }))
try {
// Before the event lands the owner is unknown and routing still fails closed.
await expect(
requestForOwnedSession('rt-approval', ambient as never, 'approval.respond', { session_id: 'rt-approval' })
).rejects.toSatisfy(isSessionOwnerResolutionError)
// use-gateway-boot stamps every primary event with the active connection
// id (Electron resolves the sole local connection to `local`).
recordSessionEventScope({ connectionId: 'local', profile: 'default', session_id: 'rt-approval' })
await expect(
requestForOwnedSession('rt-approval', ambient as never, 'approval.respond', { session_id: 'rt-approval' })
).resolves.toEqual({ method: 'approval.respond', params: { session_id: 'rt-approval' }, via: 'primary' })
expect(ambient).not.toHaveBeenCalled()
} finally {
setPrimaryGateway(null)
$connectionsRegistry.set(null)
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
}
})
})