feat: Add intelligent auto-router and enhanced integrations
- 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>
This commit is contained in:
440
dexto/packages/webui/lib/events/middleware/activity.test.ts
Normal file
440
dexto/packages/webui/lib/events/middleware/activity.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Activity Middleware Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { activityMiddleware } from './activity.js';
|
||||
import { useEventLogStore } from '../../stores/eventLogStore.js';
|
||||
import type { ClientEvent } from '../types.js';
|
||||
import { ApprovalType, ApprovalStatus } from '@dexto/core';
|
||||
|
||||
describe('activityMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
// Reset event log store
|
||||
useEventLogStore.setState({ events: [], maxEvents: 1000 });
|
||||
});
|
||||
|
||||
describe('middleware execution', () => {
|
||||
it('should call next() to propagate event', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should call next before logging', () => {
|
||||
const callOrder: string[] = [];
|
||||
const next = vi.fn(() => {
|
||||
callOrder.push('next');
|
||||
});
|
||||
|
||||
const originalAddEvent = useEventLogStore.getState().addEvent;
|
||||
useEventLogStore.setState({
|
||||
addEvent: (event) => {
|
||||
callOrder.push('addEvent');
|
||||
originalAddEvent(event);
|
||||
},
|
||||
});
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
expect(callOrder).toEqual(['next', 'addEvent']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event logging', () => {
|
||||
it('should log llm:thinking event', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].name).toBe('llm:thinking');
|
||||
expect(events[0].category).toBe('agent');
|
||||
expect(events[0].description).toBe('Agent started processing');
|
||||
expect(events[0].sessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('should log llm:chunk with content preview', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:chunk',
|
||||
chunkType: 'text',
|
||||
content: 'This is a long piece of content that should be truncated in the preview',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('agent');
|
||||
expect(events[0].description).toContain('Streaming text:');
|
||||
expect(events[0].description).toContain(
|
||||
'This is a long piece of content that should be tru...'
|
||||
);
|
||||
});
|
||||
|
||||
it('should log llm:response with token count', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Response content',
|
||||
sessionId: 'session-1',
|
||||
tokenUsage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
},
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('agent');
|
||||
expect(events[0].description).toBe('Response complete (150 tokens)');
|
||||
});
|
||||
|
||||
it('should log llm:response without token count', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Response content',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].description).toBe('Response complete');
|
||||
});
|
||||
|
||||
it('should log llm:tool-call with tool name', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:tool-call',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/test.txt' },
|
||||
callId: 'call-123',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('tool');
|
||||
expect(events[0].description).toBe('Calling tool: read_file');
|
||||
});
|
||||
|
||||
it('should log llm:tool-result with success status', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:tool-result',
|
||||
toolName: 'read_file',
|
||||
callId: 'call-123',
|
||||
success: true,
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('tool');
|
||||
expect(events[0].description).toBe('Tool read_file succeeded');
|
||||
});
|
||||
|
||||
it('should log llm:tool-result with failure status', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:tool-result',
|
||||
toolName: 'write_file',
|
||||
callId: 'call-456',
|
||||
success: false,
|
||||
error: 'Permission denied',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].description).toBe('Tool write_file failed');
|
||||
});
|
||||
|
||||
it('should log llm:error with error message', () => {
|
||||
const next = vi.fn();
|
||||
const error = new Error('API rate limit exceeded');
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error,
|
||||
context: 'chat completion',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Error: API rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should log approval:request with tool name', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'approval:request',
|
||||
type: ApprovalType.TOOL_CONFIRMATION,
|
||||
approvalId: '123',
|
||||
timeout: 30000,
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
toolName: 'execute_command',
|
||||
toolCallId: 'call-exec-123',
|
||||
args: { command: 'rm -rf /' },
|
||||
},
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('approval');
|
||||
expect(events[0].description).toBe('Approval requested for execute_command');
|
||||
});
|
||||
|
||||
it('should log approval:response with granted status', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'approval:response',
|
||||
status: ApprovalStatus.APPROVED,
|
||||
approvalId: '123',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].description).toBe('Approval granted');
|
||||
});
|
||||
|
||||
it('should log approval:response with denied status', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'approval:response',
|
||||
status: ApprovalStatus.DENIED,
|
||||
approvalId: '123',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].description).toBe('Approval denied');
|
||||
});
|
||||
|
||||
it('should log run:complete with finish reason', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'run:complete',
|
||||
finishReason: 'stop',
|
||||
stepCount: 5,
|
||||
durationMs: 2000,
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('agent');
|
||||
expect(events[0].description).toBe('Run complete (stop)');
|
||||
});
|
||||
|
||||
it('should log session:title-updated with title', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'session:title-updated',
|
||||
sessionId: 'session-1',
|
||||
title: 'My Conversation',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Session title: "My Conversation"');
|
||||
});
|
||||
|
||||
it('should log message:queued with position', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'message:queued',
|
||||
position: 2,
|
||||
id: 'msg-123',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('user');
|
||||
expect(events[0].description).toBe('Message queued at position 2');
|
||||
});
|
||||
|
||||
it('should log message:dequeued', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'message:dequeued',
|
||||
count: 2,
|
||||
ids: ['msg-1', 'msg-2'],
|
||||
coalesced: true,
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('user');
|
||||
expect(events[0].description).toBe('Queued message processed');
|
||||
});
|
||||
|
||||
it('should log context:compacted with token counts', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'context:compacted',
|
||||
originalTokens: 10000,
|
||||
compactedTokens: 5000,
|
||||
originalMessages: 50,
|
||||
compactedMessages: 25,
|
||||
strategy: 'llm-summary',
|
||||
reason: 'overflow',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Context compacted: 10000 → 5000 tokens');
|
||||
});
|
||||
|
||||
it('should log context:pruned with counts', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'context:pruned',
|
||||
prunedCount: 10,
|
||||
savedTokens: 2000,
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Context pruned: 10 messages, saved 2000 tokens');
|
||||
});
|
||||
|
||||
it('should log connection:status event', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'connection:status',
|
||||
status: 'reconnecting',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Connection reconnecting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown events', () => {
|
||||
it('should log unknown events as system category', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'custom:event' as any,
|
||||
data: 'test',
|
||||
} as any;
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].category).toBe('system');
|
||||
expect(events[0].description).toBe('Unknown event: custom:event');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionId capture', () => {
|
||||
it('should capture sessionId from events that have it', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].sessionId).toBe('session-123');
|
||||
});
|
||||
|
||||
it('should handle events without sessionId', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'connection:status',
|
||||
status: 'connected',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].sessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store full event as metadata', () => {
|
||||
const next = vi.fn();
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:tool-call',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/test.txt', encoding: 'utf-8' },
|
||||
callId: 'call-123',
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
|
||||
activityMiddleware(event, next);
|
||||
|
||||
const { events } = useEventLogStore.getState();
|
||||
expect(events[0].metadata).toEqual({
|
||||
name: 'llm:tool-call',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/test.txt', encoding: 'utf-8' },
|
||||
callId: 'call-123',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
232
dexto/packages/webui/lib/events/middleware/activity.ts
Normal file
232
dexto/packages/webui/lib/events/middleware/activity.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Activity Middleware
|
||||
*
|
||||
* Logs events to the activity log store for debugging and monitoring.
|
||||
* Maps event types to human-readable descriptions and categories.
|
||||
*/
|
||||
|
||||
import type { EventMiddleware, ClientEvent } from '../types.js';
|
||||
import type { StreamingEventName } from '@dexto/core';
|
||||
import { useEventLogStore, type EventCategory } from '../../stores/eventLogStore.js';
|
||||
|
||||
// =============================================================================
|
||||
// Activity Mapping Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Activity mapping for an event
|
||||
*/
|
||||
interface ActivityMapping {
|
||||
/**
|
||||
* Event category
|
||||
*/
|
||||
category: EventCategory;
|
||||
|
||||
/**
|
||||
* Function to generate human-readable description from event
|
||||
*/
|
||||
getDescription: (event: ClientEvent) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of event names to activity mappings
|
||||
*/
|
||||
const activityMappings: Partial<Record<StreamingEventName | string, ActivityMapping>> = {
|
||||
'llm:thinking': {
|
||||
category: 'agent',
|
||||
getDescription: () => 'Agent started processing',
|
||||
},
|
||||
|
||||
'llm:chunk': {
|
||||
category: 'agent',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'llm:chunk') {
|
||||
const preview = e.content?.slice(0, 50) || '';
|
||||
return `Streaming ${e.chunkType}: "${preview}${preview.length >= 50 ? '...' : ''}"`;
|
||||
}
|
||||
return 'Content chunk received';
|
||||
},
|
||||
},
|
||||
|
||||
'llm:response': {
|
||||
category: 'agent',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'llm:response') {
|
||||
const tokens = e.tokenUsage?.totalTokens;
|
||||
return tokens ? `Response complete (${tokens} tokens)` : 'Response complete';
|
||||
}
|
||||
return 'Response complete';
|
||||
},
|
||||
},
|
||||
|
||||
'llm:tool-call': {
|
||||
category: 'tool',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'llm:tool-call') {
|
||||
return `Calling tool: ${e.toolName}`;
|
||||
}
|
||||
return 'Tool call';
|
||||
},
|
||||
},
|
||||
|
||||
'llm:tool-result': {
|
||||
category: 'tool',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'llm:tool-result') {
|
||||
const status = e.success ? 'succeeded' : 'failed';
|
||||
return `Tool ${e.toolName} ${status}`;
|
||||
}
|
||||
return 'Tool result';
|
||||
},
|
||||
},
|
||||
|
||||
'llm:error': {
|
||||
category: 'system',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'llm:error') {
|
||||
return `Error: ${e.error?.message || 'Unknown error'}`;
|
||||
}
|
||||
return 'Error occurred';
|
||||
},
|
||||
},
|
||||
|
||||
'approval:request': {
|
||||
category: 'approval',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'approval:request') {
|
||||
// Tool confirmation requests have toolName in metadata
|
||||
if (e.type === 'tool_confirmation' && 'toolName' in e.metadata) {
|
||||
return `Approval requested for ${e.metadata.toolName}`;
|
||||
}
|
||||
// Command confirmation requests
|
||||
if (e.type === 'command_confirmation' && 'toolName' in e.metadata) {
|
||||
return `Command approval requested for ${e.metadata.toolName}`;
|
||||
}
|
||||
// Generic approval request
|
||||
return `Approval requested (${e.type})`;
|
||||
}
|
||||
return 'Approval requested';
|
||||
},
|
||||
},
|
||||
|
||||
'approval:response': {
|
||||
category: 'approval',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'approval:response') {
|
||||
const statusText =
|
||||
e.status === 'approved'
|
||||
? 'granted'
|
||||
: e.status === 'denied'
|
||||
? 'denied'
|
||||
: 'cancelled';
|
||||
return `Approval ${statusText}`;
|
||||
}
|
||||
return 'Approval response';
|
||||
},
|
||||
},
|
||||
|
||||
'run:complete': {
|
||||
category: 'agent',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'run:complete') {
|
||||
return `Run complete (${e.finishReason})`;
|
||||
}
|
||||
return 'Run complete';
|
||||
},
|
||||
},
|
||||
|
||||
'session:title-updated': {
|
||||
category: 'system',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'session:title-updated') {
|
||||
return `Session title: "${e.title}"`;
|
||||
}
|
||||
return 'Session title updated';
|
||||
},
|
||||
},
|
||||
|
||||
'message:dequeued': {
|
||||
category: 'user',
|
||||
getDescription: () => 'Queued message processed',
|
||||
},
|
||||
|
||||
'message:queued': {
|
||||
category: 'user',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'message:queued') {
|
||||
return `Message queued at position ${e.position}`;
|
||||
}
|
||||
return 'Message queued';
|
||||
},
|
||||
},
|
||||
|
||||
'context:compacted': {
|
||||
category: 'system',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'context:compacted') {
|
||||
return `Context compacted: ${e.originalTokens} → ${e.compactedTokens} tokens`;
|
||||
}
|
||||
return 'Context compacted';
|
||||
},
|
||||
},
|
||||
|
||||
'context:pruned': {
|
||||
category: 'system',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'context:pruned') {
|
||||
return `Context pruned: ${e.prunedCount} messages, saved ${e.savedTokens} tokens`;
|
||||
}
|
||||
return 'Context pruned';
|
||||
},
|
||||
},
|
||||
|
||||
'connection:status': {
|
||||
category: 'system',
|
||||
getDescription: (e) => {
|
||||
if (e.name === 'connection:status') {
|
||||
return `Connection ${e.status}`;
|
||||
}
|
||||
return 'Connection status changed';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Middleware Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Activity logging middleware
|
||||
*
|
||||
* Logs all events to the event log store for debugging and monitoring.
|
||||
* Always calls next() to ensure events continue through the pipeline.
|
||||
*/
|
||||
export const activityMiddleware: EventMiddleware = (event, next) => {
|
||||
// Always call next first to ensure event propagates
|
||||
next(event);
|
||||
|
||||
const { addEvent } = useEventLogStore.getState();
|
||||
const mapping = activityMappings[event.name];
|
||||
|
||||
if (mapping) {
|
||||
// Known event type - use mapping
|
||||
addEvent({
|
||||
name: event.name,
|
||||
category: mapping.category,
|
||||
description: mapping.getDescription(event),
|
||||
timestamp: Date.now(),
|
||||
sessionId: 'sessionId' in event ? event.sessionId : undefined,
|
||||
metadata: { ...event },
|
||||
});
|
||||
} else {
|
||||
// Unknown event type - log with generic description
|
||||
addEvent({
|
||||
name: event.name,
|
||||
category: 'system',
|
||||
description: `Unknown event: ${event.name}`,
|
||||
timestamp: Date.now(),
|
||||
sessionId: 'sessionId' in event ? event.sessionId : undefined,
|
||||
metadata: { ...event },
|
||||
});
|
||||
}
|
||||
};
|
||||
19
dexto/packages/webui/lib/events/middleware/index.ts
Normal file
19
dexto/packages/webui/lib/events/middleware/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Event Bus Middleware
|
||||
*
|
||||
* Export all middleware functions for the client event bus.
|
||||
*/
|
||||
|
||||
export {
|
||||
loggingMiddleware,
|
||||
createLoggingMiddleware,
|
||||
configureLogging,
|
||||
resetLoggingConfig,
|
||||
type LoggingConfig,
|
||||
} from './logging.js';
|
||||
|
||||
export { notificationMiddleware } from './notification.js';
|
||||
export { activityMiddleware } from './activity.js';
|
||||
|
||||
// Future middleware exports:
|
||||
// export { analyticsMiddleware } from './analytics.js';
|
||||
199
dexto/packages/webui/lib/events/middleware/logging.ts
Normal file
199
dexto/packages/webui/lib/events/middleware/logging.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Logging Middleware
|
||||
*
|
||||
* Logs all events for debugging purposes.
|
||||
* Can be enabled/disabled based on environment or flags.
|
||||
*/
|
||||
|
||||
import type { EventMiddleware, ClientEvent } from '../types.js';
|
||||
|
||||
/**
|
||||
* Event categories for colored logging
|
||||
*/
|
||||
const EVENT_CATEGORIES: Record<string, { color: string; label: string }> = {
|
||||
// LLM events
|
||||
'llm:thinking': { color: '#3b82f6', label: 'LLM' },
|
||||
'llm:chunk': { color: '#3b82f6', label: 'LLM' },
|
||||
'llm:response': { color: '#22c55e', label: 'LLM' },
|
||||
'llm:tool-call': { color: '#f59e0b', label: 'Tool' },
|
||||
'llm:tool-result': { color: '#f59e0b', label: 'Tool' },
|
||||
'llm:error': { color: '#ef4444', label: 'Error' },
|
||||
'llm:unsupported-input': { color: '#ef4444', label: 'Error' },
|
||||
|
||||
// Approval events
|
||||
'approval:request': { color: '#8b5cf6', label: 'Approval' },
|
||||
'approval:response': { color: '#8b5cf6', label: 'Approval' },
|
||||
|
||||
// Session events
|
||||
'session:title-updated': { color: '#06b6d4', label: 'Session' },
|
||||
|
||||
// Context events
|
||||
'context:compacted': { color: '#64748b', label: 'Context' },
|
||||
'context:pruned': { color: '#64748b', label: 'Context' },
|
||||
|
||||
// Queue events
|
||||
'message:queued': { color: '#ec4899', label: 'Queue' },
|
||||
'message:dequeued': { color: '#ec4899', label: 'Queue' },
|
||||
|
||||
// Run lifecycle
|
||||
'run:complete': { color: '#22c55e', label: 'Run' },
|
||||
|
||||
// Connection (client-only)
|
||||
'connection:status': { color: '#64748b', label: 'Connection' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get summary for an event (for compact logging)
|
||||
*/
|
||||
function getEventSummary(event: ClientEvent): string {
|
||||
switch (event.name) {
|
||||
case 'llm:thinking':
|
||||
return `session=${event.sessionId}`;
|
||||
case 'llm:chunk':
|
||||
return `${event.chunkType}: "${event.content.slice(0, 30)}${event.content.length > 30 ? '...' : ''}"`;
|
||||
case 'llm:response':
|
||||
return `tokens=${event.tokenUsage?.totalTokens ?? '?'}, model=${event.model ?? '?'}`;
|
||||
case 'llm:tool-call':
|
||||
return `${event.toolName}(${JSON.stringify(event.args).slice(0, 50)}...)`;
|
||||
case 'llm:tool-result':
|
||||
return `${event.toolName}: ${event.success ? 'success' : 'failed'}`;
|
||||
case 'llm:error':
|
||||
return event.error?.message ?? 'Unknown error';
|
||||
case 'approval:request': {
|
||||
// toolName is in metadata for tool_confirmation type
|
||||
const toolName =
|
||||
'metadata' in event && event.metadata && 'toolName' in event.metadata
|
||||
? event.metadata.toolName
|
||||
: 'unknown';
|
||||
return `${event.type}: ${toolName}`;
|
||||
}
|
||||
case 'approval:response':
|
||||
return `${event.approvalId}: ${event.status}`;
|
||||
case 'session:title-updated':
|
||||
return `"${event.title}"`;
|
||||
case 'run:complete':
|
||||
return `reason=${event.finishReason}, steps=${event.stepCount}`;
|
||||
case 'message:dequeued':
|
||||
return `count=${event.count}`;
|
||||
case 'connection:status':
|
||||
return event.status;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for logging middleware
|
||||
*/
|
||||
export interface LoggingConfig {
|
||||
/** Enable/disable logging */
|
||||
enabled: boolean;
|
||||
/** Log full event payload (verbose) */
|
||||
verbose: boolean;
|
||||
/** Event names to exclude from logging */
|
||||
exclude: string[];
|
||||
/** Only log these event names (if set, overrides exclude) */
|
||||
include?: string[];
|
||||
}
|
||||
|
||||
const defaultConfig: LoggingConfig = {
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
verbose: false,
|
||||
exclude: ['llm:chunk'], // Chunks are too noisy by default
|
||||
};
|
||||
|
||||
let config: LoggingConfig = { ...defaultConfig };
|
||||
|
||||
/**
|
||||
* Configure the logging middleware
|
||||
*/
|
||||
export function configureLogging(newConfig: Partial<LoggingConfig>): void {
|
||||
config = { ...config, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset logging config to defaults
|
||||
*/
|
||||
export function resetLoggingConfig(): void {
|
||||
config = { ...defaultConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging middleware
|
||||
*
|
||||
* Logs events to the console with colored labels and summaries.
|
||||
* Disabled by default in production.
|
||||
*/
|
||||
export const loggingMiddleware: EventMiddleware = (event, next) => {
|
||||
// Always pass through
|
||||
next(event);
|
||||
|
||||
// Skip if disabled
|
||||
if (!config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check include/exclude filters
|
||||
if (config.include && !config.include.includes(event.name)) {
|
||||
return;
|
||||
}
|
||||
if (!config.include && config.exclude.includes(event.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get category info
|
||||
const category = EVENT_CATEGORIES[event.name] ?? { color: '#9ca3af', label: 'Event' };
|
||||
const summary = getEventSummary(event);
|
||||
|
||||
// Log with styling
|
||||
const sessionId = 'sessionId' in event ? event.sessionId : undefined;
|
||||
const sessionSuffix = sessionId ? ` [${sessionId.slice(0, 8)}]` : '';
|
||||
|
||||
console.log(
|
||||
`%c[${category.label}]%c ${event.name}${sessionSuffix}${summary ? ` - ${summary}` : ''}`,
|
||||
`color: ${category.color}; font-weight: bold`,
|
||||
'color: inherit'
|
||||
);
|
||||
|
||||
// Verbose mode: log full payload
|
||||
if (config.verbose) {
|
||||
console.log(' Payload:', event);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a custom logging middleware with specific config
|
||||
*/
|
||||
export function createLoggingMiddleware(customConfig: Partial<LoggingConfig>): EventMiddleware {
|
||||
const localConfig = { ...defaultConfig, ...customConfig };
|
||||
|
||||
return (event, next) => {
|
||||
next(event);
|
||||
|
||||
if (!localConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (localConfig.include && !localConfig.include.includes(event.name)) {
|
||||
return;
|
||||
}
|
||||
if (!localConfig.include && localConfig.exclude.includes(event.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = EVENT_CATEGORIES[event.name] ?? { color: '#9ca3af', label: 'Event' };
|
||||
const summary = getEventSummary(event);
|
||||
const sessionId = 'sessionId' in event ? event.sessionId : undefined;
|
||||
const sessionSuffix = sessionId ? ` [${sessionId.slice(0, 8)}]` : '';
|
||||
|
||||
console.log(
|
||||
`%c[${category.label}]%c ${event.name}${sessionSuffix}${summary ? ` - ${summary}` : ''}`,
|
||||
`color: ${category.color}; font-weight: bold`,
|
||||
'color: inherit'
|
||||
);
|
||||
|
||||
if (localConfig.verbose) {
|
||||
console.log(' Payload:', event);
|
||||
}
|
||||
};
|
||||
}
|
||||
251
dexto/packages/webui/lib/events/middleware/notification.test.ts
Normal file
251
dexto/packages/webui/lib/events/middleware/notification.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Tests for notification middleware
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { notificationMiddleware } from './notification.js';
|
||||
import { useSessionStore } from '../../stores/sessionStore.js';
|
||||
import { useNotificationStore } from '../../stores/notificationStore.js';
|
||||
import type { ClientEvent } from '../types.js';
|
||||
|
||||
describe('notificationMiddleware', () => {
|
||||
// Mock next function
|
||||
const next = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
useSessionStore.setState({
|
||||
currentSessionId: 'current-session',
|
||||
isWelcomeState: false,
|
||||
isCreatingSession: false,
|
||||
isSwitchingSession: false,
|
||||
isReplayingHistory: false,
|
||||
isLoadingHistory: false,
|
||||
});
|
||||
|
||||
useNotificationStore.setState({
|
||||
toasts: [],
|
||||
maxToasts: 5,
|
||||
});
|
||||
|
||||
// Clear mock
|
||||
next.mockClear();
|
||||
});
|
||||
|
||||
it('should always call next', () => {
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(event);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('notification suppression', () => {
|
||||
it('should suppress notifications during history replay', () => {
|
||||
useSessionStore.setState({ isReplayingHistory: true });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error: new Error('Test error'),
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should suppress notifications during session switch', () => {
|
||||
useSessionStore.setState({ isSwitchingSession: true });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error: new Error('Test error'),
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should suppress notifications during history loading', () => {
|
||||
useSessionStore.setState({ isLoadingHistory: true });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Test response',
|
||||
sessionId: 'background-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('llm:error events', () => {
|
||||
it('should NOT create toast for errors in current session (shown inline)', () => {
|
||||
useSessionStore.setState({ currentSessionId: 'current-session' });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error: new Error('Test error message'),
|
||||
sessionId: 'current-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
// Errors in current session are shown inline via ErrorBanner, not as toasts
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
expect(toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should create toast for errors in background session', () => {
|
||||
useSessionStore.setState({ currentSessionId: 'current-session' });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error: new Error('Test error'),
|
||||
sessionId: 'background-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].title).toBe('Error in background session');
|
||||
expect(toasts[0].description).toBe('Test error');
|
||||
expect(toasts[0].intent).toBe('danger');
|
||||
expect(toasts[0].sessionId).toBe('background-session');
|
||||
});
|
||||
|
||||
it('should handle error without message in background session', () => {
|
||||
useSessionStore.setState({ currentSessionId: 'current-session' });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:error',
|
||||
error: new Error(),
|
||||
sessionId: 'background-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].description).toBe('An error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('llm:response events', () => {
|
||||
it('should NOT create toast for responses in current session', () => {
|
||||
useSessionStore.setState({ currentSessionId: 'current-session' });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Test response',
|
||||
sessionId: 'current-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
expect(toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should create toast for responses in background session', () => {
|
||||
useSessionStore.setState({ currentSessionId: 'current-session' });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Test response',
|
||||
sessionId: 'background-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].title).toBe('Response Ready');
|
||||
expect(toasts[0].description).toBe('Agent completed in background session');
|
||||
expect(toasts[0].intent).toBe('info');
|
||||
expect(toasts[0].sessionId).toBe('background-session');
|
||||
});
|
||||
|
||||
it('should create toast when no session is active (treated as background)', () => {
|
||||
useSessionStore.setState({ currentSessionId: null });
|
||||
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:response',
|
||||
content: 'Test response',
|
||||
sessionId: 'some-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
const { toasts } = useNotificationStore.getState();
|
||||
// When no session is active, any session is considered "background"
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].sessionId).toBe('some-session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('other events', () => {
|
||||
it('should not create toast for llm:thinking', () => {
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not create toast for llm:chunk', () => {
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:chunk',
|
||||
chunkType: 'text',
|
||||
content: 'Test chunk',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not create toast for llm:tool-call', () => {
|
||||
const event: ClientEvent = {
|
||||
name: 'llm:tool-call',
|
||||
toolName: 'test-tool',
|
||||
args: {},
|
||||
callId: 'call-123',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not create toast for connection:status', () => {
|
||||
const event: ClientEvent = {
|
||||
name: 'connection:status',
|
||||
status: 'connected',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
notificationMiddleware(event, next);
|
||||
|
||||
expect(useNotificationStore.getState().toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
dexto/packages/webui/lib/events/middleware/notification.ts
Normal file
87
dexto/packages/webui/lib/events/middleware/notification.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Notification Middleware
|
||||
*
|
||||
* Converts significant events into toast notifications.
|
||||
* Respects notification suppression during history replay and session switches.
|
||||
*/
|
||||
|
||||
import type { EventMiddleware, ClientEvent } from '../types.js';
|
||||
import { useSessionStore } from '../../stores/sessionStore.js';
|
||||
import { useNotificationStore, type Toast } from '../../stores/notificationStore.js';
|
||||
|
||||
/**
|
||||
* Convert an event to a toast notification
|
||||
* Returns null if the event should not generate a toast
|
||||
*/
|
||||
function eventToToast(
|
||||
event: ClientEvent,
|
||||
isCurrentSession: boolean
|
||||
): Omit<Toast, 'id' | 'timestamp'> | null {
|
||||
switch (event.name) {
|
||||
// Errors are now shown inline via ErrorBanner, not as toasts
|
||||
// Only show toast for errors in background sessions
|
||||
case 'llm:error': {
|
||||
if (isCurrentSession) {
|
||||
return null; // Don't toast - shown inline via ErrorBanner
|
||||
}
|
||||
const sessionId = 'sessionId' in event ? event.sessionId : undefined;
|
||||
return {
|
||||
title: 'Error in background session',
|
||||
description: event.error?.message || 'An error occurred',
|
||||
intent: 'danger',
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
// Only notify for background sessions (not current session)
|
||||
case 'llm:response': {
|
||||
const sessionId = 'sessionId' in event ? event.sessionId : undefined;
|
||||
if (isCurrentSession) {
|
||||
return null; // Don't notify for current session
|
||||
}
|
||||
return {
|
||||
title: 'Response Ready',
|
||||
description: 'Agent completed in background session',
|
||||
intent: 'info',
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
// No notifications for other events
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification middleware
|
||||
*
|
||||
* Converts events into toast notifications based on:
|
||||
* - Event type (approval, error, response)
|
||||
* - Session context (current vs background)
|
||||
* - Notification suppression state (replay, switching)
|
||||
*/
|
||||
export const notificationMiddleware: EventMiddleware = (event, next) => {
|
||||
// Always call next first
|
||||
next(event);
|
||||
|
||||
const { shouldSuppressNotifications, currentSessionId } = useSessionStore.getState();
|
||||
const { addToast } = useNotificationStore.getState();
|
||||
|
||||
// Skip notifications during history replay or session switch
|
||||
if (shouldSuppressNotifications()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this event is from the current session
|
||||
const sessionId = 'sessionId' in event ? event.sessionId : undefined;
|
||||
const isCurrentSession = sessionId === currentSessionId;
|
||||
|
||||
// Convert event to toast
|
||||
const toast = eventToToast(event, isCurrentSession);
|
||||
|
||||
// Add toast if applicable
|
||||
if (toast) {
|
||||
addToast(toast);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user