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