import { describe, it, expect, vi } from 'vitest'; import { extractSessionCommand, handleSessionCommand, isSessionCommandControlMessage, isSessionCommandAllowed, } from './session-commands.js'; import type { NewMessage } from './types.js'; import type { SessionCommandDeps } from './session-commands.js'; describe('extractSessionCommand', () => { const trigger = /^@Andy\b/i; it('detects bare /compact', () => { expect(extractSessionCommand('/compact', trigger)).toBe('/compact'); }); it('does not treat bot-name prefixes as commands anymore', () => { expect(extractSessionCommand('@Andy /compact', trigger)).toBeNull(); }); it('detects bare /clear', () => { expect(extractSessionCommand('/clear', trigger)).toBe('/clear'); }); it('rejects /compact with extra text', () => { expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); }); it('rejects partial matches', () => { expect(extractSessionCommand('/compaction', trigger)).toBeNull(); }); it('rejects regular messages', () => { expect( extractSessionCommand('please compact the conversation', trigger), ).toBeNull(); }); it('handles whitespace', () => { expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact'); }); it('is case-sensitive for the command', () => { expect(extractSessionCommand('/Compact', trigger)).toBeNull(); }); }); describe('isSessionCommandAllowed', () => { it('allows main group regardless of sender', () => { expect(isSessionCommandAllowed(true, false, false)).toBe(true); }); it('allows trusted/admin sender (is_from_me) in non-main group', () => { expect(isSessionCommandAllowed(false, true, false)).toBe(true); }); it('allows configured admin sender in non-main group', () => { expect(isSessionCommandAllowed(false, false, true)).toBe(true); }); it('denies untrusted sender in non-main group', () => { expect(isSessionCommandAllowed(false, false, false)).toBe(false); }); it('allows trusted sender in main group', () => { expect(isSessionCommandAllowed(true, true, false)).toBe(true); }); }); describe('isSessionCommandControlMessage', () => { it('matches clear confirmation output', () => { expect( isSessionCommandControlMessage( 'Current session cleared. The next message will start a new conversation.', ), ).toBe(true); }); it('matches admin denial output', () => { expect( isSessionCommandControlMessage('Session commands require admin access.'), ).toBe(true); }); it('does not match regular bot conversation', () => { expect( isSessionCommandControlMessage( '좋네요. 필요해지면 바로 말씀드리겠습니다.', ), ).toBe(false); }); }); function makeMsg( content: string, overrides: Partial = {}, ): NewMessage { return { id: 'msg-1', chat_jid: 'group@test', sender: 'user@test', sender_name: 'User', content, timestamp: '100', ...overrides, }; } function makeDeps( overrides: Partial = {}, ): SessionCommandDeps { return { sendMessage: vi.fn().mockResolvedValue(undefined), setTyping: vi.fn().mockResolvedValue(undefined), runAgent: vi.fn().mockResolvedValue('success'), closeStdin: vi.fn(), clearSession: vi.fn(), advanceCursor: vi.fn(), formatMessages: vi.fn().mockReturnValue(''), isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), killProcess: vi.fn().mockReturnValue(false), ...overrides, }; } const trigger = /^@Andy\b/i; describe('handleSessionCommand', () => { it('returns handled:false when no session command found', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ missedMessages: [makeMsg('hello')], isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result.handled).toBe(false); }); it('handles authorized /compact in main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact')], isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.runAgent).toHaveBeenCalledWith( '/compact', expect.any(Function), ); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); it('handles authorized /clear without invoking the agent', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ missedMessages: [makeMsg('/clear')], isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.closeStdin).toHaveBeenCalledTimes(1); expect(deps.clearSession).toHaveBeenCalledTimes(1); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); expect(deps.runAgent).not.toHaveBeenCalled(); expect(deps.sendMessage).toHaveBeenCalledWith( 'Current session cleared. The next message will start a new conversation.', ); }); it('sends denial to interactable sender in non-main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact', { is_from_me: false })], isMainGroup: false, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.sendMessage).toHaveBeenCalledWith( 'Session commands require admin access.', ); expect(deps.runAgent).not.toHaveBeenCalled(); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); it('silently consumes denied command when sender cannot interact', async () => { const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false), }); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact', { is_from_me: false })], isMainGroup: false, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.sendMessage).not.toHaveBeenCalled(); expect(deps.advanceCursor).toHaveBeenCalledWith('100'); }); it('processes pre-compact messages before /compact', async () => { const deps = makeDeps(); const msgs = [ makeMsg('summarize this', { timestamp: '99' }), makeMsg('/compact', { timestamp: '100' }), ]; const result = await handleSessionCommand({ missedMessages: msgs, isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC'); // Two runAgent calls: pre-compact + /compact expect(deps.runAgent).toHaveBeenCalledTimes(2); expect(deps.runAgent).toHaveBeenCalledWith( '', expect.any(Function), ); expect(deps.runAgent).toHaveBeenCalledWith( '/compact', expect.any(Function), ); }); it('allows is_from_me sender in non-main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact', { is_from_me: true })], isMainGroup: false, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.runAgent).toHaveBeenCalledWith( '/compact', expect.any(Function), ); }); it('allows configured admin sender in non-main group', async () => { const deps = makeDeps({ isAdminSender: vi.fn().mockReturnValue(true), }); const result = await handleSessionCommand({ missedMessages: [ makeMsg('/clear', { is_from_me: false, sender: 'discord-user-1' }), ], isMainGroup: false, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.clearSession).toHaveBeenCalledTimes(1); expect(deps.sendMessage).toHaveBeenCalledWith( 'Current session cleared. The next message will start a new conversation.', ); }); it('reports failure when command-stage runAgent returns error without streamed status', async () => { // runAgent resolves 'error' but callback never gets status: 'error' const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => { await onOutput({ status: 'success', result: null }); return 'error'; }), }); const result = await handleSessionCommand({ missedMessages: [makeMsg('/compact')], isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: true }); expect(deps.sendMessage).toHaveBeenCalledWith( expect.stringContaining('failed'), ); }); it('returns success:false on pre-compact failure with no output', async () => { const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') }); const msgs = [ makeMsg('summarize this', { timestamp: '99' }), makeMsg('/compact', { timestamp: '100' }), ]; const result = await handleSessionCommand({ missedMessages: msgs, isMainGroup: true, groupName: 'test', triggerPattern: trigger, timezone: 'UTC', deps, }); expect(result).toEqual({ handled: true, success: false }); expect(deps.sendMessage).toHaveBeenCalledWith( expect.stringContaining('Failed to process'), ); }); });