emailThreadContext.test.tsx4.8 KBView on GitHub import { useCedarStore } from '@/modules/store';
import { enterChatThread, forgetChatEntry } from '@/modules/ux/layout/enterChatThread';
import { api } from '@/modules/trpc/trpc';
/**
* An open email thread is the chat's context.
*
* Chatting with a thread open used to leave no trace on the chat: the thread rode the wire as an
* ambient `emailThreadId` and vanished. It never became a chip, never showed in the rail, and
* re-opening the chat put you back on the agenda with no sign of what it was about. These cover
* the two halves of the fix — the send-time commit into `chatContext.items`, and reading that back
* out as the chat's displayed artifact when the chat is re-entered.
*/
const addContextItem = api.chat.addContextItem.mutate as jest.Mock;
const THREAD = 'chat_1';
beforeEach(() => {
forgetChatEntry();
addContextItem.mockReset();
// The store's optimistic write is what the assertions read; echoing the input back keeps the
// server reconcile from clobbering it with `undefined`.
addContextItem.mockImplementation(async ({ item }) => ({
items: [{ ...item, addedBy: 'user' }],
primaryConversation: null,
}));
useCedarStore.setState((state) => ({
...state,
threadMap: {
[THREAD]: { id: THREAD, lastLoaded: new Date().toISOString(), messages: [] },
},
mainThreadId: THREAD,
activeThreadId: THREAD,
activeConversationId: null,
conversationSelection: [],
selectedThreadId: null,
// Derived mirrors of `selectedArtifact` — written by setSelectedArtifact, so replacing
// threadMap alone leaves them stale from the previous case.
isThreadOpen: false,
isConversationOpen: false,
threadData: {
thr_1: { id: 'thr_1', messages: [], latest: { subject: 'Q3 renewal' } },
},
}));
});
const items = () => useCedarStore.getState().threadMap[THREAD]?.chatContext?.items ?? [];
describe('open email thread → committed chat context', () => {
it('sending with a thread open commits it as an email_thread context item', async () => {
const store = useCedarStore.getState();
store.openThread('thr_1');
expect(useCedarStore.getState().isThreadOpen).toBe(true);
// No provider is configured in tests, so the send bails right after the context merge —
// which is the part under test. The error is caught and surfaced as a chat message.
await useCedarStore.getState().sendMessage();
expect(items()).toEqual([
expect.objectContaining({ kind: 'email_thread', id: 'thr_1', label: 'Q3 renewal' }),
]);
});
it('does not commit a stale selectedThreadId when no thread is open', async () => {
// Navigating off /mail leaves `selectedThreadId` pointing at the last email read. That is
// not context — only an OPEN thread is.
useCedarStore.setState((s) => ({ ...s, selectedThreadId: 'thr_1' }));
expect(useCedarStore.getState().isThreadOpen).toBe(false);
await useCedarStore.getState().sendMessage();
expect(items()).toEqual([]);
});
it('commits the thread once, not on every send', async () => {
useCedarStore.getState().openThread('thr_1');
await useCedarStore.getState().sendMessage();
await useCedarStore.getState().sendMessage();
expect(addContextItem).toHaveBeenCalledTimes(1);
expect(items()).toHaveLength(1);
});
});
describe('re-entering the chat restores the email thread as the displayed artifact', () => {
it('opens the committed email thread and points selectedThreadId at it', () => {
const store = useCedarStore.getState();
store.setContextItems(THREAD, [
{ kind: 'email_thread', id: 'thr_1', label: 'Q3 renewal', addedBy: 'user' },
]);
store.clearArtifact();
const navigate = jest.fn();
enterChatThread(THREAD, { navigate });
const s = useCedarStore.getState();
expect(s.getDisplayArtifact()).toEqual({ kind: 'email_thread', id: 'thr_1' });
// `openThread`, not `openArtifact` — ThreadDisplay's data and LayoutUrlSync's `?threadOpen`
// both read `selectedThreadId`, which `openArtifact` alone never sets.
expect(s.selectedThreadId).toBe('thr_1');
// `/home`, not `/agent`. `/agent` is the address the agent home USED to have and is now a
// redirect only (app/routes.ts); navigating to it would bounce through a second entry.
expect(navigate).toHaveBeenCalledWith('/home');
});
it('still prefers a conversation when the chat has both', () => {
const store = useCedarStore.getState();
store.setPrimaryConversation(THREAD, { id: 'conv_1', name: 'Numeral' });
store.setContextItems(THREAD, [
{ kind: 'email_thread', id: 'thr_1', label: 'Q3 renewal', addedBy: 'user' },
]);
store.clearArtifact();
enterChatThread(THREAD, { navigate: jest.fn() });
expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
kind: 'conversation',
id: 'conv_1',
});
});
});