1009 lines
33 KiB
TypeScript
1009 lines
33 KiB
TypeScript
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { createClientSessionState } from '@/lib/chat-runtime'
|
|
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
|
import { $changeEventsAvailable, notifySessionsChanged, resetLiveSync } from '@/store/live-sync'
|
|
import {
|
|
$activeSessionId,
|
|
$selectedStoredSessionId,
|
|
setBusy,
|
|
setCronSessions,
|
|
setMessagingSessions,
|
|
setSessionOwnerHint,
|
|
setSessions
|
|
} from '@/store/session'
|
|
import {
|
|
$attentionSessionIds,
|
|
$sessionTiles,
|
|
$stalledSessionIds,
|
|
$workingSessionIds,
|
|
clearAllSessionStates,
|
|
publishSessionState,
|
|
SESSION_WATCHDOG_TIMEOUT_MS
|
|
} from '@/store/session-states'
|
|
|
|
import {
|
|
type ActiveTranscriptRefreshDeps,
|
|
isTypingBurstActive,
|
|
noteRendererKeyboardActivity,
|
|
reconcileActiveTranscript,
|
|
reconcileTileTranscripts as reconcileTileTranscriptsForTest,
|
|
rehydrateLiveSessionStatuses,
|
|
resetTypingActivityTracking,
|
|
resolveActiveTranscriptSession,
|
|
useBackgroundSync,
|
|
windowIsActivelyViewed
|
|
} from './use-background-sync'
|
|
|
|
vi.mock('@/hermes', async importOriginal => ({
|
|
...(await importOriginal()),
|
|
getLatestSessionMessages: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/store/projects', async importOriginal => ({
|
|
...(await importOriginal()),
|
|
refreshProjectTree: vi.fn(async () => undefined)
|
|
}))
|
|
|
|
const { getLatestSessionMessages } = await import('@/hermes')
|
|
const { refreshProjectTree } = await import('@/store/projects')
|
|
|
|
const ACTIVE_RUNTIME_ID = 'runtime-active'
|
|
const ACTIVE_STORED_ID = 'stored-active'
|
|
|
|
function transcript(answer: string, sessionId = ACTIVE_STORED_ID) {
|
|
return {
|
|
messages: [
|
|
{ content: 'question', role: 'user', timestamp: 1 },
|
|
{ content: answer, role: 'assistant', timestamp: 2 }
|
|
],
|
|
session_id: sessionId
|
|
}
|
|
}
|
|
|
|
function makeRefresh(resolveSession: ActiveTranscriptRefreshDeps['resolveSession'] = () => ({ profile: 'default' })) {
|
|
const activeSessionIdRef = { current: ACTIVE_RUNTIME_ID as string | null }
|
|
const selectedStoredSessionIdRef = { current: ACTIVE_STORED_ID as string | null }
|
|
const busyRef = { current: false }
|
|
const requestSequenceRef = { current: 0 }
|
|
const signatureRef = { current: new Map<string, string>() }
|
|
const state = createClientSessionState(ACTIVE_STORED_ID)
|
|
const states = new Map([[ACTIVE_RUNTIME_ID, state]])
|
|
|
|
const updateSessionStateRef = {
|
|
updateSessionState: vi.fn((sessionId: string, updater: (value: typeof state) => typeof state) => {
|
|
const next = updater(states.get(sessionId) ?? createClientSessionState(ACTIVE_STORED_ID))
|
|
states.set(sessionId, next)
|
|
|
|
return next
|
|
})
|
|
}
|
|
|
|
const { updateSessionState } = updateSessionStateRef
|
|
|
|
const refresh = () =>
|
|
reconcileActiveTranscript({
|
|
activeSessionIdRef,
|
|
busyRef,
|
|
requestSequenceRef,
|
|
resolveSession,
|
|
selectedStoredSessionIdRef,
|
|
signatureRef,
|
|
updateSessionState
|
|
})
|
|
|
|
return { activeSessionIdRef, busyRef, refresh, selectedStoredSessionIdRef, state, states, updateSessionState }
|
|
}
|
|
|
|
function useSyncHarness({
|
|
activeIsMessaging = false,
|
|
activeSessionId,
|
|
activeStoredSessionId,
|
|
gatewayState = 'open',
|
|
refreshActiveTranscript
|
|
}: {
|
|
activeIsMessaging?: boolean
|
|
activeSessionId: string | null
|
|
activeStoredSessionId: string | null
|
|
gatewayState?: string
|
|
refreshActiveTranscript: () => Promise<void>
|
|
}) {
|
|
const updateSessionState: Parameters<typeof useBackgroundSync>[0]['updateSessionState'] = vi.fn(
|
|
(sessionId, updater) => {
|
|
const current = {} as Parameters<typeof updater>[0]
|
|
|
|
return updater(current)
|
|
}
|
|
)
|
|
|
|
useBackgroundSync({
|
|
activeConnectionId: 'local',
|
|
activeGatewayProfile: 'default',
|
|
activeIsMessaging,
|
|
activeSessionId,
|
|
activeStoredSessionId,
|
|
freshDraftReady: false,
|
|
gatewayState,
|
|
refreshActiveTranscript,
|
|
refreshCronJobs: vi.fn(),
|
|
refreshCurrentModel: vi.fn(),
|
|
refreshHermesConfig: vi.fn(),
|
|
refreshMessagingSessions: vi.fn(),
|
|
refreshSessions: vi.fn(),
|
|
updateSessionState,
|
|
requestGateway: vi.fn(async () => ({ sessions: [] })) as never
|
|
})
|
|
}
|
|
|
|
type SyncOptions = {
|
|
activeIsMessaging?: boolean
|
|
activeSessionId?: null | string
|
|
activeStoredSessionId?: null | string
|
|
gatewayState?: string
|
|
}
|
|
|
|
function renderSync(refreshActiveTranscript: () => Promise<void>, options: SyncOptions = {}) {
|
|
return renderHook(
|
|
(props: SyncOptions) =>
|
|
useSyncHarness({
|
|
activeSessionId: ACTIVE_RUNTIME_ID,
|
|
activeStoredSessionId: ACTIVE_STORED_ID,
|
|
refreshActiveTranscript,
|
|
...props
|
|
}),
|
|
{ initialProps: options }
|
|
)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// visiblePoll only ticks while the window is actively viewed; jsdom's
|
|
// document.hasFocus() is not reliably true, so pin it for these tests.
|
|
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
vi.clearAllTimers()
|
|
vi.useRealTimers()
|
|
resetLiveSync()
|
|
$activeSessionId.set(null)
|
|
$selectedStoredSessionId.set(null)
|
|
setSessions([])
|
|
setCronSessions([])
|
|
setMessagingSessions([])
|
|
setBusy(false)
|
|
vi.clearAllMocks()
|
|
vi.restoreAllMocks()
|
|
clearAllSessionStates()
|
|
$sessionTiles.set([])
|
|
resetTypingActivityTracking()
|
|
})
|
|
|
|
describe('active transcript refresh', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('answer') as never)
|
|
})
|
|
|
|
it('refreshes a hidden session through its unique complete owner route', async () => {
|
|
const hiddenStoredSessionId = 'hidden-bot-chat'
|
|
|
|
const ownerRoute = {
|
|
connectionId: 'ssh-bot-owner',
|
|
mode: 'remote' as const,
|
|
profile: 'bot-route',
|
|
targetProfile: 'bot-profile'
|
|
}
|
|
|
|
$changeEventsAvailable.set(true)
|
|
$activeSessionId.set(ACTIVE_RUNTIME_ID)
|
|
$selectedStoredSessionId.set(hiddenStoredSessionId)
|
|
setSessionOwnerHint(hiddenStoredSessionId, ownerRoute)
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
fixture.selectedStoredSessionIdRef.current = hiddenStoredSessionId
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(
|
|
transcript('hidden external answer', hiddenStoredSessionId) as never
|
|
)
|
|
|
|
renderSync(fixture.refresh, { activeStoredSessionId: hiddenStoredSessionId })
|
|
|
|
act(() => notifySessionsChanged())
|
|
|
|
await waitFor(() =>
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(hiddenStoredSessionId, {
|
|
connectionId: ownerRoute.connectionId,
|
|
profile: ownerRoute.targetProfile
|
|
})
|
|
)
|
|
expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({
|
|
text: 'hidden external answer'
|
|
})
|
|
})
|
|
|
|
it('reconciles a workspace TILE transcript when sessions.changed ticks (#94255 review: behavior, not source-grep)', async () => {
|
|
$changeEventsAvailable.set(true)
|
|
// The tile's runtime differs from the active session — it is NOT the main
|
|
// pane surface, so only the tile reconcile path may update it.
|
|
const TILE_RUNTIME_ID = 'runtime-tile'
|
|
const TILE_STORED_ID = 'stored-tile'
|
|
$activeSessionId.set('runtime-something-else')
|
|
$selectedStoredSessionId.set('stored-other')
|
|
|
|
const states = new Map<string, ReturnType<typeof createClientSessionState>>()
|
|
states.set(TILE_RUNTIME_ID, createClientSessionState(TILE_STORED_ID))
|
|
|
|
let updaterCallCount = 0
|
|
|
|
const updateSessionState: Parameters<typeof reconcileTileTranscriptsForTest>[0]['updateSessionState'] = vi.fn(
|
|
(sessionId, updater) => {
|
|
updaterCallCount += 1
|
|
const current = {} as Parameters<typeof updater>[0]
|
|
|
|
return updater(current)
|
|
}
|
|
)
|
|
|
|
void updateSessionState
|
|
|
|
const signatureRef = { current: new Map<string, string>() }
|
|
const requestSequenceRef = { current: 0 }
|
|
|
|
vi.mocked(getLatestSessionMessages).mockImplementation(async (storedId: string) => {
|
|
if (storedId === TILE_STORED_ID) {
|
|
return {
|
|
messages: [
|
|
{ content: 'tile question', role: 'user', timestamp: 1 },
|
|
{ content: 'background delivery answer', role: 'assistant', timestamp: 2 }
|
|
],
|
|
session_id: TILE_STORED_ID
|
|
} as never
|
|
}
|
|
|
|
return transcript('main-pane answer') as never
|
|
})
|
|
|
|
// Seed a tile so reconcileTileTranscripts has a target.
|
|
setSessions([]) // bot chats are hidden from $sessions — the whole point
|
|
|
|
await act(async () => {
|
|
await reconcileTileTranscriptsForTest({
|
|
tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }],
|
|
requestSequenceRef,
|
|
signatureRef,
|
|
updateSessionState
|
|
})
|
|
})
|
|
|
|
// Behavior assertions:
|
|
expect(updaterCallCount).toBeGreaterThan(0)
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(TILE_STORED_ID, undefined)
|
|
})
|
|
|
|
it('reconciles an idle tile while the main pane is busy', async () => {
|
|
const runtimeId = 'runtime-idle-tile'
|
|
const storedId = 'stored-idle-tile'
|
|
const idleState = createClientSessionState(storedId)
|
|
|
|
setBusy(true)
|
|
publishSessionState(runtimeId, idleState)
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('idle tile update', storedId) as never)
|
|
|
|
const updateSessionState = vi.fn((sessionId: string, updater: (state: typeof idleState) => typeof idleState) => {
|
|
expect(sessionId).toBe(runtimeId)
|
|
|
|
return updater(idleState)
|
|
})
|
|
|
|
await reconcileTileTranscriptsForTest({
|
|
tiles: [{ runtimeId, storedSessionId: storedId }],
|
|
requestSequenceRef: { current: 0 },
|
|
signatureRef: { current: new Map() },
|
|
updateSessionState
|
|
})
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(storedId, undefined)
|
|
expect(updateSessionState).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not reconcile a busy tile when the main pane is idle', async () => {
|
|
const runtimeId = 'runtime-busy-tile'
|
|
const storedId = 'stored-busy-tile'
|
|
const liveState = createClientSessionState(storedId)
|
|
|
|
liveState.busy = true
|
|
liveState.messages = [
|
|
{
|
|
id: 'live-assistant',
|
|
parts: [{ text: 'streaming answer', type: 'text' }],
|
|
pending: true,
|
|
role: 'assistant'
|
|
}
|
|
]
|
|
publishSessionState(runtimeId, liveState)
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue({ messages: [], session_id: storedId } as never)
|
|
|
|
const updateSessionState = vi.fn()
|
|
|
|
await reconcileTileTranscriptsForTest({
|
|
tiles: [{ runtimeId, storedSessionId: storedId }],
|
|
requestSequenceRef: { current: 0 },
|
|
signatureRef: { current: new Map() },
|
|
updateSessionState
|
|
})
|
|
|
|
expect(getLatestSessionMessages).not.toHaveBeenCalled()
|
|
expect(updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discards a tile snapshot when the tile closes during the read', async () => {
|
|
const runtimeId = 'runtime-closing-tile'
|
|
const storedId = 'stored-closing-tile'
|
|
let resolveRead: (value: unknown) => void = () => undefined
|
|
|
|
$sessionTiles.set([{ runtimeId, storedSessionId: storedId }])
|
|
publishSessionState(runtimeId, createClientSessionState(storedId))
|
|
vi.mocked(getLatestSessionMessages).mockReturnValueOnce(
|
|
new Promise(resolve => {
|
|
resolveRead = resolve
|
|
}) as never
|
|
)
|
|
|
|
const updateSessionState = vi.fn()
|
|
|
|
const reconcile = reconcileTileTranscriptsForTest({
|
|
requestSequenceRef: { current: 0 },
|
|
signatureRef: { current: new Map() },
|
|
updateSessionState
|
|
})
|
|
|
|
$sessionTiles.set([])
|
|
resolveRead(transcript('stale tile answer', storedId))
|
|
await reconcile
|
|
|
|
expect(updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('isolates tile transcript reads by connection and profile while preserving the legacy local path', async () => {
|
|
vi.mocked(getLatestSessionMessages).mockImplementation(async storedId => transcript(storedId, storedId) as never)
|
|
|
|
const updateSessionState: Parameters<typeof reconcileTileTranscriptsForTest>[0]['updateSessionState'] = vi.fn(
|
|
(_sessionId, updater) => updater({} as Parameters<typeof updater>[0])
|
|
)
|
|
|
|
await reconcileTileTranscriptsForTest({
|
|
tiles: [
|
|
{
|
|
ownerRoute: {
|
|
connectionId: 'connection-a',
|
|
mode: 'remote',
|
|
profile: 'shared-profile',
|
|
targetProfile: 'target-a'
|
|
},
|
|
runtimeId: 'runtime-a',
|
|
storedSessionId: 'stored-a'
|
|
},
|
|
{
|
|
ownerRoute: { connectionId: 'connection-b', mode: 'remote', profile: 'shared-profile' },
|
|
runtimeId: 'runtime-b',
|
|
storedSessionId: 'stored-b'
|
|
},
|
|
{ runtimeId: 'runtime-local', storedSessionId: 'stored-local' }
|
|
],
|
|
requestSequenceRef: { current: 0 },
|
|
signatureRef: { current: new Map<string, string>() },
|
|
updateSessionState
|
|
})
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-a', {
|
|
connectionId: 'connection-a',
|
|
profile: 'target-a'
|
|
})
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-b', {
|
|
connectionId: 'connection-b',
|
|
profile: 'shared-profile'
|
|
})
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-local', undefined)
|
|
expect(updateSessionState).toHaveBeenCalledWith('runtime-a', expect.any(Function), 'stored-a')
|
|
expect(updateSessionState).toHaveBeenCalledWith('runtime-b', expect.any(Function), 'stored-b')
|
|
expect(updateSessionState).toHaveBeenCalledWith('runtime-local', expect.any(Function), 'stored-local')
|
|
})
|
|
|
|
it('skips the tile fetch entirely when nothing changed (signature-gated)', async () => {
|
|
$changeEventsAvailable.set(true)
|
|
|
|
const TILE_RUNTIME_ID = 'runtime-tile-2'
|
|
const TILE_STORED_ID = 'stored-tile-2'
|
|
|
|
const signatureRef = { current: new Map<string, string>() }
|
|
|
|
// Pre-seed the signature with what the mock returns → no-change tick.
|
|
const pre = {
|
|
messages: [
|
|
{ content: 'q', role: 'user', timestamp: 1 },
|
|
{ content: 'a', role: 'assistant', timestamp: 2 }
|
|
],
|
|
session_id: TILE_STORED_ID
|
|
}
|
|
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(pre as never)
|
|
|
|
// Compute the same signature the reconcile will compute, and pre-seed it.
|
|
const preSignature = sessionMessagesSignature(pre.messages as never)
|
|
|
|
signatureRef.current.set(`tile:${TILE_STORED_ID}`, preSignature)
|
|
|
|
const updateSessionState = vi.fn()
|
|
const requestSequenceRef = { current: 0 }
|
|
|
|
await act(async () => {
|
|
await reconcileTileTranscriptsForTest({
|
|
tiles: [{ storedSessionId: TILE_STORED_ID, runtimeId: TILE_RUNTIME_ID }],
|
|
requestSequenceRef,
|
|
signatureRef,
|
|
updateSessionState
|
|
})
|
|
})
|
|
|
|
expect(updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refreshes a local/Desktop session when sessions.changed ticks', async () => {
|
|
$changeEventsAvailable.set(true)
|
|
$activeSessionId.set(ACTIVE_RUNTIME_ID)
|
|
$selectedStoredSessionId.set(ACTIVE_STORED_ID)
|
|
setSessionOwnerHint(ACTIVE_STORED_ID, {
|
|
connectionId: 'stale-owner',
|
|
mode: 'remote',
|
|
profile: 'wrong-profile',
|
|
targetProfile: 'wrong-target'
|
|
})
|
|
setSessions([
|
|
{
|
|
connectionId: 'future-visible-owner',
|
|
id: ACTIVE_STORED_ID,
|
|
profile: 'desktop-profile',
|
|
source: 'desktop',
|
|
targetProfile: 'must-not-rewrite-visible-row'
|
|
} as never
|
|
])
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('external answer') as never)
|
|
|
|
renderSync(fixture.refresh)
|
|
|
|
act(() => notifySessionsChanged())
|
|
|
|
await waitFor(() =>
|
|
expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({
|
|
text: 'external answer'
|
|
})
|
|
)
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'desktop-profile')
|
|
})
|
|
|
|
it('does not add a periodic transcript poll to local/Desktop sessions', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refresh = vi.fn(async () => undefined)
|
|
|
|
renderSync(refresh)
|
|
// Exactly the one connect-time pull (#94779) — no timer after it.
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(60_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('retains the existing periodic backstop for messaging sessions', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refresh = vi.fn(async () => undefined)
|
|
|
|
renderSync(refresh, { activeIsMessaging: true })
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
await act(async () => Promise.resolve())
|
|
refresh.mockClear()
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(30_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('only defers an external tick while busy, then refreshes once after idle', async () => {
|
|
$changeEventsAvailable.set(true)
|
|
const refresh = vi.fn(async () => undefined)
|
|
|
|
renderSync(refresh)
|
|
refresh.mockClear() // drop the connect-time pull; this test is about busy transitions
|
|
|
|
act(() => setBusy(true))
|
|
act(() => setBusy(false))
|
|
expect(refresh).not.toHaveBeenCalled()
|
|
act(() => setBusy(true))
|
|
|
|
act(() => {
|
|
notifySessionsChanged()
|
|
notifySessionsChanged()
|
|
})
|
|
expect(refresh).not.toHaveBeenCalled()
|
|
|
|
act(() => setBusy(false))
|
|
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('pulls the open transcript once per (re)connect, not on session switches (#94779)', () => {
|
|
$changeEventsAvailable.set(true)
|
|
const refresh = vi.fn(async () => undefined)
|
|
|
|
const { rerender } = renderSync(refresh, { gatewayState: 'connecting' })
|
|
expect(refresh).not.toHaveBeenCalled()
|
|
|
|
rerender({ gatewayState: 'open' })
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
|
|
rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' })
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
|
|
rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'closed' })
|
|
rerender({ activeSessionId: 'runtime-other', activeStoredSessionId: 'stored-other', gatewayState: 'open' })
|
|
expect(refresh).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('coalesces a burst of global session-change ticks', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refresh = vi.fn(async () => undefined)
|
|
|
|
renderSync(refresh)
|
|
refresh.mockClear() // drop the connect-time pull; this test is about tick coalescing
|
|
|
|
act(() => {
|
|
for (let index = 0; index < 20; index += 1) {
|
|
notifySessionsChanged()
|
|
}
|
|
})
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(9_999)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refresh).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('refreshes the project tree on a sessions.changed tick, alongside the sessions list (#100354)', async () => {
|
|
$changeEventsAvailable.set(true)
|
|
|
|
renderSync(vi.fn(async () => undefined))
|
|
|
|
act(() => notifySessionsChanged())
|
|
|
|
await waitFor(() => expect(refreshProjectTree).toHaveBeenCalledTimes(1))
|
|
})
|
|
})
|
|
|
|
describe('reconcileActiveTranscript', () => {
|
|
it('resolves and hydrates a messaging session from the messaging sessions store', async () => {
|
|
setSessionOwnerHint(ACTIVE_STORED_ID, {
|
|
connectionId: 'stale-messaging-owner',
|
|
mode: 'remote',
|
|
profile: 'wrong-profile',
|
|
targetProfile: 'wrong-target'
|
|
})
|
|
setMessagingSessions([{ id: ACTIVE_STORED_ID, profile: 'messaging-profile', source: 'telegram' } as never])
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('telegram answer') as never)
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'messaging-profile')
|
|
expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({
|
|
text: 'telegram answer'
|
|
})
|
|
})
|
|
|
|
it('resolves and hydrates a cron session from the cron sessions store', async () => {
|
|
setCronSessions([{ id: ACTIVE_STORED_ID, profile: 'cron-profile', source: 'cron' } as never])
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('cron progress') as never)
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(ACTIVE_STORED_ID, 'cron-profile')
|
|
expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({
|
|
text: 'cron progress'
|
|
})
|
|
})
|
|
|
|
it('fails closed when a hidden session id has multiple owner hints', async () => {
|
|
const ambiguousStoredSessionId = 'ambiguous-hidden-chat'
|
|
setSessionOwnerHint(ambiguousStoredSessionId, {
|
|
connectionId: 'owner-a',
|
|
mode: 'remote',
|
|
profile: 'bot'
|
|
})
|
|
setSessionOwnerHint(ambiguousStoredSessionId, {
|
|
connectionId: 'owner-b',
|
|
mode: 'remote',
|
|
profile: 'bot'
|
|
})
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
fixture.selectedStoredSessionIdRef.current = ambiguousStoredSessionId
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).not.toHaveBeenCalled()
|
|
expect(fixture.updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the presentation profile when a hidden owner has no target profile', async () => {
|
|
const hiddenStoredSessionId = 'hidden-no-target'
|
|
setSessionOwnerHint(hiddenStoredSessionId, {
|
|
connectionId: 'owner-no-target',
|
|
mode: 'remote',
|
|
profile: 'presentation-profile'
|
|
})
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
fixture.selectedStoredSessionIdRef.current = hiddenStoredSessionId
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(hiddenStoredSessionId, {
|
|
connectionId: 'owner-no-target',
|
|
profile: 'presentation-profile'
|
|
})
|
|
})
|
|
|
|
it('reads and publishes only the active hidden owner when another owner coexists', async () => {
|
|
const ownerAStoredSessionId = 'owner-a-chat'
|
|
const ownerBStoredSessionId = 'owner-b-hidden-chat'
|
|
|
|
const ownerBRoute = {
|
|
connectionId: 'owner-b',
|
|
mode: 'remote' as const,
|
|
profile: 'bot-route',
|
|
targetProfile: 'bot-b'
|
|
}
|
|
|
|
setSessions([{ id: ownerAStoredSessionId, profile: 'bot-a', source: 'desktop' } as never])
|
|
setSessionOwnerHint(ownerAStoredSessionId, {
|
|
connectionId: 'owner-a',
|
|
mode: 'remote',
|
|
profile: 'bot-route',
|
|
targetProfile: 'bot-a'
|
|
})
|
|
setSessionOwnerHint(ownerBStoredSessionId, ownerBRoute)
|
|
const fixture = makeRefresh(resolveActiveTranscriptSession)
|
|
fixture.selectedStoredSessionIdRef.current = ownerBStoredSessionId
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('owner B answer', ownerBStoredSessionId) as never)
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).toHaveBeenCalledTimes(1)
|
|
expect(getLatestSessionMessages).toHaveBeenCalledWith(ownerBStoredSessionId, {
|
|
connectionId: ownerBRoute.connectionId,
|
|
profile: ownerBRoute.targetProfile
|
|
})
|
|
expect(fixture.updateSessionState).toHaveBeenCalledWith(
|
|
ACTIVE_RUNTIME_ID,
|
|
expect.any(Function),
|
|
ownerBStoredSessionId
|
|
)
|
|
expect(fixture.states.get(ACTIVE_RUNTIME_ID)?.messages.at(-1)?.parts[0]).toMatchObject({
|
|
text: 'owner B answer'
|
|
})
|
|
})
|
|
|
|
it('publishes changed authoritative messages once without duplicates', async () => {
|
|
const fixture = makeRefresh()
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue(transcript('new answer') as never)
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(fixture.updateSessionState).toHaveBeenCalledTimes(1)
|
|
const messages = fixture.states.get(ACTIVE_RUNTIME_ID)?.messages ?? []
|
|
expect(messages.map(message => message.role)).toEqual(['user', 'assistant'])
|
|
expect(new Set(messages.map(message => message.id)).size).toBe(messages.length)
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(fixture.updateSessionState).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('preserves a local assistant error while hydrating authoritative messages', async () => {
|
|
const fixture = makeRefresh()
|
|
fixture.state.messages = [
|
|
{ id: '1-0-user', parts: [{ text: 'question', type: 'text' }], role: 'user' },
|
|
{ error: 'local failure', id: 'assistant-error', parts: [], role: 'assistant' }
|
|
]
|
|
vi.mocked(getLatestSessionMessages).mockResolvedValue({
|
|
messages: [{ content: 'question', role: 'user', timestamp: 1 }],
|
|
session_id: ACTIVE_STORED_ID
|
|
} as never)
|
|
|
|
await fixture.refresh()
|
|
|
|
const messages = fixture.states.get(ACTIVE_RUNTIME_ID)?.messages ?? []
|
|
expect(messages.map(message => message.id)).toEqual(['1-0-user', 'assistant-error'])
|
|
expect(messages.at(-1)?.error).toBe('local failure')
|
|
})
|
|
|
|
it('does not clobber a busy stream', async () => {
|
|
const fixture = makeRefresh()
|
|
fixture.busyRef.current = true
|
|
|
|
await fixture.refresh()
|
|
|
|
expect(getLatestSessionMessages).not.toHaveBeenCalled()
|
|
expect(fixture.updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('discards a response when the active session changes in flight', async () => {
|
|
const fixture = makeRefresh()
|
|
let resolve: ((value: unknown) => void) | undefined
|
|
vi.mocked(getLatestSessionMessages).mockReturnValueOnce(
|
|
new Promise(currentResolve => {
|
|
resolve = currentResolve
|
|
}) as never
|
|
)
|
|
|
|
const request = fixture.refresh()
|
|
fixture.selectedStoredSessionIdRef.current = 'stored-other'
|
|
fixture.activeSessionIdRef.current = 'runtime-other'
|
|
resolve?.(transcript('stale answer'))
|
|
await request
|
|
|
|
expect(fixture.updateSessionState).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('windowIsActivelyViewed', () => {
|
|
it('requires both DOM visibility and keyboard focus', () => {
|
|
expect(windowIsActivelyViewed({ focused: true, visibilityState: 'visible' })).toBe(true)
|
|
expect(windowIsActivelyViewed({ focused: false, visibilityState: 'visible' })).toBe(false)
|
|
expect(windowIsActivelyViewed({ focused: true, visibilityState: 'hidden' })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('rehydrateLiveSessionStatuses', () => {
|
|
it('restores running sessions after reconnect without opening them', () => {
|
|
const now = 1_800_000_000_000
|
|
|
|
rehydrateLiveSessionStatuses(
|
|
{
|
|
sessions: [
|
|
{
|
|
id: 'runtime-overnight',
|
|
last_active: (now - SESSION_WATCHDOG_TIMEOUT_MS - 1_000) / 1000,
|
|
session_key: 'overnight-exam-learning',
|
|
status: 'working'
|
|
},
|
|
{
|
|
id: 'runtime-cleanup',
|
|
last_active: now / 1000,
|
|
session_key: 'temporary-file-cleanup',
|
|
status: 'working'
|
|
}
|
|
]
|
|
},
|
|
now
|
|
)
|
|
|
|
expect($workingSessionIds.get()).toEqual(['overnight-exam-learning', 'temporary-file-cleanup'])
|
|
expect($stalledSessionIds.get()).toEqual(['overnight-exam-learning'])
|
|
expect($attentionSessionIds.get()).toEqual([])
|
|
})
|
|
|
|
it('restores a waiting turn as working and needing attention', () => {
|
|
rehydrateLiveSessionStatuses({
|
|
sessions: [{ id: 'runtime-needs-user', session_key: 'needs-user', status: 'waiting' }]
|
|
})
|
|
|
|
expect($workingSessionIds.get()).toEqual(['needs-user'])
|
|
expect($attentionSessionIds.get()).toEqual(['needs-user'])
|
|
expect($stalledSessionIds.get()).toEqual([])
|
|
})
|
|
|
|
it('ignores idle, starting, and malformed live-session rows', () => {
|
|
rehydrateLiveSessionStatuses({
|
|
sessions: [
|
|
{ id: 'runtime-idle', session_key: 'idle-session', status: 'idle' },
|
|
{ id: 'runtime-starting', session_key: 'starting-session', status: 'starting' },
|
|
{ id: 'runtime-malformed', status: 'working' }
|
|
]
|
|
})
|
|
|
|
expect($workingSessionIds.get()).toEqual([])
|
|
expect($attentionSessionIds.get()).toEqual([])
|
|
expect($stalledSessionIds.get()).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('typing-aware sessions.changed deferral', () => {
|
|
// Dedicated harness: the sessions-list spy must be the exact fn handed to
|
|
// the hook (the shared harness above wires inner vi.fn()s and its outer spy
|
|
// observes the transcript path instead), and EVERY param must keep a stable
|
|
// identity across the tick-driven re-renders — an unstable prop would
|
|
// re-run the connect-reseed effect and re-subscribe the throttle each
|
|
// render, polluting the counts under observation.
|
|
function renderTypingSync(refreshSessions: () => Promise<void>) {
|
|
const stable = {
|
|
refreshActiveTranscript: async () => undefined,
|
|
refreshCronJobs: vi.fn(),
|
|
refreshCurrentModel: vi.fn(),
|
|
refreshHermesConfig: vi.fn(),
|
|
refreshMessagingSessions: vi.fn(),
|
|
requestGateway: vi.fn(async () => ({ sessions: [] })) as never,
|
|
// Required by the hook's params. This harness never drives the
|
|
// transcript path, so the updater just runs against a throwaway state —
|
|
// but it must live in `stable` like every other prop, since a fresh
|
|
// identity per render would re-run the connect-reseed effect.
|
|
updateSessionState: vi.fn(
|
|
(
|
|
_sessionId: string,
|
|
updater: (state: ReturnType<typeof createClientSessionState>) => ReturnType<typeof createClientSessionState>
|
|
) => updater(createClientSessionState(ACTIVE_STORED_ID))
|
|
)
|
|
}
|
|
|
|
return renderHook(() => {
|
|
useBackgroundSync({
|
|
activeConnectionId: 'local',
|
|
activeGatewayProfile: 'default',
|
|
activeIsMessaging: false,
|
|
activeSessionId: null,
|
|
activeStoredSessionId: null,
|
|
freshDraftReady: false,
|
|
gatewayState: 'open',
|
|
...stable,
|
|
refreshSessions
|
|
})
|
|
})
|
|
}
|
|
|
|
const typeKey = (): void => {
|
|
window.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a' }))
|
|
}
|
|
|
|
/** Mount, land one full throttle cycle so lastRunAt sits at a known clock
|
|
* position, then clear the spy. */
|
|
async function primeThrottle(refreshSessions: ReturnType<typeof vi.fn>): Promise<void> {
|
|
act(() => notifySessionsChanged())
|
|
await act(async () => {
|
|
// One SESSIONS_LIST_TICK_GAP_MS covers both the immediate first tick
|
|
// and any trailing timer the burst armed.
|
|
vi.advanceTimersByTime(10_000)
|
|
await Promise.resolve()
|
|
})
|
|
refreshSessions.mockClear()
|
|
}
|
|
|
|
it('holds the trailing sessions.changed refresh while a typing burst is live, then lands it once after the keyboard quiets', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
|
|
renderTypingSync(refreshSessions)
|
|
await primeThrottle(refreshSessions)
|
|
|
|
// A ~6s continuous burst: keys every 200ms, broadcasts every ~1s. The
|
|
// first broadcast finds the throttle gap already elapsed (primed), so the
|
|
// deferral engages immediately and must hold for the whole burst.
|
|
for (let index = 0; index < 30; index += 1) {
|
|
typeKey()
|
|
|
|
if (index % 5 === 0) {
|
|
act(() => notifySessionsChanged())
|
|
}
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(200)
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
// The heavy list pass must not have landed under the keystrokes.
|
|
expect(refreshSessions).not.toHaveBeenCalled()
|
|
|
|
// Last key at ~5.8s; quiet threshold elapses ~7.3s → the held pass lands
|
|
// exactly once shortly after.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
|
|
// ...and nothing extra afterwards without further broadcasts — mid-burst
|
|
// ticks must not have stacked trailing timers behind the promised pass.
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(10_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('holds through a burst longer than the throttle gap and lands once after the keyboard quiets', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
|
|
renderTypingSync(refreshSessions)
|
|
await primeThrottle(refreshSessions)
|
|
|
|
// Keys every 200ms for ~22s — longer than SESSIONS_LIST_TICK_GAP_MS.
|
|
// Broadcasts keep flowing; the heavy pass must not land under them.
|
|
for (let index = 0; index < 110; index += 1) {
|
|
typeKey()
|
|
|
|
if (index % 10 === 0) {
|
|
act(() => notifySessionsChanged())
|
|
}
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(200)
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
expect(refreshSessions).not.toHaveBeenCalled()
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(2_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(10_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not defer anything when the keyboard has been idle', async () => {
|
|
vi.useFakeTimers()
|
|
$changeEventsAvailable.set(true)
|
|
const refreshSessions = vi.fn(async () => undefined)
|
|
|
|
renderTypingSync(refreshSessions)
|
|
await primeThrottle(refreshSessions)
|
|
|
|
act(() => notifySessionsChanged())
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(11_000)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('isTypingBurstActive', () => {
|
|
it('marks a burst warm for the quiet threshold and cold at it', () => {
|
|
resetTypingActivityTracking()
|
|
|
|
// No keyboard history → nothing to defer for.
|
|
expect(isTypingBurstActive(1_000_000)).toBe(false)
|
|
|
|
noteRendererKeyboardActivity(1_000_000)
|
|
expect(isTypingBurstActive(1_000_000)).toBe(true)
|
|
expect(isTypingBurstActive(1_000_000 + 1_499)).toBe(true)
|
|
|
|
// Exactly one quiet threshold after the last key the keyboard is cold.
|
|
expect(isTypingBurstActive(1_000_000 + 1_500)).toBe(false)
|
|
})
|
|
})
|