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