diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 52363223a..66e1832a2 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -162,4 +162,147 @@ describe('coreRpcClient', () => { expect(fetch).not.toHaveBeenCalled(); expect(result).toEqual({ state: 'ready' }); }); + + test.each([ + ['openhuman.get_config', 'openhuman.config_get'], + ['openhuman.get_runtime_flags', 'openhuman.config_get_runtime_flags'], + ['openhuman.set_browser_allow_all', 'openhuman.config_set_browser_allow_all'], + ['openhuman.update_browser_settings', 'openhuman.config_update_browser_settings'], + ['openhuman.update_memory_settings', 'openhuman.config_update_memory_settings'], + ['openhuman.update_model_settings', 'openhuman.config_update_model_settings'], + ['openhuman.update_runtime_settings', 'openhuman.config_update_runtime_settings'], + [ + 'openhuman.update_screen_intelligence_settings', + 'openhuman.config_update_screen_intelligence_settings', + ], + [ + 'openhuman.workspace_onboarding_flag_exists', + 'openhuman.config_workspace_onboarding_flag_exists', + ], + ['openhuman.workspace_onboarding_flag_set', 'openhuman.config_workspace_onboarding_flag_set'], + ])('rewrites legacy alias %s -> %s', async (incoming, expected) => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callCoreRpc({ method: incoming }); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.method).toBe(expected); + }); + + test('passes through unknown methods unchanged', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callCoreRpc({ method: 'openhuman.threads_list' }); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.method).toBe('openhuman.threads_list'); + }); + + test('defaults params to empty object when omitted', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callCoreRpc({ method: 'openhuman.threads_list' }); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.params).toEqual({}); + expect(body.jsonrpc).toBe('2.0'); + expect(typeof body.id).toBe('number'); + }); + + test('passes through provided params verbatim', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + const params = { thread_id: 't-1', nested: { flag: true } }; + await callCoreRpc({ method: 'openhuman.threads_messages_list', params }); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.params).toEqual(params); + }); + + test('increments jsonrpc id on sequential calls', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 0, result: {} }), + } as Response); + + await callCoreRpc({ method: 'openhuman.threads_list' }); + await callCoreRpc({ method: 'openhuman.threads_list' }); + const idA = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)).id; + const idB = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)).id; + expect(typeof idA).toBe('number'); + expect(typeof idB).toBe('number'); + expect(idB).toBe(idA + 1); + }); + + test('throws when JSON-RPC response is missing both result and error', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1 }), + } as Response); + + await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'Core RPC response missing result' + ); + }); + + test('falls back to generic error message when error.message is blank', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: '' } }), + } as Response); + + await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'Core RPC returned an error' + ); + }); + + test('wraps network errors with message propagated through', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED sidecar')); + + await expect(callCoreRpc({ method: 'openhuman.threads_list' })).rejects.toThrow( + 'ECONNREFUSED sidecar' + ); + }); + + test('rewrites multi-segment auth methods (auth.sub.segment) to underscore form', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callCoreRpc({ method: 'openhuman.auth.sub.segment' }); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.method).toBe('openhuman.auth_sub_segment'); + }); + + test('sends content-type json header and POST method', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }), + } as Response); + + await callCoreRpc({ method: 'openhuman.threads_list' }); + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + }); }); diff --git a/app/src/store/__tests__/accountsSlice.core.test.ts b/app/src/store/__tests__/accountsSlice.core.test.ts new file mode 100644 index 000000000..53ccf87b5 --- /dev/null +++ b/app/src/store/__tests__/accountsSlice.core.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; + +import type { Account, AccountLogEntry, IngestedMessage } from '../../types/accounts'; +import reducer, { + addAccount, + appendLog, + appendMessages, + removeAccount, + resetAccountsState, + setAccountStatus, + setActiveAccount, +} from '../accountsSlice'; + +function makeAccount(overrides: Partial = {}): Account { + return { + id: 'acct-1', + provider: 'slack', + label: 'Slack', + createdAt: '2026-01-01T00:00:00Z', + status: 'pending', + ...overrides, + }; +} + +function makeMessage(overrides: Partial = {}): IngestedMessage { + return { id: 'm-1', from: 'alice', body: 'hi', unread: 0, ts: 0, ...overrides }; +} + +describe('accountsSlice addAccount', () => { + it('inserts a new account, initialises caches, and picks the first as active', () => { + const state = reducer(undefined, addAccount(makeAccount())); + expect(state.accounts['acct-1']).toBeDefined(); + expect(state.order).toEqual(['acct-1']); + expect(state.messages['acct-1']).toEqual([]); + expect(state.unread['acct-1']).toBe(0); + expect(state.logs['acct-1']).toEqual([]); + expect(state.activeAccountId).toBe('acct-1'); + }); + + it('re-adding an existing account updates fields but does not duplicate order or reset caches', () => { + let state = reducer(undefined, addAccount(makeAccount())); + state = reducer( + state, + appendMessages({ accountId: 'acct-1', messages: [makeMessage()], unread: 3 }) + ); + expect(state.messages['acct-1']).toHaveLength(1); + expect(state.unread['acct-1']).toBe(3); + + state = reducer(state, addAccount(makeAccount({ label: 'Slack Renamed', status: 'open' }))); + expect(state.order).toEqual(['acct-1']); + expect(state.accounts['acct-1'].label).toBe('Slack Renamed'); + expect(state.accounts['acct-1'].status).toBe('open'); + // nullish-coalescing assignments must preserve existing caches. + expect(state.messages['acct-1']).toHaveLength(1); + expect(state.unread['acct-1']).toBe(3); + }); + + it('does not overwrite activeAccountId when more accounts are added', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer(state, addAccount(makeAccount({ id: 'b' }))); + expect(state.activeAccountId).toBe('a'); + expect(state.order).toEqual(['a', 'b']); + }); +}); + +describe('accountsSlice removeAccount', () => { + it('removes account and associated caches', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer(state, addAccount(makeAccount({ id: 'b' }))); + state = reducer( + state, + appendLog({ accountId: 'a', entry: { ts: 0, level: 'info', msg: 'x' } as AccountLogEntry }) + ); + + state = reducer(state, removeAccount({ accountId: 'a' })); + expect(state.accounts.a).toBeUndefined(); + expect(state.messages.a).toBeUndefined(); + expect(state.unread.a).toBeUndefined(); + expect(state.logs.a).toBeUndefined(); + expect(state.order).toEqual(['b']); + }); + + it('reassigns active to the first remaining account on removal of active', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer(state, addAccount(makeAccount({ id: 'b' }))); + expect(state.activeAccountId).toBe('a'); + + state = reducer(state, removeAccount({ accountId: 'a' })); + expect(state.activeAccountId).toBe('b'); + }); + + it('sets active to null when last account is removed', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'only' }))); + state = reducer(state, removeAccount({ accountId: 'only' })); + expect(state.order).toEqual([]); + expect(state.activeAccountId).toBeNull(); + }); +}); + +describe('accountsSlice setActiveAccount and setAccountStatus', () => { + it('setActiveAccount sets and clears the active id', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer(state, setActiveAccount(null)); + expect(state.activeAccountId).toBeNull(); + state = reducer(state, setActiveAccount('a')); + expect(state.activeAccountId).toBe('a'); + }); + + it('setAccountStatus updates status and lastError', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer( + state, + setAccountStatus({ accountId: 'a', status: 'error', lastError: 'sso expired' }) + ); + expect(state.accounts.a.status).toBe('error'); + expect(state.accounts.a.lastError).toBe('sso expired'); + }); + + it('setAccountStatus ignores unknown accounts', () => { + const state = reducer(undefined, setAccountStatus({ accountId: 'ghost', status: 'error' })); + expect(state.accounts.ghost).toBeUndefined(); + }); +}); + +describe('accountsSlice appendMessages', () => { + it('replaces messages on every ingest (snapshot semantics, not delta)', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer( + state, + appendMessages({ accountId: 'a', messages: [makeMessage({ id: 'm1' })] }) + ); + state = reducer( + state, + appendMessages({ accountId: 'a', messages: [makeMessage({ id: 'm2' })] }) + ); + expect(state.messages.a.map(m => m.id)).toEqual(['m2']); + }); + + it('caps the stored message list at 200 entries', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + const many = Array.from({ length: 250 }, (_v, i) => makeMessage({ id: `m-${i}` })); + state = reducer(state, appendMessages({ accountId: 'a', messages: many })); + expect(state.messages.a).toHaveLength(200); + expect(state.messages.a[0].id).toBe('m-0'); + expect(state.messages.a[199].id).toBe('m-199'); + }); + + it('updates unread when provided and ignores unknown accounts', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer( + state, + appendMessages({ accountId: 'a', messages: [makeMessage()], unread: 7 }) + ); + expect(state.unread.a).toBe(7); + + const unchanged = reducer( + state, + appendMessages({ accountId: 'ghost', messages: [makeMessage()] }) + ); + expect(unchanged.messages.ghost).toBeUndefined(); + }); +}); + +describe('accountsSlice appendLog', () => { + it('caps the log buffer at 100 entries and keeps the latest', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + for (let i = 0; i < 120; i += 1) { + state = reducer( + state, + appendLog({ + accountId: 'a', + entry: { ts: i, level: 'info', msg: `line-${i}` } as AccountLogEntry, + }) + ); + } + expect(state.logs.a).toHaveLength(100); + expect((state.logs.a[0] as AccountLogEntry & { msg: string }).msg).toBe('line-20'); + expect((state.logs.a[99] as AccountLogEntry & { msg: string }).msg).toBe('line-119'); + }); +}); + +describe('accountsSlice resetAccountsState', () => { + it('returns to the initial empty state', () => { + let state = reducer(undefined, addAccount(makeAccount({ id: 'a' }))); + state = reducer(state, addAccount(makeAccount({ id: 'b' }))); + state = reducer(state, resetAccountsState()); + expect(state.accounts).toEqual({}); + expect(state.order).toEqual([]); + expect(state.activeAccountId).toBeNull(); + expect(state.messages).toEqual({}); + expect(state.unread).toEqual({}); + expect(state.logs).toEqual({}); + }); +}); diff --git a/app/src/store/__tests__/deepLinkAuthState.test.ts b/app/src/store/__tests__/deepLinkAuthState.test.ts new file mode 100644 index 000000000..f81331aba --- /dev/null +++ b/app/src/store/__tests__/deepLinkAuthState.test.ts @@ -0,0 +1,107 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + beginDeepLinkAuthProcessing, + completeDeepLinkAuthProcessing, + failDeepLinkAuthProcessing, + getDeepLinkAuthState, + subscribeDeepLinkAuthState, + useDeepLinkAuthState, +} from '../deepLinkAuthState'; + +/** + * Reset module-level state between tests by calling complete() (the default/idle state) + * before each test's assertions. The ad-hoc store persists across tests. + */ +afterEach(() => { + completeDeepLinkAuthProcessing(); +}); + +describe('deepLinkAuthState transitions', () => { + it('starts idle with no error message', () => { + completeDeepLinkAuthProcessing(); + expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null }); + }); + + it('beginDeepLinkAuthProcessing flips isProcessing true and clears prior error', () => { + failDeepLinkAuthProcessing('prior failure'); + expect(getDeepLinkAuthState().errorMessage).toBe('prior failure'); + + beginDeepLinkAuthProcessing(); + expect(getDeepLinkAuthState()).toEqual({ isProcessing: true, errorMessage: null }); + }); + + it('completeDeepLinkAuthProcessing returns to idle', () => { + beginDeepLinkAuthProcessing(); + completeDeepLinkAuthProcessing(); + expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null }); + }); + + it('failDeepLinkAuthProcessing surfaces message and resets processing flag', () => { + beginDeepLinkAuthProcessing(); + failDeepLinkAuthProcessing('token expired'); + expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: 'token expired' }); + }); +}); + +describe('deepLinkAuthState subscribers', () => { + it('notifies subscribers on every transition', () => { + const listener = vi.fn(); + const unsubscribe = subscribeDeepLinkAuthState(listener); + + beginDeepLinkAuthProcessing(); + failDeepLinkAuthProcessing('boom'); + completeDeepLinkAuthProcessing(); + + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it('stops notifying after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeDeepLinkAuthState(listener); + beginDeepLinkAuthProcessing(); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + completeDeepLinkAuthProcessing(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('supports multiple independent subscribers', () => { + const a = vi.fn(); + const b = vi.fn(); + const offA = subscribeDeepLinkAuthState(a); + const offB = subscribeDeepLinkAuthState(b); + + beginDeepLinkAuthProcessing(); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + offA(); + failDeepLinkAuthProcessing('oops'); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(2); + + offB(); + }); +}); + +describe('useDeepLinkAuthState hook', () => { + it('re-renders when state changes', () => { + completeDeepLinkAuthProcessing(); + const { result } = renderHook(() => useDeepLinkAuthState()); + expect(result.current).toEqual({ isProcessing: false, errorMessage: null }); + + act(() => { + beginDeepLinkAuthProcessing(); + }); + expect(result.current).toEqual({ isProcessing: true, errorMessage: null }); + + act(() => { + failDeepLinkAuthProcessing('denied'); + }); + expect(result.current).toEqual({ isProcessing: false, errorMessage: 'denied' }); + }); +}); diff --git a/app/src/store/__tests__/threadSlice.test.ts b/app/src/store/__tests__/threadSlice.test.ts new file mode 100644 index 000000000..64a8a8735 --- /dev/null +++ b/app/src/store/__tests__/threadSlice.test.ts @@ -0,0 +1,273 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { threadApi } from '../../services/api/threadApi'; +import type { Thread, ThreadMessage } from '../../types/thread'; +import threadReducer, { + addInferenceResponse, + clearAllThreads, + clearSelectedThread, + loadThreadMessages, + loadThreads, + setActiveThread, + setSelectedThread, +} from '../threadSlice'; + +vi.mock('../../services/api/threadApi', () => ({ + threadApi: { + createNewThread: vi.fn(), + getThreads: vi.fn(), + getThreadMessages: vi.fn(), + appendMessage: vi.fn(), + deleteThread: vi.fn(), + generateTitleIfNeeded: vi.fn(), + updateMessage: vi.fn(), + purge: vi.fn(), + }, +})); + +const mockedThreadApi = vi.mocked(threadApi); + +function createStore() { + return configureStore({ reducer: { thread: threadReducer } }); +} + +function makeThread(overrides: Partial = {}): Thread { + return { + id: 't-1', + title: 'Untitled', + chatId: null, + isActive: false, + messageCount: 0, + lastMessageAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function makeMessage(overrides: Partial = {}): ThreadMessage { + return { + id: 'm-1', + content: 'hello', + type: 'text', + extraMetadata: {}, + sender: 'user', + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('threadSlice synchronous reducers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('starts with the expected initial state', () => { + const store = createStore(); + const state = store.getState().thread; + expect(state.threads).toEqual([]); + expect(state.selectedThreadId).toBeNull(); + expect(state.activeThreadId).toBeNull(); + expect(state.messagesByThreadId).toEqual({}); + expect(state.messages).toEqual([]); + expect(state.isLoadingThreads).toBe(false); + expect(state.isLoadingMessages).toBe(false); + expect(state.suggestedQuestions).toEqual([]); + }); + + it('setSelectedThread copies cached messages into the visible list', async () => { + const store = createStore(); + const cached = [makeMessage({ id: 'm-1' }), makeMessage({ id: 'm-2' })]; + + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ + messages: cached, + count: cached.length, + }); + await store.dispatch(loadThreadMessages('t-1')); + + store.dispatch(setSelectedThread('t-1')); + const state = store.getState().thread; + expect(state.selectedThreadId).toBe('t-1'); + expect(state.messages).toEqual(cached); + expect(state.messagesError).toBeNull(); + expect(state.suggestedQuestions).toEqual([]); + }); + + it('setSelectedThread resets messages when cache is empty', () => { + const store = createStore(); + store.dispatch(setSelectedThread('missing')); + const state = store.getState().thread; + expect(state.selectedThreadId).toBe('missing'); + expect(state.messages).toEqual([]); + }); + + it('clearSelectedThread clears visible selection but keeps cache', async () => { + const store = createStore(); + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ + messages: [makeMessage()], + count: 1, + }); + await store.dispatch(loadThreadMessages('t-1')); + store.dispatch(setSelectedThread('t-1')); + + store.dispatch(clearSelectedThread()); + const state = store.getState().thread; + expect(state.selectedThreadId).toBeNull(); + expect(state.messages).toEqual([]); + expect(state.messagesByThreadId['t-1']).toHaveLength(1); + }); + + it('setActiveThread only touches the active id', () => { + const store = createStore(); + store.dispatch(setActiveThread('t-active')); + expect(store.getState().thread.activeThreadId).toBe('t-active'); + store.dispatch(setActiveThread(null)); + expect(store.getState().thread.activeThreadId).toBeNull(); + }); + + it('clearAllThreads wipes threads, messages, and selection', async () => { + const store = createStore(); + mockedThreadApi.getThreads.mockResolvedValueOnce({ + threads: [makeThread({ id: 't-1' })], + count: 1, + }); + await store.dispatch(loadThreads()); + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ + messages: [makeMessage()], + count: 1, + }); + await store.dispatch(loadThreadMessages('t-1')); + store.dispatch(setSelectedThread('t-1')); + store.dispatch(setActiveThread('t-1')); + + store.dispatch(clearAllThreads()); + const state = store.getState().thread; + expect(state.threads).toEqual([]); + expect(state.messagesByThreadId).toEqual({}); + expect(state.selectedThreadId).toBeNull(); + expect(state.activeThreadId).toBeNull(); + expect(state.messages).toEqual([]); + }); +}); + +describe('threadSlice loadThreads thunk', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets isLoadingThreads while pending and stores threads on fulfilled', async () => { + const store = createStore(); + const payload = { threads: [makeThread({ id: 'a' })], count: 1 }; + mockedThreadApi.getThreads.mockImplementationOnce(async () => { + expect(store.getState().thread.isLoadingThreads).toBe(true); + return payload; + }); + + await store.dispatch(loadThreads()); + const state = store.getState().thread; + expect(state.isLoadingThreads).toBe(false); + expect(state.threads).toEqual(payload.threads); + }); + + it('clears loading on rejection', async () => { + const store = createStore(); + mockedThreadApi.getThreads.mockRejectedValueOnce(new Error('network down')); + + const result = await store.dispatch(loadThreads()); + expect(result.type).toBe('thread/loadThreads/rejected'); + expect(store.getState().thread.isLoadingThreads).toBe(false); + }); +}); + +describe('threadSlice loadThreadMessages thunk', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('hydrates messagesByThreadId and mirrors visible list when selected', async () => { + const store = createStore(); + store.dispatch(setSelectedThread('t-1')); + const messages = [makeMessage({ id: 'a' }), makeMessage({ id: 'b' })]; + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ messages, count: messages.length }); + + await store.dispatch(loadThreadMessages('t-1')); + const state = store.getState().thread; + expect(state.messagesByThreadId['t-1']).toEqual(messages); + expect(state.messages).toEqual(messages); + expect(state.isLoadingMessages).toBe(false); + expect(state.messagesError).toBeNull(); + }); + + it('does not overwrite visible messages when loading a non-selected thread', async () => { + const store = createStore(); + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ + messages: [makeMessage({ id: 'x' })], + count: 1, + }); + await store.dispatch(loadThreadMessages('t-1')); + store.dispatch(setSelectedThread('t-1')); + + mockedThreadApi.getThreadMessages.mockResolvedValueOnce({ + messages: [makeMessage({ id: 'y', content: 'other thread' })], + count: 1, + }); + await store.dispatch(loadThreadMessages('t-2')); + + const state = store.getState().thread; + expect(state.messagesByThreadId['t-2']).toHaveLength(1); + expect(state.messagesByThreadId['t-2'][0].content).toBe('other thread'); + // Visible messages stayed pinned to t-1. + expect(state.messages).toHaveLength(1); + expect(state.messages[0].id).toBe('x'); + }); + + it('records messagesError on rejection', async () => { + const store = createStore(); + mockedThreadApi.getThreadMessages.mockRejectedValueOnce(new Error('boom')); + await store.dispatch(loadThreadMessages('t-1')); + const state = store.getState().thread; + expect(state.isLoadingMessages).toBe(false); + expect(state.messagesError).toBe('boom'); + }); +}); + +describe('threadSlice addInferenceResponse thunk', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('appends to the supplied thread even when activeThreadId is null', async () => { + const store = createStore(); + const persisted = makeMessage({ id: 'srv-1', sender: 'agent', content: 'ack' }); + mockedThreadApi.appendMessage.mockResolvedValueOnce(persisted); + + const result = await store.dispatch(addInferenceResponse({ content: 'ack', threadId: 't-1' })); + + expect(result.type).toBe('thread/addInferenceResponse/fulfilled'); + const state = store.getState().thread; + expect(state.messagesByThreadId['t-1']).toEqual([persisted]); + // activeThreadId must not be mutated by this thunk — only ChatRuntimeProvider clears it. + expect(state.activeThreadId).toBeNull(); + }); + + it('falls back to activeThreadId when no threadId is supplied', async () => { + const store = createStore(); + store.dispatch(setActiveThread('t-active')); + mockedThreadApi.appendMessage.mockResolvedValueOnce(makeMessage({ sender: 'agent' })); + + await store.dispatch(addInferenceResponse({ content: 'ack' })); + expect(mockedThreadApi.appendMessage).toHaveBeenCalledWith( + 't-active', + expect.objectContaining({ sender: 'agent', content: 'ack' }) + ); + // activeThreadId must not be cleared by this thunk — ChatRuntimeProvider owns that. + expect(store.getState().thread.activeThreadId).toBe('t-active'); + }); + + it('rejects cleanly when neither threadId nor activeThreadId is set', async () => { + const store = createStore(); + const result = await store.dispatch(addInferenceResponse({ content: 'ack' })); + expect(result.type).toBe('thread/addInferenceResponse/rejected'); + expect(mockedThreadApi.appendMessage).not.toHaveBeenCalled(); + }); +});