jest.mock('uuid', () => ({ v4: jest.fn(() => 'session-id'), })); import { NetaClawSessionService } from '../src/modules/netaclaw/gateway/session.js'; import { NetaClawGateway } from '../src/modules/netaclaw/gateway/server.js'; import { FileSessionTreeProvider } from '../src/modules/netaclaw/session-tree/file_provider.js'; import { MySqlSessionTreeProvider } from '../src/modules/netaclaw/session-tree/mysql_provider.js'; describe('NetaClawSessionService.deleteAllSessions', () => { let service: NetaClawSessionService; beforeEach(() => { service = new NetaClawSessionService(); }); it('deletes all subagent sessions, messages, and sessions without calling delete({}) when userId is omitted', async () => { const sessionRows = [{ sessionId: 's1' }, { sessionId: 's2' }]; const sessionDeleteBuilder = { delete: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue(undefined), }; service.sessionRepo = { find: jest.fn().mockResolvedValue(sessionRows), delete: jest.fn().mockRejectedValue(new Error('delete({}) should not be called')), createQueryBuilder: jest.fn().mockReturnValue(sessionDeleteBuilder), } as any; service.messageRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.subagentSessionRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.agentSessionRepo = { find: jest.fn().mockResolvedValue([]), delete: jest.fn().mockResolvedValue(undefined), createQueryBuilder: jest.fn().mockReturnValue({ delete: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue(undefined), }), } as any; service.agentSessionEntryRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; await service.deleteAllSessions(); expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: {} }); expect(service.subagentSessionRepo.delete).toHaveBeenCalledTimes(2); expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' }); expect(service.subagentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' }); expect(service.messageRepo.delete).toHaveBeenCalledTimes(2); expect(service.messageRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' }); expect(service.messageRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' }); expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledTimes(2); expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' }); expect(service.agentSessionEntryRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' }); expect(service.agentSessionRepo.delete).toHaveBeenCalledTimes(2); expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(1, { sessionId: 's1' }); expect(service.agentSessionRepo.delete).toHaveBeenNthCalledWith(2, { sessionId: 's2' }); expect(service.agentSessionRepo.find).toHaveBeenCalledWith(); expect(service.agentSessionRepo.createQueryBuilder).toHaveBeenCalledWith(); expect(service.sessionRepo.delete).not.toHaveBeenCalled(); expect(service.sessionRepo.createQueryBuilder).toHaveBeenCalledWith(); expect(sessionDeleteBuilder.delete).toHaveBeenCalled(); expect(sessionDeleteBuilder.from).toHaveBeenCalled(); expect(sessionDeleteBuilder.execute).toHaveBeenCalled(); }); it('keeps user-scoped deletes on repository criteria when userId is provided', async () => { const sessionRows = [{ sessionId: 's1' }]; service.sessionRepo = { find: jest.fn().mockResolvedValue(sessionRows), delete: jest.fn().mockResolvedValue(undefined), createQueryBuilder: jest.fn(), } as any; service.messageRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.subagentSessionRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.agentSessionRepo = { find: jest.fn().mockResolvedValue([]), delete: jest.fn().mockResolvedValue(undefined), createQueryBuilder: jest.fn(), } as any; service.agentSessionEntryRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; await service.deleteAllSessions('user-1'); expect(service.sessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' }); expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' }); expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 's1' }); expect(service.agentSessionRepo.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' }); expect(service.sessionRepo.delete).toHaveBeenCalledWith({ userId: 'user-1' }); expect(service.sessionRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(service.agentSessionRepo.createQueryBuilder).not.toHaveBeenCalled(); }); }); describe('NetaClawSessionService.deleteSession', () => { let service: NetaClawSessionService; beforeEach(() => { service = new NetaClawSessionService(); service.agentSessionEntryRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.agentSessionRepo = { findOneBy: jest.fn().mockResolvedValue(null), delete: jest.fn().mockResolvedValue(undefined), } as any; service.subagentSessionRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.messageRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; service.sessionRepo = { delete: jest.fn().mockResolvedValue(undefined), } as any; }); it('deletes the active session-tree provider storage for the selected agent before legacy rows', async () => { const provider = { deleteSession: jest.fn().mockResolvedValue(undefined), }; jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any); await service.deleteSession('session-tree-1', 7); expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(7); expect(provider.deleteSession).toHaveBeenCalledWith('session-tree-1'); expect(service.agentSessionEntryRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' }); expect(service.agentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' }); expect(service.subagentSessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' }); expect(service.messageRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' }); expect(service.sessionRepo.delete).toHaveBeenCalledWith({ sessionId: 'session-tree-1' }); }); it('resolves the owning agent from mysql session-tree rows before selecting the provider', async () => { const provider = { deleteSession: jest.fn().mockResolvedValue(undefined), }; (service.agentSessionRepo.findOneBy as jest.Mock).mockResolvedValue({ sessionId: 'mysql-session-1', agentId: '8', }); jest.spyOn(service, 'getSessionTreeProviderForAgent').mockResolvedValue(provider as any); await service.deleteSession('mysql-session-1', 7); expect(service.agentSessionRepo.findOneBy).toHaveBeenCalledWith({ sessionId: 'mysql-session-1' }); expect(service.getSessionTreeProviderForAgent).toHaveBeenCalledWith(8); expect(provider.deleteSession).toHaveBeenCalledWith('mysql-session-1'); }); }); describe('NetaClawSessionService history ordering', () => { let service: NetaClawSessionService; beforeEach(() => { service = new NetaClawSessionService(); }); it('orders compacted history by createTime asc and id asc', async () => { service.messageRepo = { find: jest.fn().mockResolvedValue([]), } as any; await service.loadHistory('session-1'); const [args] = (service.messageRepo.find as jest.Mock).mock.calls[0]; expect(args.where.sessionId).toBe('session-1'); expect(args.where.compactedAt?._type).toBe('isNull'); expect(args.order).toEqual({ createTime: 'ASC', id: 'ASC' }); }); it('orders history-with-id and full message views by createTime asc and id asc', async () => { service.messageRepo = { find: jest.fn().mockResolvedValue([]), } as any; await service.loadHistoryWithId('session-1'); await service.getMessages('session-1', 'full'); const [firstCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[0]; const [secondCallArgs] = (service.messageRepo.find as jest.Mock).mock.calls[1]; expect(firstCallArgs.where.sessionId).toBe('session-1'); expect(firstCallArgs.where.compactedAt?._type).toBe('isNull'); expect(firstCallArgs.order).toEqual({ createTime: 'ASC', id: 'ASC' }); expect(secondCallArgs).toEqual({ where: { sessionId: 'session-1' }, order: { createTime: 'ASC', id: 'ASC' }, }); }); }); describe('NetaClawSessionService session-tree provider resolution', () => { let service: NetaClawSessionService; beforeEach(() => { service = new NetaClawSessionService(); service.netaclawConfig = { dataDir: 'C:/neta-data', defaultModel: 'anthropic:claude-sonnet-4-20250514', apiKeys: {}, skillsDir: './skills', maxToolRounds: 20, session: { backend: 'file', }, } as any; service.agentRepo = { findOneBy: jest.fn(), } as any; service.agentSessionRepo = { find: jest.fn(), findOne: jest.fn(), findOneBy: jest.fn(), save: jest.fn(), delete: jest.fn(), } as any; service.agentSessionEntryRepo = { find: jest.fn(), findOne: jest.fn(), findOneBy: jest.fn(), save: jest.fn(), delete: jest.fn(), } as any; }); it('uses the global file backend by default when the agent has no session config', async () => { (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({ id: 7, name: 'default', config: {}, }); const provider = await service.getSessionTreeProviderForAgent(7); expect(provider).toBeInstanceOf(FileSessionTreeProvider); expect((provider as FileSessionTreeProvider).getSessionFilePath('session-1')).toBe( 'C:\\neta-data\\sessions\\c2Vzc2lvbi0x.jsonl' ); }); it('defaults to file backend when both agent config and global session config are absent', async () => { service.netaclawConfig = { dataDir: 'C:/neta-data', defaultModel: 'anthropic:claude-sonnet-4-20250514', apiKeys: {}, skillsDir: './skills', maxToolRounds: 20, } as any; (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({ id: 11, name: 'default-no-session-config', config: {}, }); const provider = await service.getSessionTreeProviderForAgent(11); expect(provider).toBeInstanceOf(FileSessionTreeProvider); expect((provider as FileSessionTreeProvider).getSessionFilePath('session-3')).toBe( 'C:\\neta-data\\sessions\\c2Vzc2lvbi0z.jsonl' ); }); it('lets the agent override the backend to mysql', async () => { (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({ id: 8, name: 'mysql-agent', config: { session: { backend: 'mysql', }, }, }); const provider = await service.getSessionTreeProviderForAgent(8); expect(provider).toBeInstanceOf(MySqlSessionTreeProvider); }); it('lets the agent override the file root dir', async () => { (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({ id: 9, name: 'file-agent', config: { session: { backend: 'file', file: { rootDir: 'D:/agent-sessions', }, }, }, }); const provider = await service.getSessionTreeProviderForAgent(9); expect(provider).toBeInstanceOf(FileSessionTreeProvider); expect((provider as FileSessionTreeProvider).getSessionFilePath('session-2')).toBe( 'D:\\agent-sessions\\c2Vzc2lvbi0y.jsonl' ); }); it('throws when mysql session backend is selected but mysql repositories are unavailable', async () => { service.agentSessionRepo = undefined as any; (service.agentRepo.findOneBy as jest.Mock).mockResolvedValue({ id: 10, name: 'broken-mysql-agent', config: { session: { backend: 'mysql', }, }, }); await expect(service.getSessionTreeProviderForAgent(10)).rejects.toThrow( 'MySqlSessionTreeProvider requires both sessionRepo and entryRepo' ); }); }); describe('NetaClawGateway compaction queue draining', () => { it('keeps queued chats pending during inline auto-compaction when drainPending is false', async () => { const gateway = new NetaClawGateway() as any; gateway.logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; gateway.ctx = { emit: jest.fn() }; gateway.sessionService = { loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 1, role: 'user', content: 'hi' }]), updateSessionMetadata: jest.fn().mockResolvedValue(undefined), }; gateway.compactionService = { compact: jest.fn().mockResolvedValue({ success: true, compactedCount: 1, keepRecentCount: 1, summaryMessageId: 99, summaryContent: 'summary', tokensBefore: 10, tokensAfter: 5, }), }; gateway.sessionState.set('session-1', { pendingChat: [{ content: 'queued', agentName: 'default', agentId: 7 }], }); const handleChatSpy = jest.spyOn(gateway, 'handleChat').mockResolvedValue(undefined); await gateway.handleCompact( 'session-1', 'auto', { compactionThreshold: 70, modelConfig: {} }, false ); expect(handleChatSpy).not.toHaveBeenCalled(); expect(gateway.sessionState.get('session-1').pendingChat).toEqual([ { content: 'queued', agentName: 'default', agentId: 7 }, ]); }); }); describe('NetaClawGateway session-tree routing', () => { it('routes websocket agent chat through executeSessionTreeChat instead of the legacy executeChat path', async () => { const gateway = new NetaClawGateway() as any; gateway.logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; gateway.ctx = { emit: jest.fn() }; gateway.sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'), updateSessionMetadata: jest.fn().mockResolvedValue(undefined), }; gateway.agentService = { info: jest.fn().mockResolvedValue({ id: 7, name: 'default', systemPrompt: 'You are the runtime.', skills: [], config: {}, modelConfig: {}, subagentConfig: {}, }), }; gateway.skillLoader = {} as any; gateway.channelService = { resolveForAgent: jest.fn(), }; gateway.toolResolver = { resolve: jest.fn().mockResolvedValue({ tools: [], toolNames: [], toolPromptHints: {}, builtinToolNames: [], disabledReasons: [], }), }; gateway.chatOrchestrator = { executeSessionTreeChat: jest.fn().mockImplementation(async ({ runner }: any) => { await runner({ sessionId: 'session-tree-1', history: [], assistantEntryId: 'entry_assistant_1', subagents: { sessionId: 'session-tree-1', parentMessageId: 0, batches: [], onBatchStart: jest.fn(), onUpdate: jest.fn(), onDone: jest.fn(), }, userMessage: 'hello tree', }); return { sessionId: 'session-tree-1', finalContent: 'final answer', content: 'final answer', usage: { inputTokens: 12, outputTokens: 5 }, toolCallCount: 0, assistantEntryId: 'entry_assistant_1', subagentBatches: [], }; }), executeChat: jest.fn(), }; gateway.subagentService = {} as any; gateway.compactionService = {} as any; await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7); expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'session-tree-1', requestedAgentName: 'default', message: 'hello tree', agentId: 7, runner: expect.any(Function), }) ); expect(gateway.chatOrchestrator.executeChat).not.toHaveBeenCalled(); }); it('does not run legacy linear-history compaction checks before session-tree websocket chat', async () => { const gateway = new NetaClawGateway() as any; gateway.logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), }; gateway.ctx = { emit: jest.fn() }; gateway.sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-tree-1'), updateSessionMetadata: jest.fn().mockResolvedValue(undefined), loadHistory: jest.fn().mockRejectedValue(new Error('legacy loadHistory should not be called')), }; gateway.agentService = { info: jest.fn().mockResolvedValue({ id: 7, name: 'default', systemPrompt: 'You are the runtime.', skills: [], config: {}, modelConfig: {}, subagentConfig: {}, compactionThreshold: 1, }), }; gateway.skillLoader = {} as any; gateway.channelService = { resolveForAgent: jest.fn(), }; gateway.toolResolver = { resolve: jest.fn().mockResolvedValue({ tools: [], toolNames: [], toolPromptHints: {}, builtinToolNames: [], disabledReasons: [], }), }; gateway.chatOrchestrator = { executeSessionTreeChat: jest.fn().mockResolvedValue({ sessionId: 'session-tree-1', finalContent: 'final answer', content: 'final answer', usage: undefined, toolCallCount: 0, assistantEntryId: 'entry_assistant_1', subagentBatches: [], }), executeChat: jest.fn(), }; gateway.subagentService = {} as any; gateway.compactionService = {} as any; const handleCompactSpy = jest.spyOn(gateway, 'handleCompact'); await gateway.handleChat('session-tree-1', 'hello tree', 'default', 7); expect(gateway.agentService.info).toHaveBeenCalledTimes(1); expect(gateway.sessionService.loadHistory).not.toHaveBeenCalled(); expect(handleCompactSpy).not.toHaveBeenCalled(); expect(gateway.chatOrchestrator.executeSessionTreeChat).toHaveBeenCalled(); }); });