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',
    });
  });
});