- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
/**
|
|
* EventBus Integration Tests
|
|
*
|
|
* Tests the full flow of events through the EventBus to stores:
|
|
* Event → EventBus → Handlers → Store Actions → State Updates
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { ClientEventBus } from './EventBus.js';
|
|
import { setupEventHandlers } from './handlers.js';
|
|
import { useChatStore } from '../stores/chatStore.js';
|
|
import { useAgentStore } from '../stores/agentStore.js';
|
|
import { ApprovalType, ApprovalStatus } from '@dexto/core';
|
|
|
|
describe('EventBus Integration', () => {
|
|
let bus: ClientEventBus;
|
|
let cleanup: () => void;
|
|
|
|
beforeEach(() => {
|
|
bus = new ClientEventBus();
|
|
cleanup = setupEventHandlers(bus);
|
|
|
|
// Reset stores to clean state
|
|
useChatStore.setState({ sessions: new Map() });
|
|
useAgentStore.setState({
|
|
status: 'idle',
|
|
connectionStatus: 'disconnected',
|
|
lastHeartbeat: null,
|
|
activeSessionId: null,
|
|
currentToolName: null,
|
|
connectionError: null,
|
|
reconnectAttempts: 0,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
// =========================================================================
|
|
// LLM Events
|
|
// =========================================================================
|
|
|
|
describe('LLM Events', () => {
|
|
it('should process llm:thinking and update stores', () => {
|
|
bus.dispatch({
|
|
name: 'llm:thinking',
|
|
sessionId: 'test-session',
|
|
});
|
|
|
|
// Check agent status
|
|
expect(useAgentStore.getState().status).toBe('thinking');
|
|
expect(useAgentStore.getState().activeSessionId).toBe('test-session');
|
|
|
|
// Check chat processing state
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.processing).toBe(true);
|
|
});
|
|
|
|
it('should process llm:chunk and create streaming message', () => {
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: 'Hello',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.streamingMessage).not.toBeNull();
|
|
expect(sessionState.streamingMessage?.content).toBe('Hello');
|
|
expect(sessionState.streamingMessage?.role).toBe('assistant');
|
|
});
|
|
|
|
it('should append chunks to streaming message', () => {
|
|
// First chunk
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: 'Hello',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
// Second chunk
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: ' world',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.streamingMessage?.content).toBe('Hello world');
|
|
});
|
|
|
|
it('should handle reasoning chunks separately', () => {
|
|
// Text chunk
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: 'Answer',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
// Reasoning chunk
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: 'Thinking...',
|
|
chunkType: 'reasoning',
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.streamingMessage?.content).toBe('Answer');
|
|
expect(sessionState.streamingMessage?.reasoning).toBe('Thinking...');
|
|
});
|
|
|
|
it('should finalize streaming message on llm:response', () => {
|
|
// Create streaming message
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'test-session',
|
|
content: 'Complete response',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
// Finalize
|
|
bus.dispatch({
|
|
name: 'llm:response',
|
|
sessionId: 'test-session',
|
|
content: 'Complete response',
|
|
model: 'gpt-4',
|
|
provider: 'openai',
|
|
tokenUsage: {
|
|
inputTokens: 10,
|
|
outputTokens: 20,
|
|
totalTokens: 30,
|
|
},
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.streamingMessage).toBeNull();
|
|
expect(sessionState.messages).toHaveLength(1);
|
|
expect(sessionState.messages[0].content).toBe('Complete response');
|
|
expect(sessionState.messages[0].model).toBe('gpt-4');
|
|
expect(sessionState.messages[0].tokenUsage?.totalTokens).toBe(30);
|
|
});
|
|
|
|
it('should handle llm:error and update stores', () => {
|
|
bus.dispatch({
|
|
name: 'llm:error',
|
|
sessionId: 'test-session',
|
|
error: {
|
|
name: 'TestError',
|
|
message: 'Something went wrong',
|
|
},
|
|
context: 'During generation',
|
|
recoverable: true,
|
|
});
|
|
|
|
// Check agent status
|
|
expect(useAgentStore.getState().status).toBe('idle');
|
|
|
|
// Check error state
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.error).not.toBeNull();
|
|
expect(sessionState.error?.message).toBe('Something went wrong');
|
|
expect(sessionState.error?.recoverable).toBe(true);
|
|
expect(sessionState.processing).toBe(false);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Tool Events
|
|
// =========================================================================
|
|
|
|
describe('Tool Events', () => {
|
|
it('should process llm:tool-call and create tool message', () => {
|
|
bus.dispatch({
|
|
name: 'llm:tool-call',
|
|
sessionId: 'test-session',
|
|
toolName: 'read_file',
|
|
args: { path: '/test.txt' },
|
|
callId: 'call-123',
|
|
});
|
|
|
|
// Check agent status
|
|
expect(useAgentStore.getState().status).toBe('executing_tool');
|
|
expect(useAgentStore.getState().currentToolName).toBe('read_file');
|
|
|
|
// Check tool message
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.messages).toHaveLength(1);
|
|
expect(sessionState.messages[0].toolName).toBe('read_file');
|
|
expect(sessionState.messages[0].toolCallId).toBe('call-123');
|
|
});
|
|
|
|
it('should update tool message with result', () => {
|
|
// Create tool call
|
|
bus.dispatch({
|
|
name: 'llm:tool-call',
|
|
sessionId: 'test-session',
|
|
toolName: 'read_file',
|
|
args: { path: '/test.txt' },
|
|
callId: 'call-123',
|
|
});
|
|
|
|
// Add result
|
|
const sanitizedResult = {
|
|
content: [{ type: 'text' as const, text: 'File contents' }],
|
|
meta: { toolName: 'read_file', toolCallId: 'call-123', success: true },
|
|
};
|
|
bus.dispatch({
|
|
name: 'llm:tool-result',
|
|
sessionId: 'test-session',
|
|
toolName: 'read_file',
|
|
callId: 'call-123',
|
|
success: true,
|
|
sanitized: sanitizedResult,
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
const toolMessage = sessionState.messages[0];
|
|
expect(toolMessage.toolResult).toEqual(sanitizedResult);
|
|
expect(toolMessage.toolResultSuccess).toBe(true);
|
|
});
|
|
|
|
it('should handle tool result with approval requirements', () => {
|
|
// Create tool call
|
|
bus.dispatch({
|
|
name: 'llm:tool-call',
|
|
sessionId: 'test-session',
|
|
toolName: 'write_file',
|
|
args: { path: '/test.txt', content: 'data' },
|
|
callId: 'call-456',
|
|
});
|
|
|
|
// Add result with approval
|
|
const sanitizedResult = {
|
|
content: [{ type: 'text' as const, text: 'File written' }],
|
|
meta: { toolName: 'write_file', toolCallId: 'call-456', success: true },
|
|
};
|
|
bus.dispatch({
|
|
name: 'llm:tool-result',
|
|
sessionId: 'test-session',
|
|
toolName: 'write_file',
|
|
callId: 'call-456',
|
|
success: true,
|
|
sanitized: sanitizedResult,
|
|
requireApproval: true,
|
|
approvalStatus: 'approved',
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
const toolMessage = sessionState.messages[0];
|
|
expect(toolMessage.requireApproval).toBe(true);
|
|
expect(toolMessage.approvalStatus).toBe('approved');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Approval Events
|
|
// =========================================================================
|
|
|
|
describe('Approval Events', () => {
|
|
it('should process approval:request', () => {
|
|
bus.dispatch({
|
|
name: 'approval:request',
|
|
sessionId: 'test-session',
|
|
type: ApprovalType.TOOL_CONFIRMATION,
|
|
approvalId: 'approval-123',
|
|
timeout: 30000,
|
|
timestamp: new Date(),
|
|
metadata: {
|
|
toolName: 'write_file',
|
|
toolCallId: 'call-write-123',
|
|
args: { path: '/test.txt' },
|
|
},
|
|
});
|
|
|
|
expect(useAgentStore.getState().status).toBe('awaiting_approval');
|
|
expect(useAgentStore.getState().activeSessionId).toBe('test-session');
|
|
});
|
|
|
|
it('should process approval:response with approved status', () => {
|
|
// Set awaiting approval
|
|
bus.dispatch({
|
|
name: 'approval:request',
|
|
sessionId: 'test-session',
|
|
type: ApprovalType.TOOL_CONFIRMATION,
|
|
approvalId: 'approval-123',
|
|
timeout: 30000,
|
|
timestamp: new Date(),
|
|
metadata: {
|
|
toolName: 'write_file',
|
|
toolCallId: 'call-write-123',
|
|
args: { path: '/test.txt' },
|
|
},
|
|
});
|
|
|
|
// Approve
|
|
bus.dispatch({
|
|
name: 'approval:response',
|
|
sessionId: 'test-session',
|
|
approvalId: 'approval-123',
|
|
status: ApprovalStatus.APPROVED,
|
|
});
|
|
|
|
// Status transitions to 'thinking' - agent is resuming execution after approval
|
|
expect(useAgentStore.getState().status).toBe('thinking');
|
|
});
|
|
|
|
it('should process approval:response with rejected status', () => {
|
|
// Set awaiting approval
|
|
bus.dispatch({
|
|
name: 'approval:request',
|
|
sessionId: 'test-session',
|
|
type: ApprovalType.TOOL_CONFIRMATION,
|
|
approvalId: 'approval-456',
|
|
timeout: 30000,
|
|
timestamp: new Date(),
|
|
metadata: {
|
|
toolName: 'write_file',
|
|
toolCallId: 'call-write-456',
|
|
args: { path: '/test.txt' },
|
|
},
|
|
});
|
|
|
|
// Reject
|
|
bus.dispatch({
|
|
name: 'approval:response',
|
|
sessionId: 'test-session',
|
|
approvalId: 'approval-456',
|
|
status: ApprovalStatus.DENIED,
|
|
});
|
|
|
|
expect(useAgentStore.getState().status).toBe('idle');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Run Events
|
|
// =========================================================================
|
|
|
|
describe('Run Events', () => {
|
|
it('should process run:complete', () => {
|
|
// Set processing state
|
|
useChatStore.getState().setProcessing('test-session', true);
|
|
useAgentStore.getState().setThinking('test-session');
|
|
|
|
// Complete run
|
|
bus.dispatch({
|
|
name: 'run:complete',
|
|
sessionId: 'test-session',
|
|
finishReason: 'stop',
|
|
stepCount: 3,
|
|
durationMs: 1500,
|
|
});
|
|
|
|
// Check states reset
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.processing).toBe(false);
|
|
expect(useAgentStore.getState().status).toBe('idle');
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Message Events
|
|
// =========================================================================
|
|
|
|
describe('Message Events', () => {
|
|
it('should process message:dequeued with text content', () => {
|
|
bus.dispatch({
|
|
name: 'message:dequeued',
|
|
sessionId: 'test-session',
|
|
count: 1,
|
|
ids: ['queued-1'],
|
|
coalesced: false,
|
|
content: [{ type: 'text', text: 'Hello from queue' }],
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.messages).toHaveLength(1);
|
|
expect(sessionState.messages[0].role).toBe('user');
|
|
expect(sessionState.messages[0].content).toBe('Hello from queue');
|
|
});
|
|
|
|
it('should process message:dequeued with image attachment', () => {
|
|
bus.dispatch({
|
|
name: 'message:dequeued',
|
|
sessionId: 'test-session',
|
|
count: 1,
|
|
ids: ['queued-2'],
|
|
coalesced: false,
|
|
content: [
|
|
{ type: 'text', text: 'Check this image' },
|
|
{ type: 'image', image: 'base64data', mimeType: 'image/png' },
|
|
],
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.messages).toHaveLength(1);
|
|
expect(sessionState.messages[0].content).toBe('Check this image');
|
|
expect(sessionState.messages[0].imageData).toEqual({
|
|
image: 'base64data',
|
|
mimeType: 'image/png',
|
|
});
|
|
});
|
|
|
|
it('should process message:dequeued with file attachment', () => {
|
|
bus.dispatch({
|
|
name: 'message:dequeued',
|
|
sessionId: 'test-session',
|
|
count: 1,
|
|
ids: ['queued-3'],
|
|
coalesced: false,
|
|
content: [
|
|
{ type: 'text', text: 'Here is the file' },
|
|
{
|
|
type: 'file',
|
|
data: 'filedata',
|
|
mimeType: 'text/plain',
|
|
filename: 'test.txt',
|
|
},
|
|
],
|
|
});
|
|
|
|
const sessionState = useChatStore.getState().getSessionState('test-session');
|
|
expect(sessionState.messages).toHaveLength(1);
|
|
expect(sessionState.messages[0].fileData).toEqual({
|
|
data: 'filedata',
|
|
mimeType: 'text/plain',
|
|
filename: 'test.txt',
|
|
});
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Multi-Session Support
|
|
// =========================================================================
|
|
|
|
describe('Multi-Session Support', () => {
|
|
it('should handle events for multiple sessions independently', () => {
|
|
// Session 1
|
|
bus.dispatch({
|
|
name: 'llm:thinking',
|
|
sessionId: 'session-1',
|
|
});
|
|
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'session-1',
|
|
content: 'Response 1',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
// Session 2
|
|
bus.dispatch({
|
|
name: 'llm:thinking',
|
|
sessionId: 'session-2',
|
|
});
|
|
|
|
bus.dispatch({
|
|
name: 'llm:chunk',
|
|
sessionId: 'session-2',
|
|
content: 'Response 2',
|
|
chunkType: 'text',
|
|
});
|
|
|
|
// Verify isolation
|
|
const session1 = useChatStore.getState().getSessionState('session-1');
|
|
const session2 = useChatStore.getState().getSessionState('session-2');
|
|
|
|
expect(session1.streamingMessage?.content).toBe('Response 1');
|
|
expect(session2.streamingMessage?.content).toBe('Response 2');
|
|
expect(session1.processing).toBe(true);
|
|
expect(session2.processing).toBe(true);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// Error Handling
|
|
// =========================================================================
|
|
|
|
describe('Error Handling', () => {
|
|
it('should handle unknown events gracefully', () => {
|
|
// Dispatch unknown event (should not throw)
|
|
expect(() => {
|
|
bus.dispatch({
|
|
// @ts-expect-error Testing unknown event
|
|
name: 'unknown:event',
|
|
sessionId: 'test-session',
|
|
});
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle events with missing sessionId', () => {
|
|
// Some events might not have sessionId
|
|
expect(() => {
|
|
bus.dispatch({
|
|
name: 'context:compacted',
|
|
sessionId: 'test-session',
|
|
originalTokens: 1000,
|
|
compactedTokens: 500,
|
|
originalMessages: 10,
|
|
compactedMessages: 5,
|
|
strategy: 'auto',
|
|
reason: 'overflow',
|
|
});
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
});
|