import { renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as HermesModule from '@/hermes' import { setSessionOwnerHint, setSessions } from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' import { useSessionTileDelegate } from './use-session-tile-delegate' vi.mock('@/hermes', async importActual => ({ ...(await importActual()), getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' })) })) vi.mock('@/store/gateway', async importActual => ({ ...(await importActual>()), requestGatewayForAgent: vi.fn(), requestGatewayForProfile: vi.fn() })) const { getLatestSessionMessages } = await import('@/hermes') const { requestGatewayForAgent, requestGatewayForProfile } = await import('@/store/gateway') const row = (over: Partial): SessionInfo => ({ ended_at: null, id: 'live', input_tokens: 0, is_active: false, last_active: 0, message_count: 1, model: null, output_tokens: 0, preview: null, profile: 'default', source: null, started_at: 0, title: null, ...over }) as SessionInfo function renderTile( requestGateway: ReturnType, refs?: { runtimeIdByStoredSessionIdRef?: { current: Map } sessionStateByRuntimeIdRef?: { current: Map } updateSessionState?: ReturnType } ) { renderHook(() => useSessionTileDelegate({ archiveSession: vi.fn(async () => undefined), branchStoredSession: vi.fn(async () => undefined), executeSlashCommand: vi.fn(async () => undefined) as never, removeSession: vi.fn(async () => undefined), requestGateway: requestGateway as never, runtimeIdByStoredSessionIdRef: (refs?.runtimeIdByStoredSessionIdRef ?? { current: new Map() }) as never, sessionStateByRuntimeIdRef: (refs?.sessionStateByRuntimeIdRef ?? { current: new Map() }) as never, updateSessionState: (refs?.updateSessionState ?? vi.fn()) as never }) ) } describe('useSessionTileDelegate resumeTile', () => { beforeEach(() => { setSessions([]) vi.mocked(getLatestSessionMessages).mockClear() }) afterEach(() => { setSessions([]) }) it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => { // A tile opens a session owned by another profile. Resuming without the // profile lets the gateway fall back to the launch-profile DB and clone the // conversation into the wrong profile (#67603). The owning profile must ride // both the transcript prefetch and the resume RPC. setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })]) const requestGateway = vi.fn(async (method: string) => method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never) ) vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-1' } as never) renderTile(requestGateway) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x') expect(runtimeId).toBe('runtime-1') expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer') expect(requestGatewayForProfile).toHaveBeenCalledWith( 'ai-engineer', 'session.resume', { session_id: 'stored-x', cols: 96, profile: 'ai-engineer', omit_messages: true }, undefined, undefined ) expect(requestGateway).not.toHaveBeenCalled() }) it('resolves and carries a default-profile session explicitly', async () => { setSessions([row({ id: 'stored-y', profile: 'default' })]) const requestGateway = vi.fn(async () => ({}) as never) // #92961: a known owner is ALWAYS routed through the profile router — // even 'default' — never dispatched on the ambient socket. vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-2' } as never) renderTile(requestGateway) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-y') expect(runtimeId).toBe('runtime-2') expect(requestGatewayForProfile).toHaveBeenCalledWith( 'default', 'session.resume', { session_id: 'stored-y', cols: 96, profile: 'default', omit_messages: true }, undefined, undefined ) expect(requestGateway).not.toHaveBeenCalled() }) it('carries a session row connection owner into a same-named tile resume', async () => { setSessions([row({ connection_id: 'source-b', id: 'stored-shared', profile: 'default' })]) const ambientRequest = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-shared' } as never) renderTile(ambientRequest) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-shared') expect(runtimeId).toBe('runtime-shared') expect(requestGatewayForAgent).toHaveBeenCalledWith('source-b', 'default', 'session.resume', { session_id: 'stored-shared', cols: 96, omit_messages: true, profile: 'default' }) expect(ambientRequest).not.toHaveBeenCalled() }) it('routes a Bot tile prefetch and resume through its exact connection owner', async () => { const route = { connectionId: 'barry', mode: 'remote' as const, profile: 'oxcoder', targetProfile: 'backend-oxcoder' } setSessionOwnerHint('stored-remote', route) vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-remote' } as never) const ambientRequest = vi.fn(async () => ({}) as never) renderTile(ambientRequest) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-remote') expect(runtimeId).toBe('runtime-remote') expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-remote', { connectionId: 'barry', profile: 'backend-oxcoder' }) expect(requestGatewayForAgent).toHaveBeenCalledWith('barry', 'oxcoder', 'session.resume', { session_id: 'stored-remote', cols: 96, omit_messages: true, profile: 'backend-oxcoder' }) expect(ambientRequest).not.toHaveBeenCalled() }) it('reuses a warm binding that still carries a transcript', async () => { const stateA = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-a' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) } const requestGateway = vi.fn(async () => ({}) as never) renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef }) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a') expect(runtimeId).toBe('runtime-a') expect(requestGateway).not.toHaveBeenCalled() expect(getLatestSessionMessages).not.toHaveBeenCalled() }) it('merges persisted messages into a warm tile on explicit reopen (#96183)', async () => { const stateA = { busy: false, messages: [{ id: 'm1', parts: [{ type: 'text', text: 'old' }], role: 'user' }], storedSessionId: 'stored-a' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-a', stateA]]) } const updateSessionState = vi.fn((_id, updater) => updater(stateA)) const requestGateway = vi.fn(async () => ({}) as never) vi.mocked(getLatestSessionMessages).mockResolvedValueOnce({ messages: [ { id: 'm1', content: 'old', role: 'user' }, { id: 'm2', content: 'cron delivery', role: 'user' } ], session_id: 'stored-a' } as never) renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState }) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-a', { refreshTranscript: true }) expect(runtimeId).toBe('runtime-a') expect(requestGateway).not.toHaveBeenCalled() expect(getLatestSessionMessages).toHaveBeenCalled() expect(updateSessionState).toHaveBeenCalled() const updater = updateSessionState.mock.calls[0][1] as (state: typeof stateA) => { messages: Array<{ parts?: Array<{ text?: string }> }> } const next = updater(stateA) const texts = next.messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? '')) expect(texts.some(text => text.includes('cron delivery'))).toBe(true) }) it('falls through to a real resume when the warm binding has no transcript (post-wake empty tile)', async () => { // Sleep/wake regression: a released/stale cached state (messages: []) must // NOT satisfy the warm path — reusing it re-bound the tile to a dead // runtime id and painted the pane permanently empty. setSessions([row({ id: 'stored-b', profile: 'default' })]) const staleState = { busy: false, messages: [], storedSessionId: 'stored-b' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-b', 'runtime-dead']]) } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', staleState]]) } const requestGateway = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never) renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef }) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-b') expect(runtimeId).toBe('runtime-fresh') expect(requestGatewayForProfile).toHaveBeenCalledWith( 'default', 'session.resume', { session_id: 'stored-b', cols: 96, profile: 'default', omit_messages: true }, undefined, undefined ) }) it('hydrates the tile model and provider from resume info', async () => { setSessions([row({ id: 'stored-model', profile: 'default' })]) const updateSessionState = vi.fn() vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ info: { fast: true, model: 'gpt-5', provider: 'openai', reasoning_effort: 'high', running: false }, session_id: 'runtime-model' } as never) renderTile(vi.fn(), { updateSessionState }) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-model') expect(runtimeId).toBe('runtime-model') expect(updateSessionState).toHaveBeenCalled() const updater = updateSessionState.mock.calls[0][1] as (state: { messages: unknown[] }) => Record const next = updater({ messages: [] }) expect(next.model).toBe('gpt-5') expect(next.provider).toBe('openai') expect(next.reasoningEffort).toBe('high') expect(next.fast).toBe(true) }) it('invalidateRuntimeBindings clears the stored→runtime map so tiles re-resume after reconnect', async () => { setSessions([row({ id: 'stored-c', profile: 'default' })]) const liveState = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-c' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-c', 'runtime-dead']]) } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', liveState]]) } const requestGateway = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-fresh' } as never) renderTile(requestGateway, { runtimeIdByStoredSessionIdRef, sessionStateByRuntimeIdRef }) // Gateway reconnect (what resetTileRuntimeBindings calls on wake): sessionTileDelegate()!.invalidateRuntimeBindings!() expect(runtimeIdByStoredSessionIdRef.current.size).toBe(0) // The next resume goes cold instead of reusing the dead binding. const runtimeId = await sessionTileDelegate()!.resumeTile('stored-c') expect(runtimeId).toBe('runtime-fresh') }) }) describe('useSessionTileDelegate retireBusyClaim', () => { it('retires a stale busy claim through the session-state write path (#93059)', () => { const busyState = { awaitingResponse: true, busy: true, messages: [{ id: 'm1' }], storedSessionId: 'stored-d' } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-dead', busyState]]) } const updateSessionState = vi.fn() renderTile( vi.fn(async () => ({}) as never), { sessionStateByRuntimeIdRef, updateSessionState } ) expect(sessionTileDelegate()!.retireBusyClaim!('runtime-dead')).toBe(true) expect(updateSessionState).toHaveBeenCalledWith('runtime-dead', expect.any(Function)) // The updater is the downgrade: busy/awaiting off, everything else intact. const updater = updateSessionState.mock.calls[0][1] as (state: typeof busyState) => typeof busyState expect(updater(busyState)).toEqual({ ...busyState, awaitingResponse: false, busy: false }) }) it('reports a miss instead of minting a cache entry for a runtime it never held', () => { // No phantoms: updateSessionState mints a state for any id it is handed, // and prune never collects a transcript-less entry — so a miss must not // reach the write path; the store retires its own mirror instead. const idle = { awaitingResponse: false, busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-e' } const sessionStateByRuntimeIdRef = { current: new Map([['runtime-idle', idle]]) } const updateSessionState = vi.fn() renderTile( vi.fn(async () => ({}) as never), { sessionStateByRuntimeIdRef, updateSessionState } ) expect(sessionTileDelegate()!.retireBusyClaim!('runtime-unknown')).toBe(false) expect(sessionTileDelegate()!.retireBusyClaim!('runtime-idle')).toBe(false) expect(updateSessionState).not.toHaveBeenCalled() }) }) describe('useSessionTileDelegate interruptSession', () => { beforeEach(() => { setSessions([]) }) afterEach(async () => { setSessions([]) const { clearSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils') clearSessionRecentlyInterrupted() }) it('marks the session recently interrupted so a quick tile edit/resend still interrupt-firsts (#83855)', async () => { const { isSessionRecentlyInterrupted } = await import('../../session/hooks/use-prompt-actions/utils') const requestGateway = vi.fn(async () => ({}) as never) renderTile(requestGateway) await sessionTileDelegate()!.interruptSession('runtime-tile-1') expect(requestGateway).toHaveBeenCalledWith('session.interrupt', { session_id: 'runtime-tile-1' }) // Same 3s cooldown the primary chat's Stop sets: busy reads false while the // gateway winds down, so the rewind path must still interrupt-first. expect(isSessionRecentlyInterrupted('runtime-tile-1')).toBe(true) }) })