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/core/worker-service.js', () => ({
updateCursorContextForProject: () => Promise.resolve(),
}));
mock.module('../../../src/common/engine-utils.js', () => ({
getWorkerPort: () => 37777,
}));
// Mock the ModeManager
mock.module('../../../src/core/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/core/worker/agents/ResponseProcessor.js';
import type { WorkerRef, StorageResult } from '../../../src/core/worker/agents/types.js';
import type { ActiveSession } from '../../../src/core/worker-types.js';
import type { DatabaseManager } from '../../../src/core/worker/DatabaseManager.js';
import type { SessionManager } from '../../../src/core/worker/SessionManager.js';
// Spy on logger methods to suppress output during tests
let loggerSpies: ReturnType<typeof spyOn>[] = [];
describe('ResponseProcessor', () => {
// Mocks
let mockStoreObservations: ReturnType<typeof mock>;
let mockChromaSyncObservation: ReturnType<typeof mock>;
let mockChromaSyncSummary: ReturnType<typeof mock>;
let mockBroadcast: ReturnType<typeof mock>;
let mockBroadcastProcessingStatus: ReturnType<typeof mock>;
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,
}),
getChromaSync: () => ({
syncObservation: mockChromaSyncObservation,
syncSummary: mockChromaSyncSummary,
}),
} as unknown as DatabaseManager;
mockSessionManager = {
getMessageIterator: async function* () {
yield* [];
},
getPendingMessageStore: () => ({
markProcessed: mock(() => {}),
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> = {}
): 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',
...overrides,
};
}
describe('parsing observations from XML response', () => {
it('should parse single observation from response', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Found important pattern</title>
<subtitle>In auth module</subtitle>
<narrative>Discovered reusable authentication pattern.</narrative>
<facts><fact>Uses JWT</fact></facts>
<concepts><concept>authentication</concept></concepts>
<files_read><file>src/auth.ts</file></files_read>
<files_modified></files_modified>
</observation>
`;
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 = `
<observation>
<type>discovery</type>
<title>First discovery</title>
<narrative>First narrative</narrative>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<observation>
<type>bugfix</type>
<title>Fixed null pointer</title>
<narrative>Second narrative</narrative>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
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('parsing summary from XML response', () => {
it('should parse summary from response', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Build login form</request>
<investigated>Reviewed existing forms</investigated>
<learned>React Hook Form works well</learned>
<completed>Form skeleton created</completed>
<next_steps>Add validation</next_steps>
<notes>Some notes</notes>
</summary>
`;
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
// Mock to return result without summary
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Test request</request>
<investigated>Test investigated</investigated>
<learned>Test learned</learned>
<completed>Test completed</completed>
<next_steps>Test next steps</next_steps>
</summary>
`;
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 = `
<observation>
<type>discovery</type>
<title>Broadcast Test</title>
<subtitle>Testing broadcast</subtitle>
<narrative>Testing SSE broadcast</narrative>
<facts><fact>Fact 1</fact></facts>
<concepts><concept>testing</concept></concepts>
<files_read><file>test.ts</file></files_read>
<files_modified></files_modified>
</observation>
`;
// Mock returning single observation ID
mockStoreObservations = mock(() => ({
observationIds: [42],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Build feature</request>
<investigated>Reviewed code</investigated>
<learned>Found patterns</learned>
<completed>Feature built</completed>
<next_steps>Add tests</next_steps>
</summary>
`;
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,
});
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,
});
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
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 = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
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', async () => {
const session = createMockSession({
memorySessionId: null, // Missing memory session ID
});
const responseText = '<observation><type>discovery</type></observation>';
await expect(
processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
)
).rejects.toThrow('Cannot store observations: memorySessionId not yet captured');
});
});
});