channelContext.test.tsx5.5 KBView on GitHub import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
/**
* Non-email channels in agent context.
*
* Opening a Slack channel / LinkedIn DM / WhatsApp chat in the unibox publishes a pointer
* (`openChannelThread`) and the unsent reply (`channelDraftBody`) into the store; the ambient
* payload carries both, so the agent sees a channel chat the way it already sees an open
* email thread. The transcript is NOT shipped — the server hydrates it from the mirror tables
* once the chat is attached.
*
* Also covers the email regression this shares a root with: an open thread's draft must keep
* riding the wire AFTER the first send, when the thread has been committed to the chat's
* context set. "Covered by chatContext" is a chip-row default, not a payload gate — the
* committed item hydrates a subject and a snippet, never an unsaved composer.
*/
function seedBase(over: Record<string, unknown> = {}) {
useCedarStore.setState((state) => ({
...state,
activeConversationId: null,
conversationSelection: [],
conversations: {},
isThreadOpen: false,
isConversationOpen: false,
selectedThreadId: null,
mainThreadId: 'main',
threadMap: { main: { id: 'main', name: 'Main', messages: [] } },
threadData: {},
activeDoc: null,
chatContextVisibility: {},
additionalContext: {},
openChannelThread: null,
channelDraftBody: null,
draftBody: null,
draftDiff: null,
...over,
}));
}
const build = () =>
useCedarStore.getState().buildMergedContextForMailSendMessage() as unknown as Record<
string,
{ data: unknown } | undefined
>;
describe('buildMergedContextForMailSendMessage — open channel chat', () => {
it('ships the Slack channel pointer and its unsent reply', () => {
seedBase({
openChannelThread: {
channel: 'slack',
id: 'C0123',
label: '#ext-cedar-numeral',
conversationId: 'conv_abc',
},
channelDraftBody: 'thanks — pricing deck attached',
});
const ctx = build();
expect(ctx.openChannel).toEqual({ data: 'slack' });
expect(ctx.openChannelId).toEqual({ data: 'C0123' });
expect(ctx.openChannelLabel).toEqual({ data: '#ext-cedar-numeral' });
expect(ctx.openChannelConversationId).toEqual({ data: 'conv_abc' });
expect(ctx.channelDraftBody).toEqual({ data: 'thanks — pricing deck attached' });
});
it('carries the open Slack sub-thread when the thread panel is up', () => {
seedBase({
openChannelThread: { channel: 'slack', id: 'C0123', threadTs: '1720000000.0001' },
});
expect(build().openChannelThreadTs).toEqual({ data: '1720000000.0001' });
});
it('ships LinkedIn and WhatsApp chats by their Unipile chat id', () => {
seedBase({
openChannelThread: { channel: 'linkedin', id: 'chat_li_1', label: 'JD O’Keefe' },
});
expect(build().openChannel).toEqual({ data: 'linkedin' });
expect(build().openChannelId).toEqual({ data: 'chat_li_1' });
seedBase({ openChannelThread: { channel: 'whatsapp', id: 'chat_wa_1', label: 'Sam' } });
expect(build().openChannel).toEqual({ data: 'whatsapp' });
expect(build().openChannelId).toEqual({ data: 'chat_wa_1' });
});
it('sends nothing when no channel chat is open', () => {
seedBase();
const ctx = build();
expect(ctx.openChannel).toBeUndefined();
expect(ctx.openChannelId).toBeUndefined();
expect(ctx.channelDraftBody).toBeUndefined();
});
it('an explicitly dismissed channel badge stops the payload too', () => {
seedBase({
openChannelThread: { channel: 'slack', id: 'C0123' },
channelDraftBody: 'draft text',
chatContextVisibility: { channelThread: false },
});
const ctx = build();
expect(ctx.openChannelId).toBeUndefined();
expect(ctx.channelDraftBody).toBeUndefined();
});
it('dismissing only the reply keeps the chat pointer', () => {
seedBase({
openChannelThread: { channel: 'slack', id: 'C0123' },
channelDraftBody: 'draft text',
chatContextVisibility: { channelDraft: false },
});
const ctx = build();
expect(ctx.openChannelId).toEqual({ data: 'C0123' });
expect(ctx.channelDraftBody).toBeUndefined();
});
});
describe('buildMergedContextForMailSendMessage — email draft survives the first send', () => {
const seedOpenThreadWithDraft = (chatContext: Record<string, unknown>) =>
seedBase({
isThreadOpen: true,
selectedThreadId: 't1',
threadData: { t1: { conversationId: null, messages: [{ id: 'd1', isDraft: true }] } },
draftBody: '<p>my unsaved draft</p>',
threadMap: { main: { id: 'main', name: 'Main', messages: [], chatContext } },
});
it('ships emailThreadId + draftBody once the thread is committed to the chat context', () => {
// What the first send does: commits the open thread onto the chat thread's context.
// Before this fix, that made the very next message drop the whole email block.
seedOpenThreadWithDraft({
email: { threadId: 't1' },
items: [{ kind: 'email_thread', id: 't1' }],
});
const ctx = build();
expect(ctx.emailThreadId).toEqual({ data: 't1' });
expect(ctx.draftBody).toEqual({ data: '<p>my unsaved draft</p>' });
expect(ctx.draftId).toEqual({ data: 'd1' });
});
it('still honours an explicit user removal of the draft badge', () => {
seedOpenThreadWithDraft({ email: { threadId: 't1' } });
useCedarStore.setState((s) => ({ ...s, chatContextVisibility: { draft: false } }));
const ctx = build();
expect(ctx.emailThreadId).toEqual({ data: 't1' });
expect(ctx.draftBody).toBeUndefined();
});
});