Files
aiturk-hermes-ide/apps/desktop/src/sdk/host-state.test.ts
T

282 lines
9.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $gatewayState } from '@/store/session'
import { $sessionStates, dropSessionState, publishSessionState } from '@/store/session-states'
import { host } from './index'
// Plugins read app state exclusively through host.state — and before this
// contract existed, only the PRIMARY workspace tab was reachable
// ($activeSessionId). Clicking a tile never moved any plugin-visible atom,
// and tile focus is pure renderer state that gateway RPC can never see, so
// no plugin-side workaround was possible. These atoms are the plugin door to
// the same focused-session signals the core statusbar reads
// (use-statusbar-items.tsx).
describe('host.state focused-session atoms', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const { host } = await import('@/sdk/index')
const states = await import('@/store/session-states')
const session = await import('@/store/session')
return { host, states, session }
}
it('exposes readonly atoms for the focused session (runtime id, stored id, usage)', async () => {
const { host } = await setup()
for (const key of ['focusedSessionId', 'focusedStoredSessionId', 'focusedUsage'] as const) {
const store = host.state[key]
expect(store, key).toBeDefined()
expect(typeof store.get, key).toBe('function')
expect(typeof store.listen, key).toBe('function')
expect(typeof store.subscribe, key).toBe('function')
}
})
it('mirrors the primary session while no tile is focused', async () => {
const { host, states } = await setup()
expect(host.state.focusedSessionId.get()).toBe(states.$focusedRuntimeId.get())
expect(host.state.focusedStoredSessionId.get()).toBe(states.$focusedStoredSessionId.get())
})
it('focusedUsage projects the focused session usage, null while unresolved', async () => {
const { host, states } = await setup()
const focused = states.$focusedSessionState.get()
expect(host.state.focusedUsage.get()).toBe(focused?.usage ?? null)
})
it('exposes the registry source that owns the active gateway', async () => {
const { host, session } = await setup()
session.setConnection({ connectionId: 'work', mode: 'remote' } as never)
expect(host.state.connectionId.get()).toBe('work')
session.setConnection({ mode: 'local' } as never)
expect(host.state.connectionId.get()).toBe('local')
session.setConnection(null)
})
it('follows the interacted tile while the primary-only atom stays put', async () => {
const { host, session, states } = await setup()
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
const { registry } = await import('@/contrib/registry')
// A second chat zone holding a session tile, next to the main workspace.
for (const id of ['workspace', 'session-tile:tile-a']) {
registry.register({
area: 'panes',
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
id,
render: () => null,
title: id
})
}
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['session-tile:tile-a'], { active: 'session-tile:tile-a', id: 'grp-side' })
])
)
const primaryBefore = session.$activeSessionId.get()
const primarySelection = session.$selectedStoredSessionId.get()
// Bind the tile to a live runtime with its own usage, so the readout
// atoms (focusedSessionId / focusedUsage) — not just the navigation id —
// are proven to follow the tile.
const tileUsage = {
calls: 3,
input: 1200,
output: 300,
total: 1500,
context_used: 42000,
context_max: 200000,
context_percent: 21,
cost_usd: 0.0123
}
states.$sessionTiles.set([{ storedSessionId: 'tile-a', runtimeId: 'runtime-tile-a' }])
states.$sessionStates.set({
'runtime-tile-a': { storedSessionId: 'tile-a', usage: tileUsage } as never
})
// Focusing the tile zone moves the focused atoms onto the tile's session…
tree.noteActiveTreeGroup('grp-side')
expect(host.state.focusedStoredSessionId.get()).toBe('tile-a')
expect(host.state.focusedSessionId.get()).toBe('runtime-tile-a')
expect(host.state.focusedUsage.get()).toBe(tileUsage)
// …while the primary-only atom a plugin used to rely on does not move.
expect(host.state.activeSessionId.get()).toBe(primaryBefore)
// Focusing back homes to the primary's selection, whatever it is.
tree.noteActiveTreeGroup('grp-main')
expect(host.state.focusedStoredSessionId.get()).toBe(primarySelection)
})
})
describe('host.state.focusedSessionProfile', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setup() {
const { host } = await import('@/sdk/index')
const profile = await import('@/store/profile')
const session = await import('@/store/session')
const states = await import('@/store/session-states')
return { host, profile, session, states }
}
it('is a readonly atom that falls back to the gateway profile with no focused session', async () => {
const { host, profile } = await setup()
expect(typeof host.state.focusedSessionProfile.get).toBe('function')
expect(typeof host.state.focusedSessionProfile.listen).toBe('function')
profile.$activeGatewayProfile.set('newsanalyst')
expect(host.state.focusedSessionProfile.get()).toBe('newsanalyst')
profile.$activeGatewayProfile.set('default')
})
it('resolves the focused session to the owner stamped on its session row, not the socket home', async () => {
const { host, profile, session } = await setup()
// The gateway socket is homed on default (e.g. cold start), but the chat
// on screen belongs to newsanalyst — the readout must say newsanalyst.
profile.$activeGatewayProfile.set('default')
session.$sessions.set([{ id: 'news-1', profile: 'newsanalyst' } as never])
session.$selectedStoredSessionId.set('news-1')
expect(host.state.focusedSessionProfile.get()).toBe('newsanalyst')
// An uncached/unstamped id inherits the gateway profile (the old ladder).
session.$selectedStoredSessionId.set('unknown-id')
expect(host.state.focusedSessionProfile.get()).toBe('default')
session.$sessions.set([])
session.$selectedStoredSessionId.set(null)
})
it('fails closed when duplicate stored or lineage ids resolve to different connections', async () => {
const { host, session } = await setup()
session.$sessions.set([
{
connection_id: 'source-a',
id: 'shared-tip-a',
_lineage_root_id: 'shared-root',
profile: 'worker'
} as never,
{
connection_id: 'source-b',
id: 'shared-root',
profile: 'worker'
} as never
])
session.$selectedStoredSessionId.set('shared-root')
expect(host.state.focusedSessionOwner.get()).toBeNull()
session.$sessions.set([])
session.$selectedStoredSessionId.set(null)
})
it('fails closed when the same focused id has ambiguous owner hints', async () => {
const { host, session } = await setup()
session.setSessionOwnerHint('ambiguous-hint', {
connectionId: 'source-a',
mode: 'remote',
profile: 'worker',
targetProfile: 'backend-worker-a'
})
session.setSessionOwnerHint('ambiguous-hint', {
connectionId: 'source-b',
mode: 'remote',
profile: 'worker',
targetProfile: 'backend-worker-b'
})
session.$sessions.set([
{
connection_id: 'source-a',
id: 'ambiguous-hint',
profile: 'worker'
} as never
])
session.$selectedStoredSessionId.set('ambiguous-hint')
expect(host.state.focusedSessionOwner.get()).toBeNull()
session.$sessions.set([])
session.$selectedStoredSessionId.set(null)
})
it('keeps the focused session connection when same-named profiles share a handle', async () => {
const { host, profile, session } = await setup()
profile.$activeGatewayProfile.set('default')
session.setConnection({ connectionId: 'source-a', mode: 'remote' } as never)
session.$sessions.set([
{
connection_id: 'source-b',
id: 'shared-default',
profile: 'default'
} as never
])
session.$selectedStoredSessionId.set('shared-default')
expect(host.state.focusedSessionOwner.get()).toEqual({
connectionId: 'source-b',
profile: 'default'
})
session.$sessions.set([])
session.$selectedStoredSessionId.set(null)
session.setConnection(null)
})
})
describe('host.state busy vs gateway', () => {
afterEach(() => {
$sessionStates.set({})
$gatewayState.set('idle')
})
it('exposes per-session turn-busy and does not treat gateway as busy', () => {
const running = { ...createClientSessionState('stored-a'), busy: true }
const idle = { ...createClientSessionState('stored-b'), busy: false }
publishSessionState('runtime-a', running)
publishSessionState('runtime-b', idle)
$gatewayState.set('open')
expect(host.state.busyBySession.get()).toEqual({ 'runtime-a': true, 'runtime-b': false })
expect(host.state.gateway.get()).toBe('open')
dropSessionState('runtime-a')
expect(host.state.busyBySession.get()['runtime-a']).toBeUndefined()
expect(host.state.gateway.get()).toBe('open')
})
})