slashCommands.test.tsx4.4 KBView on GitHub
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import {
  SLASH_COMMANDS,
  filterSlashCommands,
} from '@/modules/cedar-os/src/components/chatInput/slashCommands';
import type { MessageThread } from '@/modules/cedar-os/src/store/messages/MessageTypes';

/**
 * Phase 11 (design: chat-thread-store) — composer `/` slash commands (new / fork / clear).
 * The run handlers strip their editor text and perform thread store operations; exercised
 * headlessly with an editor stub + the real store.
 */
function makeEditorStub() {
  const calls: string[] = [];
  const inserted: string[] = [];
  const chain = {
    focus: () => chain,
    deleteRange: () => {
      calls.push('deleteRange');
      return chain;
    },
    insertContentAt: (_range: unknown, content: string) => {
      calls.push('insertContentAt');
      inserted.push(content);
      return chain;
    },
    run: () => true,
  };
  return {
    editor: { chain: () => chain } as never,
    range: { from: 0, to: 1 } as never,
    calls,
    inserted,
  };
}

const cmd = (id: string) => SLASH_COMMANDS.find((c) => c.id === id)!;

const seed = (thread: Partial<MessageThread> & Pick<MessageThread, 'id'>) => {
  useCedarStore.setState((state) => ({
    ...state,
    threadMap: { [thread.id]: { messages: [], ...thread } },
    mainThreadId: thread.id,
    activeThreadId: thread.id,
    messages: [],
  }));
};

describe('slash commands — thread operations', () => {
  it('filterSlashCommands narrows by id/label and returns all for empty query', () => {
    expect(filterSlashCommands('').length).toBe(5);
    expect(filterSlashCommands('for').map((c) => c.id)).toEqual(['fork']);
    expect(filterSlashCommands('clear').map((c) => c.id)).toEqual(['clear']);
  });

  it('/conversation and /file insert their trigger char so the mention menu opens', () => {
    const conv = makeEditorStub();
    cmd('conversation').run(conv.editor, conv.range);
    expect(conv.calls).toContain('insertContentAt');
    expect(conv.inserted).toEqual(['@']);

    const file = makeEditorStub();
    cmd('file').run(file.editor, file.range);
    expect(file.calls).toContain('insertContentAt');
    expect(file.inserted).toEqual(['[']);
  });

  it('/new drops to the draft chat — no thread id until it is used (and strips its editor text)', () => {
    seed({ id: 'A', name: 'A' });
    const { editor, range, calls } = makeEditorStub();

    cmd('new').run(editor, range);

    expect(calls).toContain('deleteRange'); // command text stripped from the editor
    expect(useCedarStore.getState().activeThreadId).toBe('');
    expect(useCedarStore.getState().mainThreadId).toBe('');
    // The chat it left is untouched, and the id is minted on first use.
    expect(useCedarStore.getState().threadMap['A']).toBeTruthy();
    const minted = useCedarStore.getState().ensureOpenChatThread();
    expect(minted).not.toBe('A');
    expect(useCedarStore.getState().threadMap[minted].messages).toHaveLength(0);
  });

  it('/fork copies messages + chatContext into a new selected thread (deep copy)', () => {
    seed({
      id: 'A',
      name: 'Adobe',
      messages: [{ id: 'm1', role: 'user', type: 'text', content: 'hi' }],
      chatContext: { items: [{ kind: 'conversation', id: 'conv_1', label: 'Adobe' }] },
    });
    const { editor, range } = makeEditorStub();

    cmd('fork').run(editor, range);

    const active = useCedarStore.getState().activeThreadId;
    const forked = useCedarStore.getState().threadMap[active];
    expect(active).not.toBe('A');
    expect(forked.messages).toHaveLength(1);
    expect(forked.chatContext?.items?.[0]?.id).toBe('conv_1');
    // Deep copy — mutating the fork's items must not touch the source.
    expect(forked.chatContext?.items).not.toBe(
      useCedarStore.getState().threadMap['A'].chatContext?.items,
    );
    expect(forked.name).toBe('Adobe (fork)');
  });

  it('/clear empties the active thread messages but preserves its chatContext', () => {
    seed({
      id: 'A',
      messages: [{ id: 'm1', role: 'user', type: 'text', content: 'hi' }],
      chatContext: { items: [{ kind: 'conversation', id: 'conv_1' }] },
    });
    const { editor, range } = makeEditorStub();

    cmd('clear').run(editor, range);

    const thread = useCedarStore.getState().threadMap['A'];
    expect(thread.messages).toHaveLength(0);
    expect(thread.chatContext?.items?.[0]?.id).toBe('conv_1'); // context preserved
    expect(useCedarStore.getState().activeThreadId).toBe('A'); // still the active thread
  });
});