282 lines
9.6 KiB
TypeScript
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')
|
|
})
|
|
})
|