test(app): expand frontend regression coverage for store slices and core RPC (#787)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jwalin Shah
2026-04-22 23:25:34 -07:00
committed by GitHub
co-authored by Claude
parent 1d62d1c0ac
commit a84c42860c
4 changed files with 717 additions and 0 deletions
@@ -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<string, string>;
expect(headers['Content-Type']).toBe('application/json');
});
});
@@ -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> = {}): Account {
return {
id: 'acct-1',
provider: 'slack',
label: 'Slack',
createdAt: '2026-01-01T00:00:00Z',
status: 'pending',
...overrides,
};
}
function makeMessage(overrides: Partial<IngestedMessage> = {}): 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({});
});
});
@@ -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' });
});
});
+273
View File
@@ -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> = {}): 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> = {}): 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();
});
});