jest.mock('uuid', () => ({ v4: jest.fn(() => 'session-id'), })); import { NetaClawSessionService } from '../src/modules/netaclaw/gateway/session.js'; import { NetaClawChatOrchestratorService } from '../src/modules/netaclaw/service/chat_orchestrator.js'; describe('NetaClawChatOrchestratorService', () => { it('anchors an assistant placeholder before running the agent and updates it with the final content', async () => { const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }), updateMessage: jest.fn().mockResolvedValue(undefined), } as unknown as NetaClawSessionService; const runner = jest.fn(async params => { expect(params.assistantMessageId).toBe(202); expect(params.userMessageId).toBe(101); expect(params.history).toEqual([]); expect(sessionService.saveMessage).toHaveBeenCalledTimes(2); expect(sessionService.updateMessage).not.toHaveBeenCalled(); return { finalContent: 'final answer', usage: { inputTokens: 11, outputTokens: 7 }, toolCallCount: 0, }; }); const service = new NetaClawChatOrchestratorService(); service.sessionService = sessionService; const result = await service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'hello', agentId: 7, userId: 'user-1', runner, }); expect(sessionService.getOrCreateSession).toHaveBeenCalledWith('session-1', 'default', 7, 'user-1'); expect(sessionService.saveMessage).toHaveBeenNthCalledWith(1, 'session-1', { role: 'user', content: 'hello', }); expect(sessionService.saveMessage).toHaveBeenNthCalledWith(2, 'session-1', { role: 'assistant', content: '', metadata: { inFlight: true }, }); expect(runner).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'session-1', assistantMessageId: 202, userMessageId: 101, userMessage: 'hello', }) ); expect(sessionService.updateMessage).toHaveBeenCalledWith(202, { content: 'final answer', metadata: { inFlight: false }, }); expect(result).toEqual({ sessionId: 'session-1', finalContent: 'final answer', content: 'final answer', usage: { inputTokens: 11, outputTokens: 7 }, toolCallCount: 0, assistantMessageId: 202, subagentBatches: [], }); }); it('excludes the just-saved user message by exact id even when later history entries exist', async () => { const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([ { id: 11, role: 'user', content: 'older user' }, { id: 101, role: 'user', content: 'current user' }, { id: 150, role: 'assistant', content: 'later assistant' }, ]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'current user' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }), updateMessage: jest.fn().mockResolvedValue(undefined), } as unknown as NetaClawSessionService; const runner = jest.fn().mockResolvedValue({ finalContent: 'final answer', toolCallCount: 0, }); const service = new NetaClawChatOrchestratorService(); service.sessionService = sessionService; await service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'current user', runner, }); expect(runner).toHaveBeenCalledWith( expect.objectContaining({ history: [ { role: 'user', content: 'older user', toolCalls: undefined, toolCallId: undefined }, { role: 'assistant', content: 'later assistant', toolCalls: undefined, toolCallId: undefined }, ], }) ); }); it('passes the saved user message id into history preparation callbacks', async () => { const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([ { id: 7, role: 'assistant', content: 'older answer' }, { id: 101, role: 'user', content: 'current user' }, ]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'current user' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '' }), updateMessage: jest.fn().mockResolvedValue(undefined), } as unknown as NetaClawSessionService; const prepareHistory = jest.fn(async ({ userMessageId, history }) => { expect(userMessageId).toBe(101); return history; }); const runner = jest.fn().mockResolvedValue({ finalContent: 'final answer', toolCallCount: 0, }); const service = new NetaClawChatOrchestratorService(); service.sessionService = sessionService; await service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'current user', prepareHistory, runner, }); expect(prepareHistory).toHaveBeenCalledWith({ sessionId: 'session-1', userMessageId: 101, history: [{ role: 'assistant', content: 'older answer', toolCalls: undefined, toolCallId: undefined }], }); }); it('collects subagent batches for the assistant placeholder and persists them on the same assistant message', async () => { const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'delegate this' }]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'delegate this' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }), updateMessage: jest.fn().mockResolvedValue(undefined), } as unknown as NetaClawSessionService; const service = new NetaClawChatOrchestratorService(); service.sessionService = sessionService; const result = await service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'delegate this', runner: async params => { expect(params.assistantMessageId).toBe(202); expect(params.subagents).toEqual( expect.objectContaining({ sessionId: 'session-1', parentMessageId: 202, batches: [], }) ); params.subagents.onBatchStart({ batchId: 'batch-1', sessionId: 'session-1', parentMessageId: 202, mode: 'parallel', tasks: [ { goal: 'task-1', agentName: 'worker-a' }, { goal: 'task-2', agentName: 'worker-b' }, ], }); params.subagents.onUpdate({ batchId: 'batch-1', sessionId: 'session-1', parentMessageId: 202, sequence: 1, timestamp: '2026-04-19T00:00:01.000Z', event: { kind: 'result', status: 'completed', subagent: { id: 501, sortOrder: 0, name: 'worker-a', goal: 'task-1', status: 'completed', summary: 'done', error: null, }, }, }); params.subagents.onDone({ batchId: 'batch-1', sessionId: 'session-1', parentMessageId: 202, results: [ { id: 501, sortOrder: 0, name: 'worker-a', goal: 'task-1', status: 'completed', summary: 'done', error: null, }, { id: 502, sortOrder: 1, name: 'worker-b', goal: 'task-2', status: 'failed', summary: '', error: 'boom', }, ], }); return { finalContent: 'final answer', toolCallCount: 1, metadata: { existing: true, }, }; }, }); expect(sessionService.updateMessage).toHaveBeenCalledWith(202, { content: 'final answer', thinking: undefined, metadata: expect.objectContaining({ existing: true, inFlight: false, subagentBatches: [ expect.objectContaining({ batchId: 'batch-1', sessionId: 'session-1', parentMessageId: 202, mode: 'parallel', tasks: [ { goal: 'task-1', agentName: 'worker-a' }, { goal: 'task-2', agentName: 'worker-b' }, ], status: 'completed', results: [ { id: 501, sortOrder: 0, name: 'worker-a', goal: 'task-1', status: 'completed', summary: 'done', error: null, }, { id: 502, sortOrder: 1, name: 'worker-b', goal: 'task-2', status: 'failed', summary: '', error: 'boom', }, ], metadata: expect.objectContaining({ events: expect.arrayContaining([ expect.objectContaining({ sequence: 1, timestamp: '2026-04-19T00:00:01.000Z', event: expect.objectContaining({ kind: 'result', status: 'completed', subagent: expect.objectContaining({ id: 501, summary: 'done', }), }), }), expect.objectContaining({ event: expect.objectContaining({ kind: 'status', status: 'completed', }), }), ]), latestEvent: expect.objectContaining({ event: expect.objectContaining({ kind: 'status', status: 'completed', }), }), finalResults: [ expect.objectContaining({ id: 501, summary: 'done', }), expect.objectContaining({ id: 502, error: 'boom', }), ], }), }), ], }), }); expect(result).toEqual({ sessionId: 'session-1', finalContent: 'final answer', content: 'final answer', usage: undefined, toolCallCount: 1, assistantMessageId: 202, subagentBatches: [ expect.objectContaining({ batchId: 'batch-1', sessionId: 'session-1', parentMessageId: 202, mode: 'parallel', tasks: [ { goal: 'task-1', agentName: 'worker-a' }, { goal: 'task-2', agentName: 'worker-b' }, ], status: 'completed', results: [ { id: 501, sortOrder: 0, name: 'worker-a', goal: 'task-1', status: 'completed', summary: 'done', error: null, }, { id: 502, sortOrder: 1, name: 'worker-b', goal: 'task-2', status: 'failed', summary: '', error: 'boom', }, ], metadata: expect.objectContaining({ events: expect.any(Array), latestEvent: expect.any(Object), finalResults: expect.any(Array), }), }), ], }); }); it('marks the assistant placeholder as failed before rethrowing the runner error', async () => { const runnerError = new Error('runner failed'); const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }), updateMessage: jest.fn().mockResolvedValue(undefined), } as unknown as NetaClawSessionService; const service = new NetaClawChatOrchestratorService(); service.sessionService = sessionService; await expect( service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'hello', runner: jest.fn().mockRejectedValue(runnerError), }) ).rejects.toThrow('runner failed'); expect(sessionService.updateMessage).toHaveBeenCalledWith(202, { content: 'runner failed', metadata: { inFlight: false, error: { message: 'runner failed', name: 'Error', }, }, }); }); it('rethrows the original runner error when placeholder cleanup also fails', async () => { const runnerError = new Error('runner failed'); const cleanupError = new Error('cleanup failed'); const sessionService = { getOrCreateSession: jest.fn().mockResolvedValue('session-1'), loadHistoryWithId: jest.fn().mockResolvedValue([{ id: 101, role: 'user', content: 'hello' }]), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 101, sessionId: 'session-1', role: 'user', content: 'hello' }) .mockResolvedValueOnce({ id: 202, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }), updateMessage: jest.fn().mockRejectedValue(cleanupError), } as unknown as NetaClawSessionService; const logger = { error: jest.fn(), } as any; const service = new NetaClawChatOrchestratorService() as NetaClawChatOrchestratorService & { logger?: { error: jest.Mock } }; service.sessionService = sessionService; service.logger = logger; await expect( service.executeChat({ sessionId: 'session-1', requestedAgentName: 'default', message: 'hello', runner: jest.fn().mockRejectedValue(runnerError), }) ).rejects.toThrow('runner failed'); expect(logger.error).toHaveBeenCalledWith( '[NetaClaw] failed to persist assistant placeholder cleanup: %s', 'cleanup failed' ); }); }); describe('NetaClawSessionService message persistence', () => { let service: NetaClawSessionService; beforeEach(() => { service = new NetaClawSessionService(); }); it('returns the saved message entity and updates it by id', async () => { const savedEntity = { id: 88, sessionId: 'session-1', role: 'assistant', content: '', metadata: { inFlight: true }, }; service.messageRepo = { save: jest.fn().mockResolvedValue(savedEntity), update: jest.fn().mockResolvedValue(undefined), } as any; const result = await service.saveMessage('session-1', { role: 'assistant', content: '', metadata: { inFlight: true }, }); expect(service.messageRepo.save).toHaveBeenCalledWith({ sessionId: 'session-1', role: 'assistant', content: '', thinking: undefined, toolCalls: undefined, toolCallId: undefined, skillName: undefined, metadata: { inFlight: true }, }); expect(result).toBe(savedEntity); await service.updateMessage(88, { content: 'final answer', metadata: { inFlight: false, source: 'agent' }, }); expect(service.messageRepo.update).toHaveBeenCalledWith( { id: 88 }, { content: 'final answer', metadata: { inFlight: false, source: 'agent' }, } ); }); });