Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
/**
|
|
* OutputHandler Tests
|
|
*
|
|
* Test suite for the OutputHandler class, specifically focusing on
|
|
* question parsing and agent completion handling.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { OutputHandler } from './output-handler.js';
|
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
|
import type { EventBus, DomainEvent, AgentWaitingEvent } from '../events/types.js';
|
|
import { getProvider } from './providers/registry.js';
|
|
|
|
// =============================================================================
|
|
// Test Helpers
|
|
// =============================================================================
|
|
|
|
function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
|
|
const emittedEvents: DomainEvent[] = [];
|
|
|
|
const mockBus = {
|
|
emittedEvents,
|
|
emit: vi.fn().mockImplementation(<T extends DomainEvent>(event: T): void => {
|
|
emittedEvents.push(event);
|
|
}),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
once: vi.fn(),
|
|
};
|
|
|
|
return mockBus;
|
|
}
|
|
|
|
function createMockAgentRepository() {
|
|
return {
|
|
findById: vi.fn(),
|
|
update: vi.fn(),
|
|
create: vi.fn(),
|
|
findByName: vi.fn(),
|
|
findByStatus: vi.fn(),
|
|
findAll: vi.fn(),
|
|
delete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('OutputHandler', () => {
|
|
let outputHandler: OutputHandler;
|
|
let mockAgentRepo: ReturnType<typeof createMockAgentRepository>;
|
|
let eventBus: ReturnType<typeof createMockEventBus>;
|
|
|
|
const mockAgent = {
|
|
id: 'agent-123',
|
|
name: 'test-agent',
|
|
taskId: 'task-456',
|
|
sessionId: 'session-789',
|
|
provider: 'claude',
|
|
mode: 'refine',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockAgentRepo = createMockAgentRepository();
|
|
eventBus = createMockEventBus();
|
|
|
|
outputHandler = new OutputHandler(
|
|
mockAgentRepo as any,
|
|
eventBus,
|
|
);
|
|
|
|
// Setup default mock behavior
|
|
mockAgentRepo.findById.mockResolvedValue(mockAgent);
|
|
});
|
|
|
|
describe('processAgentOutput', () => {
|
|
it('should correctly parse and handle questions from Claude CLI output', async () => {
|
|
// Arrange: Create realistic Claude CLI output with questions (like fantastic-crane)
|
|
const questionsResult = {
|
|
status: "questions",
|
|
questions: [
|
|
{
|
|
id: "q1",
|
|
question: "What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)"
|
|
},
|
|
{
|
|
id: "q2",
|
|
question: "What does 'modern look' mean for you? (e.g., dark mode support, specific color scheme, animations)"
|
|
},
|
|
{
|
|
id: "q3",
|
|
question: "Are there any specific shadcn components you want to use or prioritize?"
|
|
}
|
|
]
|
|
};
|
|
|
|
const claudeOutput = JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
is_error: false,
|
|
session_id: "test-session-123",
|
|
result: JSON.stringify(questionsResult),
|
|
total_cost_usd: 0.05
|
|
});
|
|
|
|
const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir');
|
|
const provider = getProvider('claude')!;
|
|
|
|
// Act
|
|
await outputHandler.processAgentOutput(
|
|
mockAgent.id,
|
|
claudeOutput,
|
|
provider,
|
|
getAgentWorkdir
|
|
);
|
|
|
|
// Assert: Agent should be updated with questions and waiting_for_input status
|
|
expect(mockAgentRepo.update).toHaveBeenCalledWith(mockAgent.id, {
|
|
pendingQuestions: JSON.stringify({
|
|
questions: [
|
|
{
|
|
id: 'q1',
|
|
question: 'What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)'
|
|
},
|
|
{
|
|
id: 'q2',
|
|
question: 'What does \'modern look\' mean for you? (e.g., dark mode support, specific color scheme, animations)'
|
|
},
|
|
{
|
|
id: 'q3',
|
|
question: 'Are there any specific shadcn components you want to use or prioritize?'
|
|
}
|
|
]
|
|
}),
|
|
status: 'waiting_for_input'
|
|
});
|
|
|
|
// Should be called at least once (could be once or twice depending on session ID extraction)
|
|
expect(mockAgentRepo.update).toHaveBeenCalledTimes(1);
|
|
|
|
// Assert: AgentWaitingEvent should be emitted
|
|
const waitingEvents = eventBus.emittedEvents.filter(e => e.type === 'agent:waiting') as AgentWaitingEvent[];
|
|
expect(waitingEvents).toHaveLength(1);
|
|
expect(waitingEvents[0].payload.questions).toEqual([
|
|
{
|
|
id: 'q1',
|
|
question: 'What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)'
|
|
},
|
|
{
|
|
id: 'q2',
|
|
question: 'What does \'modern look\' mean for you? (e.g., dark mode support, specific color scheme, animations)'
|
|
},
|
|
{
|
|
id: 'q3',
|
|
question: 'Are there any specific shadcn components you want to use or prioritize?'
|
|
}
|
|
]);
|
|
});
|
|
|
|
it('should handle malformed questions gracefully', async () => {
|
|
// Arrange: Create output with malformed questions JSON
|
|
const malformedOutput = JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
is_error: false,
|
|
session_id: "test-session",
|
|
result: '{"status": "questions", "questions": [malformed json]}',
|
|
total_cost_usd: 0.05
|
|
});
|
|
|
|
const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir');
|
|
const provider = getProvider('claude')!;
|
|
|
|
// Act & Assert: Should not throw, should handle error gracefully
|
|
await expect(
|
|
outputHandler.processAgentOutput(
|
|
mockAgent.id,
|
|
malformedOutput,
|
|
provider,
|
|
getAgentWorkdir
|
|
)
|
|
).resolves.not.toThrow();
|
|
|
|
// Should update status to crashed due to malformed JSON
|
|
const updateCalls = mockAgentRepo.update.mock.calls;
|
|
const crashedCall = updateCalls.find(call => call[1]?.status === 'crashed');
|
|
expect(crashedCall).toBeDefined();
|
|
});
|
|
|
|
it('should correctly handle "done" status without questions', async () => {
|
|
// Arrange: Create output with done status
|
|
const doneOutput = JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
is_error: false,
|
|
session_id: "test-session",
|
|
result: JSON.stringify({
|
|
status: "done",
|
|
message: "Task completed successfully"
|
|
}),
|
|
total_cost_usd: 0.05
|
|
});
|
|
|
|
const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir');
|
|
const provider = getProvider('claude')!;
|
|
|
|
// Act
|
|
await outputHandler.processAgentOutput(
|
|
mockAgent.id,
|
|
doneOutput,
|
|
provider,
|
|
getAgentWorkdir
|
|
);
|
|
|
|
// Assert: Should not set waiting_for_input status or pendingQuestions
|
|
const updateCalls = mockAgentRepo.update.mock.calls;
|
|
const waitingCall = updateCalls.find(call => call[1]?.status === 'waiting_for_input');
|
|
expect(waitingCall).toBeUndefined();
|
|
|
|
const questionsCall = updateCalls.find(call => call[1]?.pendingQuestions);
|
|
expect(questionsCall).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getPendingQuestions', () => {
|
|
it('should retrieve and parse stored pending questions', async () => {
|
|
// Arrange
|
|
const questionsPayload = {
|
|
questions: [
|
|
{ id: 'q1', question: 'Test question 1?' },
|
|
{ id: 'q2', question: 'Test question 2?' }
|
|
]
|
|
};
|
|
|
|
mockAgentRepo.findById.mockResolvedValue({
|
|
...mockAgent,
|
|
pendingQuestions: JSON.stringify(questionsPayload)
|
|
});
|
|
|
|
// Act
|
|
const result = await outputHandler.getPendingQuestions(mockAgent.id);
|
|
|
|
// Assert
|
|
expect(result).toEqual(questionsPayload);
|
|
expect(mockAgentRepo.findById).toHaveBeenCalledWith(mockAgent.id);
|
|
});
|
|
|
|
it('should return null when no pending questions exist', async () => {
|
|
// Arrange
|
|
mockAgentRepo.findById.mockResolvedValue({
|
|
...mockAgent,
|
|
pendingQuestions: null
|
|
});
|
|
|
|
// Act
|
|
const result = await outputHandler.getPendingQuestions(mockAgent.id);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// formatAnswersAsPrompt Tests
|
|
// =============================================================================
|
|
|
|
describe('formatAnswersAsPrompt', () => {
|
|
it('should format normal answers correctly', () => {
|
|
const answers = {
|
|
'q1': 'The admin UI has tables and forms',
|
|
'q2': 'Modern means dark mode and clean aesthetics'
|
|
};
|
|
|
|
const result = outputHandler.formatAnswersAsPrompt(answers);
|
|
|
|
expect(result).toBe(
|
|
'Here are my answers to your questions:\n' +
|
|
'[q1]: The admin UI has tables and forms\n' +
|
|
'[q2]: Modern means dark mode and clean aesthetics'
|
|
);
|
|
});
|
|
|
|
it('should handle instruction-enhanced answers for retry scenarios', () => {
|
|
const answers = {
|
|
'q1': 'Fix the authentication bug',
|
|
'__instruction__': 'IMPORTANT: Create a signal.json file when done'
|
|
};
|
|
|
|
const result = outputHandler.formatAnswersAsPrompt(answers);
|
|
|
|
expect(result).toBe(
|
|
'IMPORTANT: Create a signal.json file when done\n\n' +
|
|
'Here are my answers to your questions:\n' +
|
|
'[q1]: Fix the authentication bug'
|
|
);
|
|
});
|
|
|
|
it('should handle instruction with whitespace correctly', () => {
|
|
const answers = {
|
|
'q1': 'Complete the task',
|
|
'__instruction__': ' \n Some instruction with whitespace \n '
|
|
};
|
|
|
|
const result = outputHandler.formatAnswersAsPrompt(answers);
|
|
|
|
expect(result).toBe(
|
|
'Some instruction with whitespace\n\n' +
|
|
'Here are my answers to your questions:\n' +
|
|
'[q1]: Complete the task'
|
|
);
|
|
});
|
|
|
|
it('should work with only instruction and no real answers', () => {
|
|
const answers = {
|
|
'__instruction__': 'Retry with this instruction'
|
|
};
|
|
|
|
const result = outputHandler.formatAnswersAsPrompt(answers);
|
|
|
|
expect(result).toBe(
|
|
'Retry with this instruction\n\n' +
|
|
'Here are my answers to your questions:\n'
|
|
);
|
|
});
|
|
|
|
it('should work with empty answers object', () => {
|
|
const answers = {};
|
|
|
|
const result = outputHandler.formatAnswersAsPrompt(answers);
|
|
|
|
expect(result).toBe(
|
|
'Here are my answers to your questions:\n'
|
|
);
|
|
});
|
|
});
|
|
}); |