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