import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; import { logger } from '../../../src/utils/logger.js'; // Mock modules that cause import chain issues - MUST be before imports // Use full paths from test file location mock.module('../../../src/services/worker-service.js', () => ({ updateCursorContextForProject: () => Promise.resolve(), })); mock.module('../../../src/shared/worker-utils.js', () => ({ getWorkerPort: () => 37777, })); // Mock the ModeManager mock.module('../../../src/services/domain/ModeManager.js', () => ({ ModeManager: { getInstance: () => ({ getActiveMode: () => ({ name: 'code', prompts: { init: 'init prompt', observation: 'obs prompt', summary: 'summary prompt', }, observation_types: [{ id: 'discovery' }, { id: 'bugfix' }, { id: 'refactor' }], observation_concepts: [], }), }), }, })); // Import after mocks import { processAgentResponse } from '../../../src/services/worker/agents/ResponseProcessor.js'; import type { WorkerRef, StorageResult } from '../../../src/services/worker/agents/types.js'; import type { ActiveSession } from '../../../src/services/worker-types.js'; import type { DatabaseManager } from '../../../src/services/worker/DatabaseManager.js'; import type { SessionManager } from '../../../src/services/worker/SessionManager.js'; // Spy on logger methods to suppress output during tests let loggerSpies: ReturnType[] = []; describe('ResponseProcessor', () => { // Mocks let mockStoreObservations: ReturnType; let mockChromaSyncObservation: ReturnType; let mockChromaSyncSummary: ReturnType; let mockBroadcast: ReturnType; let mockBroadcastProcessingStatus: ReturnType; let mockDbManager: DatabaseManager; let mockSessionManager: SessionManager; let mockWorker: WorkerRef; beforeEach(() => { // Spy on logger to suppress output loggerSpies = [ spyOn(logger, 'info').mockImplementation(() => {}), spyOn(logger, 'debug').mockImplementation(() => {}), spyOn(logger, 'warn').mockImplementation(() => {}), spyOn(logger, 'error').mockImplementation(() => {}), ]; // Create fresh mocks for each test mockStoreObservations = mock(() => ({ observationIds: [1, 2], summaryId: 1, createdAtEpoch: 1700000000000, } as StorageResult)); mockChromaSyncObservation = mock(() => Promise.resolve()); mockChromaSyncSummary = mock(() => Promise.resolve()); mockDbManager = { getSessionStore: () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), // FK fix (Issue #846) getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), // FK fix (Issue #846) }), getChromaSync: () => ({ syncObservation: mockChromaSyncObservation, syncSummary: mockChromaSyncSummary, }), } as unknown as DatabaseManager; mockSessionManager = { getMessageIterator: async function* () { yield* []; }, getPendingMessageStore: () => ({ markProcessed: mock(() => {}), confirmProcessed: mock(() => {}), // CLAIM-CONFIRM pattern: confirm after successful storage cleanupProcessed: mock(() => 0), resetStuckMessages: mock(() => 0), }), } as unknown as SessionManager; mockBroadcast = mock(() => {}); mockBroadcastProcessingStatus = mock(() => {}); mockWorker = { sseBroadcaster: { broadcast: mockBroadcast, }, broadcastProcessingStatus: mockBroadcastProcessingStatus, }; }); afterEach(() => { loggerSpies.forEach(spy => spy.mockRestore()); mock.restore(); }); // Helper to create mock session function createMockSession( overrides: Partial = {} ): ActiveSession { return { sessionDbId: 1, contentSessionId: 'content-session-123', memorySessionId: 'memory-session-456', project: 'test-project', userPrompt: 'Test prompt', pendingMessages: [], abortController: new AbortController(), generatorPromise: null, lastPromptNumber: 5, startTime: Date.now(), cumulativeInputTokens: 100, cumulativeOutputTokens: 50, earliestPendingTimestamp: Date.now() - 10000, conversationHistory: [], currentProvider: 'claude', processingMessageIds: [], // CLAIM-CONFIRM pattern: track message IDs being processed ...overrides, }; } describe('parsing observations from XML response', () => { it('should parse single observation from response', async () => { const session = createMockSession(); const responseText = ` discovery Found important pattern In auth module Discovered reusable authentication pattern. Uses JWT authentication src/auth.ts `; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(mockStoreObservations).toHaveBeenCalledTimes(1); const [memorySessionId, project, observations, summary] = mockStoreObservations.mock.calls[0]; expect(memorySessionId).toBe('memory-session-456'); expect(project).toBe('test-project'); expect(observations).toHaveLength(1); expect(observations[0].type).toBe('discovery'); expect(observations[0].title).toBe('Found important pattern'); }); it('should parse multiple observations from response', async () => { const session = createMockSession(); const responseText = ` discovery First discovery First narrative bugfix Fixed null pointer Second narrative `; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); const [, , observations] = mockStoreObservations.mock.calls[0]; expect(observations).toHaveLength(2); expect(observations[0].type).toBe('discovery'); expect(observations[1].type).toBe('bugfix'); }); }); describe('non-XML observer responses', () => { it('warns when the observer returns prose that will be discarded', async () => { const session = createMockSession(); const responseText = 'Skipping — repeated log scan with no new findings.'; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(logger.warn).toHaveBeenCalledWith( 'PARSER', 'TestAgent returned non-XML response; observation content was discarded', expect.objectContaining({ sessionId: 1, preview: responseText }) ); const [, , observations, summary] = mockStoreObservations.mock.calls[0]; expect(observations).toHaveLength(0); expect(summary).toBeNull(); }); }); describe('parsing summary from XML response', () => { it('should parse summary from response', async () => { const session = createMockSession(); const responseText = ` discovery Test Build login form Reviewed existing forms React Hook Form works well Form skeleton created Add validation Some notes `; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); const [, , , summary] = mockStoreObservations.mock.calls[0]; expect(summary).not.toBeNull(); expect(summary.request).toBe('Build login form'); expect(summary.investigated).toBe('Reviewed existing forms'); expect(summary.learned).toBe('React Hook Form works well'); }); it('should handle response without summary', async () => { const session = createMockSession(); const responseText = ` discovery Test `; // Mock to return result without summary mockStoreObservations = mock(() => ({ observationIds: [1], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); const [, , , summary] = mockStoreObservations.mock.calls[0]; expect(summary).toBeNull(); }); }); describe('atomic database transactions', () => { it('should call storeObservations atomically', async () => { const session = createMockSession(); const responseText = ` discovery Test Test request Test investigated Test learned Test completed Test next steps `; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, 1700000000000, 'TestAgent' ); // Verify storeObservations was called exactly once (atomic) expect(mockStoreObservations).toHaveBeenCalledTimes(1); // Verify all parameters passed correctly const [ memorySessionId, project, observations, summary, promptNumber, tokens, timestamp, ] = mockStoreObservations.mock.calls[0]; expect(memorySessionId).toBe('memory-session-456'); expect(project).toBe('test-project'); expect(observations).toHaveLength(1); expect(summary).not.toBeNull(); expect(promptNumber).toBe(5); expect(tokens).toBe(100); expect(timestamp).toBe(1700000000000); }); }); describe('SSE broadcasting', () => { it('should broadcast observations via SSE', async () => { const session = createMockSession(); const responseText = ` discovery Broadcast Test Testing broadcast Testing SSE broadcast Fact 1 testing test.ts `; // Mock returning single observation ID mockStoreObservations = mock(() => ({ observationIds: [42], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); // Should broadcast observation expect(mockBroadcast).toHaveBeenCalled(); // Find the observation broadcast call const observationCall = mockBroadcast.mock.calls.find( (call: any[]) => call[0].type === 'new_observation' ); expect(observationCall).toBeDefined(); expect(observationCall[0].observation.id).toBe(42); expect(observationCall[0].observation.title).toBe('Broadcast Test'); expect(observationCall[0].observation.type).toBe('discovery'); }); it('should broadcast summary via SSE', async () => { const session = createMockSession(); const responseText = ` discovery Test Build feature Reviewed code Found patterns Feature built Add tests `; await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); // Find the summary broadcast call const summaryCall = mockBroadcast.mock.calls.find( (call: any[]) => call[0].type === 'new_summary' ); expect(summaryCall).toBeDefined(); expect(summaryCall[0].summary.request).toBe('Build feature'); }); }); describe('handling empty response', () => { it('should handle empty response gracefully', async () => { const session = createMockSession(); const responseText = ''; // Mock to handle empty observations mockStoreObservations = mock(() => ({ observationIds: [], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); // Should still call storeObservations with empty arrays expect(mockStoreObservations).toHaveBeenCalledTimes(1); const [, , observations, summary] = mockStoreObservations.mock.calls[0]; expect(observations).toHaveLength(0); expect(summary).toBeNull(); }); it('should handle response with only text (no XML)', async () => { const session = createMockSession(); const responseText = 'This is just plain text without any XML tags.'; mockStoreObservations = mock(() => ({ observationIds: [], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(mockStoreObservations).toHaveBeenCalledTimes(1); const [, , observations] = mockStoreObservations.mock.calls[0]; expect(observations).toHaveLength(0); }); }); describe('session cleanup', () => { it('should reset earliestPendingTimestamp after processing', async () => { const session = createMockSession({ earliestPendingTimestamp: 1700000000000, }); const responseText = ` discovery Test `; mockStoreObservations = mock(() => ({ observationIds: [1], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(session.earliestPendingTimestamp).toBeNull(); }); it('should call broadcastProcessingStatus after processing', async () => { const session = createMockSession(); const responseText = ` discovery Test `; mockStoreObservations = mock(() => ({ observationIds: [1], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(mockBroadcastProcessingStatus).toHaveBeenCalled(); }); }); describe('conversation history', () => { it('should add assistant response to conversation history', async () => { const session = createMockSession({ conversationHistory: [], }); const responseText = ` discovery Test `; mockStoreObservations = mock(() => ({ observationIds: [1], summaryId: null, createdAtEpoch: 1700000000000, })); (mockDbManager.getSessionStore as any) = () => ({ storeObservations: mockStoreObservations, ensureMemorySessionIdRegistered: mock(() => {}), getSessionById: mock(() => ({ memory_session_id: 'memory-session-456' })), }); await processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ); expect(session.conversationHistory).toHaveLength(1); expect(session.conversationHistory[0].role).toBe('assistant'); expect(session.conversationHistory[0].content).toBe(responseText); }); }); describe('error handling', () => { it('should throw error if memorySessionId is missing from session', async () => { const session = createMockSession({ memorySessionId: null, // Missing memory session ID }); const responseText = 'discovery'; await expect( processAgentResponse( responseText, session, mockDbManager, mockSessionManager, mockWorker, 100, null, 'TestAgent' ) ).rejects.toThrow('Cannot store observations: memorySessionId not yet captured'); }); }); });