ambientTrim.test.tsx4.2 KBView on GitHub
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';

/**
 * Phase 11 (design: chat-context-set) — the per-message ambient payload carries
 * pointers, not payloads. `buildMergedContextForMailSendMessage`:
 *   - never ships `activeDocContent` (dropped entirely; the backend hydrates the
 *     doc from `activeDocPath`);
 *   - only ships `emailThreadMessages` when a DRAFT is open on the thread (the
 *     client's unsaved draft view is fresher than the server). With no draft open
 *     it sends `emailThreadId` only and the backend hydrates the thread messages.
 */

const threadMessage = (over: Record<string, unknown>) => ({
  id: 'x',
  isDraft: false,
  messageId: 'msg_1',
  subject: 'Re: Numeral pricing',
  sender: { name: 'JD', email: '<email>' },
  to: [{ email: '<email>' }],
  cc: undefined,
  receivedOn: '2026-07-05T00:00:00Z',
  snippet: 'here is the pricing',
  ...over,
});

function seedThread(messages: unknown[]) {
  useCedarStore.setState((state) => ({
    ...state,
    activeConversationId: null,
    conversationSelection: [],
    conversations: {},
    isThreadOpen: true,
    isConversationOpen: false,
    selectedThreadId: 't1',
    mainThreadId: 'main',
    threadMap: { main: { id: 'main', name: 'Main', messages: [] } },
    // ambient doc pointer — path/id/title should ride the wire, body never should
    activeDoc: {
      path: 'conversation/conv_abc/overview',
      documentId: 'doc_1',
      title: 'Overview',
    },
    threadData: { t1: { conversationId: null, messages } },
    chatContextVisibility: {},
    additionalContext: {},
  }));
}

describe('buildMergedContextForMailSendMessage — ambient trim (phase 11)', () => {
  it('no draft open: omits emailThreadMessages, sends emailThreadId + activeDocPath only, never activeDocContent', () => {
    seedThread([threadMessage({ messageId: 'msg_1' })]);
    // no draft body in the editor
    useCedarStore.setState((s) => ({ ...s, draftBody: null, draftDiff: null }));

    const ctx = useCedarStore.getState().buildMergedContextForMailSendMessage() as unknown as Record<
      string,
      unknown
    >;

    expect(ctx.emailThreadId).toEqual({ data: 't1' });
    expect(ctx.emailThreadMessages).toBeUndefined();
    expect(ctx.activeDocPath).toEqual({ data: 'conversation/conv_abc/overview' });
    // The id rides alongside the path: it is what manage-context needs to ATTACH the
    // ambient file, and a path is not an id.
    expect(ctx.activeDocId).toEqual({ data: 'doc_1' });
    expect(ctx.activeDocTitle).toEqual({ data: 'Overview' });
    // activeDocContent is gone entirely — the key must never appear on the wire.
    expect('activeDocContent' in ctx).toBe(false);
  });

  it('thread not open: omits emailThreadId even with a stale selectedThreadId (no leak after leaving mail)', () => {
    // A leftover `selectedThreadId` (e.g. the last email opened before navigating to /agent)
    // must not ride the wire once no thread is actually open — matches ContextBadgeRow's gate.
    seedThread([threadMessage({ messageId: 'msg_1' })]);
    useCedarStore.setState((s) => ({ ...s, isThreadOpen: false }));

    const ctx = useCedarStore.getState().buildMergedContextForMailSendMessage() as unknown as Record<
      string,
      unknown
    >;

    expect(ctx.emailThreadId).toBeUndefined();
    expect(ctx.emailThreadMessages).toBeUndefined();
  });

  it('draft open: ships emailThreadMessages + draftBody (client is authoritative for the live draft)', () => {
    seedThread([
      threadMessage({ id: 'd1', messageId: undefined, isDraft: true }),
      threadMessage({ id: 'm1', messageId: 'msg_1' }),
    ]);
    useCedarStore.setState((s) => ({ ...s, draftBody: '<p>my unsaved draft</p>', draftDiff: null }));

    const ctx = useCedarStore.getState().buildMergedContextForMailSendMessage() as unknown as Record<
      string,
      { data: unknown }
    >;

    expect(ctx.emailThreadId).toEqual({ data: 't1' });
    const msgs = ctx.emailThreadMessages?.data as unknown[] | undefined;
    expect(Array.isArray(msgs)).toBe(true);
    expect(msgs).toHaveLength(1); // the non-draft unprocessed message
    expect(ctx.draftBody).toEqual({ data: '<p>my unsaved draft</p>' });
    expect('activeDocContent' in ctx).toBe(false);
  });
});