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:
373
dexto/packages/webui/lib/events/EventBus.test.ts
Normal file
373
dexto/packages/webui/lib/events/EventBus.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ClientEventBus } from './EventBus.js';
|
||||
import type { ClientEvent, EventMiddleware } from './types.js';
|
||||
|
||||
// Helper to create a test event
|
||||
function createTestEvent(
|
||||
name: 'llm:thinking' | 'llm:chunk' | 'llm:response' = 'llm:thinking',
|
||||
sessionId = 'test-session'
|
||||
): ClientEvent {
|
||||
if (name === 'llm:thinking') {
|
||||
return { name: 'llm:thinking', sessionId };
|
||||
}
|
||||
if (name === 'llm:chunk') {
|
||||
return { name: 'llm:chunk', sessionId, content: 'test', chunkType: 'text' as const };
|
||||
}
|
||||
return {
|
||||
name: 'llm:response',
|
||||
sessionId,
|
||||
content: 'test response',
|
||||
tokenUsage: { totalTokens: 100 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClientEventBus', () => {
|
||||
let bus: ClientEventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new ClientEventBus();
|
||||
});
|
||||
|
||||
describe('dispatch and handlers', () => {
|
||||
it('should dispatch events to registered handlers', () => {
|
||||
const handler = vi.fn();
|
||||
bus.on('llm:thinking', handler);
|
||||
|
||||
const event = createTestEvent('llm:thinking');
|
||||
bus.dispatch(event);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should dispatch events to wildcard handlers', () => {
|
||||
const wildcardHandler = vi.fn();
|
||||
bus.on('*', wildcardHandler);
|
||||
|
||||
const event = createTestEvent('llm:thinking');
|
||||
bus.dispatch(event);
|
||||
|
||||
expect(wildcardHandler).toHaveBeenCalledTimes(1);
|
||||
expect(wildcardHandler).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should call both specific and wildcard handlers', () => {
|
||||
const specificHandler = vi.fn();
|
||||
const wildcardHandler = vi.fn();
|
||||
|
||||
bus.on('llm:thinking', specificHandler);
|
||||
bus.on('*', wildcardHandler);
|
||||
|
||||
const event = createTestEvent('llm:thinking');
|
||||
bus.dispatch(event);
|
||||
|
||||
expect(specificHandler).toHaveBeenCalledTimes(1);
|
||||
expect(wildcardHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call handlers for different event types', () => {
|
||||
const handler = vi.fn();
|
||||
bus.on('llm:response', handler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should support multiple handlers for the same event', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
bus.on('llm:thinking', handler1);
|
||||
bus.on('llm:thinking', handler2);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriptions', () => {
|
||||
it('should unsubscribe when subscription.unsubscribe() is called', () => {
|
||||
const handler = vi.fn();
|
||||
const subscription = bus.on('llm:thinking', handler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(handler).toHaveBeenCalledTimes(1); // Still 1, not 2
|
||||
});
|
||||
|
||||
it('should support once() for single invocation', () => {
|
||||
const handler = vi.fn();
|
||||
bus.once('llm:thinking', handler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should support off() to remove all handlers for an event', () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
bus.on('llm:thinking', handler1);
|
||||
bus.on('llm:thinking', handler2);
|
||||
|
||||
bus.off('llm:thinking');
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled();
|
||||
expect(handler2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('middleware', () => {
|
||||
it('should execute middleware in order before handlers', () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const middleware1: EventMiddleware = (event, next) => {
|
||||
order.push('middleware1-before');
|
||||
next(event);
|
||||
order.push('middleware1-after');
|
||||
};
|
||||
|
||||
const middleware2: EventMiddleware = (event, next) => {
|
||||
order.push('middleware2-before');
|
||||
next(event);
|
||||
order.push('middleware2-after');
|
||||
};
|
||||
|
||||
bus.use(middleware1);
|
||||
bus.use(middleware2);
|
||||
bus.on('llm:thinking', () => order.push('handler'));
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(order).toEqual([
|
||||
'middleware1-before',
|
||||
'middleware2-before',
|
||||
'handler',
|
||||
'middleware2-after',
|
||||
'middleware1-after',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow middleware to modify events', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const modifyingMiddleware: EventMiddleware = (event, next) => {
|
||||
if (event.name === 'llm:chunk') {
|
||||
next({
|
||||
...event,
|
||||
content: 'modified content',
|
||||
} as ClientEvent);
|
||||
} else {
|
||||
next(event);
|
||||
}
|
||||
};
|
||||
|
||||
bus.use(modifyingMiddleware);
|
||||
bus.on('llm:chunk', handler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:chunk'));
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'modified content' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow middleware to block events', () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const blockingMiddleware: EventMiddleware = (_event, _next) => {
|
||||
// Don't call next() - event is blocked
|
||||
};
|
||||
|
||||
bus.use(blockingMiddleware);
|
||||
bus.on('llm:thinking', handler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent duplicate middleware registration', () => {
|
||||
const middleware: EventMiddleware = (event, next) => next(event);
|
||||
|
||||
bus.use(middleware);
|
||||
bus.use(middleware); // Duplicate
|
||||
|
||||
expect(bus.middlewareCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow removing middleware', () => {
|
||||
const callCount = vi.fn();
|
||||
const middleware: EventMiddleware = (event, next) => {
|
||||
callCount();
|
||||
next(event);
|
||||
};
|
||||
|
||||
bus.use(middleware);
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(callCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
bus.removeMiddleware(middleware);
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(callCount).toHaveBeenCalledTimes(1); // Still 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('event history', () => {
|
||||
it('should store events in history', () => {
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
bus.dispatch(createTestEvent('llm:response'));
|
||||
|
||||
const history = bus.getHistory();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].name).toBe('llm:thinking');
|
||||
expect(history[1].name).toBe('llm:response');
|
||||
});
|
||||
|
||||
it('should filter history by predicate', () => {
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
bus.dispatch(createTestEvent('llm:response'));
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
const filtered = bus.getHistory((e) => e.name === 'llm:thinking');
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter history by session ID', () => {
|
||||
bus.dispatch(createTestEvent('llm:thinking', 'session-1'));
|
||||
bus.dispatch(createTestEvent('llm:thinking', 'session-2'));
|
||||
bus.dispatch(createTestEvent('llm:thinking', 'session-1'));
|
||||
|
||||
const filtered = bus.getHistoryBySession('session-1');
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should clear history', () => {
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
bus.dispatch(createTestEvent('llm:response'));
|
||||
|
||||
bus.clearHistory();
|
||||
|
||||
expect(bus.getHistory()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should limit history to max size', () => {
|
||||
// Dispatch more than MAX_HISTORY_SIZE events
|
||||
for (let i = 0; i < 1050; i++) {
|
||||
bus.dispatch(createTestEvent('llm:thinking', `session-${i}`));
|
||||
}
|
||||
|
||||
const history = bus.getHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay', () => {
|
||||
it('should replay events through the bus', () => {
|
||||
const handler = vi.fn();
|
||||
bus.on('llm:thinking', handler);
|
||||
|
||||
const events = [
|
||||
createTestEvent('llm:thinking', 'session-1'),
|
||||
createTestEvent('llm:thinking', 'session-2'),
|
||||
];
|
||||
|
||||
bus.replay(events);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should set isReplaying flag during replay', () => {
|
||||
const replayingStates: boolean[] = [];
|
||||
|
||||
const trackingMiddleware: EventMiddleware = (event, next) => {
|
||||
replayingStates.push(bus.isReplaying);
|
||||
next(event);
|
||||
};
|
||||
|
||||
bus.use(trackingMiddleware);
|
||||
|
||||
// Normal dispatch
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(replayingStates).toEqual([false]);
|
||||
|
||||
// Replay
|
||||
bus.replay([createTestEvent('llm:thinking')]);
|
||||
expect(replayingStates).toEqual([false, true]);
|
||||
|
||||
// After replay
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
expect(replayingStates).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('should reset isReplaying flag even if replay throws', () => {
|
||||
const throwingMiddleware: EventMiddleware = () => {
|
||||
throw new Error('Test error');
|
||||
};
|
||||
|
||||
bus.use(throwingMiddleware);
|
||||
|
||||
expect(() => bus.replay([createTestEvent('llm:thinking')])).toThrow();
|
||||
expect(bus.isReplaying).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should catch handler errors and continue to other handlers', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const throwingHandler = vi.fn(() => {
|
||||
throw new Error('Handler error');
|
||||
});
|
||||
const normalHandler = vi.fn();
|
||||
|
||||
bus.on('llm:thinking', throwingHandler);
|
||||
bus.on('llm:thinking', normalHandler);
|
||||
|
||||
bus.dispatch(createTestEvent('llm:thinking'));
|
||||
|
||||
expect(throwingHandler).toHaveBeenCalled();
|
||||
expect(normalHandler).toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('counts', () => {
|
||||
it('should track handler count', () => {
|
||||
expect(bus.handlerCount).toBe(0);
|
||||
|
||||
const sub1 = bus.on('llm:thinking', () => {});
|
||||
expect(bus.handlerCount).toBe(1);
|
||||
|
||||
bus.on('llm:response', () => {});
|
||||
expect(bus.handlerCount).toBe(2);
|
||||
|
||||
sub1.unsubscribe();
|
||||
expect(bus.handlerCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should track middleware count', () => {
|
||||
expect(bus.middlewareCount).toBe(0);
|
||||
|
||||
const mw: EventMiddleware = (e, n) => n(e);
|
||||
bus.use(mw);
|
||||
expect(bus.middlewareCount).toBe(1);
|
||||
|
||||
bus.removeMiddleware(mw);
|
||||
expect(bus.middlewareCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
342
dexto/packages/webui/lib/events/EventBus.ts
Normal file
342
dexto/packages/webui/lib/events/EventBus.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Client Event Bus
|
||||
*
|
||||
* Central event dispatch system with middleware pipeline support.
|
||||
* All SSE events flow through this bus, enabling:
|
||||
* - Unified logging and debugging
|
||||
* - Cross-cutting concerns (notifications, analytics)
|
||||
* - Event history for replay and debugging
|
||||
* - Type-safe event handling
|
||||
*/
|
||||
|
||||
import type {
|
||||
ClientEvent,
|
||||
ClientEventName,
|
||||
EventMiddleware,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Maximum number of events to keep in history
|
||||
*/
|
||||
const MAX_HISTORY_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Client Event Bus
|
||||
*
|
||||
* Provides centralized event dispatch with middleware support.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const eventBus = new ClientEventBus();
|
||||
*
|
||||
* // Add middleware
|
||||
* eventBus.use(loggingMiddleware);
|
||||
* eventBus.use(notificationMiddleware);
|
||||
*
|
||||
* // Subscribe to specific events
|
||||
* eventBus.on('llm:chunk', (event) => {
|
||||
* console.log('Chunk:', event.content);
|
||||
* });
|
||||
*
|
||||
* // Subscribe to all events
|
||||
* eventBus.on('*', (event) => {
|
||||
* console.log('Any event:', event.name);
|
||||
* });
|
||||
*
|
||||
* // Dispatch an event
|
||||
* eventBus.dispatch({ name: 'llm:thinking', sessionId: 'abc' });
|
||||
* ```
|
||||
*/
|
||||
export class ClientEventBus {
|
||||
/**
|
||||
* Registered middleware functions (executed in order)
|
||||
*/
|
||||
private middlewares: EventMiddleware[] = [];
|
||||
|
||||
/**
|
||||
* Event handlers by event name
|
||||
* '*' is the wildcard key for handlers that receive all events
|
||||
*/
|
||||
private handlers: Map<ClientEventName | '*', Set<EventHandler>> = new Map();
|
||||
|
||||
/**
|
||||
* Event history for debugging and replay
|
||||
*/
|
||||
private history: ClientEvent[] = [];
|
||||
|
||||
/**
|
||||
* Flag to track if we're currently replaying history
|
||||
* Used by middleware to suppress notifications during replay
|
||||
*/
|
||||
private _isReplaying = false;
|
||||
|
||||
/**
|
||||
* Register a middleware function
|
||||
*
|
||||
* Middleware is executed in the order it's added.
|
||||
* Each middleware must call next(event) to continue the chain.
|
||||
*
|
||||
* @param middleware - Middleware function to add
|
||||
* @returns this for chaining
|
||||
*/
|
||||
use(middleware: EventMiddleware): this {
|
||||
// Prevent duplicate middleware
|
||||
if (!this.middlewares.includes(middleware)) {
|
||||
this.middlewares.push(middleware);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a middleware function
|
||||
*
|
||||
* @param middleware - Middleware function to remove
|
||||
* @returns this for chaining
|
||||
*/
|
||||
removeMiddleware(middleware: EventMiddleware): this {
|
||||
const index = this.middlewares.indexOf(middleware);
|
||||
if (index !== -1) {
|
||||
this.middlewares.splice(index, 1);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events of a specific type
|
||||
*
|
||||
* @param eventName - Event name to subscribe to, or '*' for all events
|
||||
* @param handler - Handler function to call when event is dispatched
|
||||
* @returns Subscription object with unsubscribe method
|
||||
*/
|
||||
on<T extends ClientEventName | '*'>(
|
||||
eventName: T,
|
||||
handler: T extends '*' ? EventHandler : EventHandler<Extract<ClientEvent, { name: T }>>
|
||||
): EventSubscription {
|
||||
let handlerSet = this.handlers.get(eventName);
|
||||
if (!handlerSet) {
|
||||
handlerSet = new Set();
|
||||
this.handlers.set(eventName, handlerSet);
|
||||
}
|
||||
handlerSet.add(handler as EventHandler);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
handlerSet?.delete(handler as EventHandler);
|
||||
// Clean up empty sets
|
||||
if (handlerSet?.size === 0) {
|
||||
this.handlers.delete(eventName);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event once
|
||||
*
|
||||
* Handler will be automatically unsubscribed after first invocation.
|
||||
*
|
||||
* @param eventName - Event name to subscribe to
|
||||
* @param handler - Handler function to call once
|
||||
* @returns Subscription object with unsubscribe method
|
||||
*/
|
||||
once<T extends ClientEventName>(
|
||||
eventName: T,
|
||||
handler: EventHandler<Extract<ClientEvent, { name: T }>>
|
||||
): EventSubscription {
|
||||
const wrappedHandler = (event: ClientEvent) => {
|
||||
subscription.unsubscribe();
|
||||
handler(event as Extract<ClientEvent, { name: T }>);
|
||||
};
|
||||
// Use type assertion for the wrapped handler
|
||||
const subscription = this.on(eventName, wrappedHandler as any);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers for a specific event type
|
||||
*
|
||||
* @param eventName - Event name to clear handlers for
|
||||
*/
|
||||
off(eventName: ClientEventName | '*'): void {
|
||||
this.handlers.delete(eventName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event through the middleware chain and to handlers
|
||||
*
|
||||
* Events flow through middleware first (in order), then to handlers.
|
||||
* Middleware can modify, block, or pass through events.
|
||||
*
|
||||
* @param event - Event to dispatch
|
||||
*/
|
||||
dispatch(event: ClientEvent): void {
|
||||
// Add to history
|
||||
this.addToHistory(event);
|
||||
|
||||
// Build middleware chain
|
||||
const chain = this.buildMiddlewareChain((finalEvent) => {
|
||||
this.notifyHandlers(finalEvent);
|
||||
});
|
||||
|
||||
// Start the chain
|
||||
chain(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event history
|
||||
*
|
||||
* @param filter - Optional filter function
|
||||
* @returns Array of events matching filter (or all if no filter)
|
||||
*/
|
||||
getHistory(filter?: (event: ClientEvent) => boolean): ClientEvent[] {
|
||||
if (filter) {
|
||||
return this.history.filter(filter);
|
||||
}
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by session ID
|
||||
*
|
||||
* @param sessionId - Session ID to filter by
|
||||
* @returns Array of events for the session
|
||||
*/
|
||||
getHistoryBySession(sessionId: string): ClientEvent[] {
|
||||
return this.history.filter(
|
||||
(event) => 'sessionId' in event && event.sessionId === sessionId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay events through the bus
|
||||
*
|
||||
* Useful for restoring state after reconnection or loading history.
|
||||
* Sets isReplaying flag so middleware can suppress notifications.
|
||||
*
|
||||
* @param events - Events to replay
|
||||
*/
|
||||
replay(events: ClientEvent[]): void {
|
||||
this._isReplaying = true;
|
||||
try {
|
||||
for (const event of events) {
|
||||
this.dispatch(event);
|
||||
}
|
||||
} finally {
|
||||
this._isReplaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently replaying history
|
||||
*
|
||||
* Middleware can use this to suppress notifications during replay.
|
||||
*/
|
||||
get isReplaying(): boolean {
|
||||
return this._isReplaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set replay state (for external control, e.g., session switching)
|
||||
*/
|
||||
setReplaying(replaying: boolean): void {
|
||||
this._isReplaying = replaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered handlers
|
||||
*/
|
||||
get handlerCount(): number {
|
||||
let count = 0;
|
||||
for (const handlers of this.handlers.values()) {
|
||||
count += handlers.size;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of registered middleware
|
||||
*/
|
||||
get middlewareCount(): number {
|
||||
return this.middlewares.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the middleware chain
|
||||
*
|
||||
* Creates a function that passes the event through each middleware in order,
|
||||
* finally calling the done callback with the (possibly modified) event.
|
||||
*/
|
||||
private buildMiddlewareChain(done: (event: ClientEvent) => void): (event: ClientEvent) => void {
|
||||
// Start from the end and build backwards
|
||||
let next = done;
|
||||
|
||||
for (let i = this.middlewares.length - 1; i >= 0; i--) {
|
||||
const middleware = this.middlewares[i];
|
||||
const currentNext = next;
|
||||
next = (event: ClientEvent) => {
|
||||
middleware(event, currentNext);
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all handlers for an event
|
||||
*/
|
||||
private notifyHandlers(event: ClientEvent): void {
|
||||
// Notify specific handlers
|
||||
const specificHandlers = this.handlers.get(event.name);
|
||||
if (specificHandlers) {
|
||||
for (const handler of specificHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error(`[EventBus] Handler error for ${event.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify wildcard handlers
|
||||
const wildcardHandlers = this.handlers.get('*');
|
||||
if (wildcardHandlers) {
|
||||
for (const handler of wildcardHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error(`[EventBus] Wildcard handler error:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event to history, respecting max size
|
||||
*/
|
||||
private addToHistory(event: ClientEvent): void {
|
||||
this.history.push(event);
|
||||
|
||||
// Trim if over limit
|
||||
if (this.history.length > MAX_HISTORY_SIZE) {
|
||||
this.history = this.history.slice(-MAX_HISTORY_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton event bus instance
|
||||
*
|
||||
* Use this for the global application event bus.
|
||||
* Components should access via EventBusProvider context for testability.
|
||||
*/
|
||||
export const eventBus = new ClientEventBus();
|
||||
208
dexto/packages/webui/lib/events/README.md
Normal file
208
dexto/packages/webui/lib/events/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Event System
|
||||
|
||||
Centralized event dispatch system for the Dexto WebUI.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SSE Stream → EventBus → Middleware → Handlers → Zustand Stores → React Components
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **EventBus** (`EventBus.ts`) - Central event dispatcher
|
||||
- Manages event subscriptions
|
||||
- Executes middleware pipeline
|
||||
- Maintains event history for debugging
|
||||
|
||||
2. **Handlers** (`handlers.ts`) - Event-to-store mapping
|
||||
- Registry of handlers by event name
|
||||
- Each handler updates appropriate Zustand stores
|
||||
- Simple, focused functions with minimal logic
|
||||
|
||||
3. **Middleware** (`middleware/`) - Cross-cutting concerns
|
||||
- Logging middleware for debugging
|
||||
- Extensible for analytics, notifications, etc.
|
||||
|
||||
4. **Types** (`types.ts`) - TypeScript definitions
|
||||
- Re-exports StreamingEvent from @dexto/core
|
||||
- Client-only events (connection status, etc.)
|
||||
|
||||
## Usage
|
||||
|
||||
### Setting Up Event Handlers
|
||||
|
||||
In your app initialization or EventBusProvider:
|
||||
|
||||
```typescript
|
||||
import { useEventBus } from '@/components/providers/EventBusProvider';
|
||||
import { setupEventHandlers } from '@/lib/events';
|
||||
|
||||
function MyApp() {
|
||||
const bus = useEventBus();
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = setupEventHandlers(bus);
|
||||
return cleanup;
|
||||
}, [bus]);
|
||||
|
||||
return <YourComponents />;
|
||||
}
|
||||
```
|
||||
|
||||
### Dispatching Events
|
||||
|
||||
Events are automatically dispatched from the SSE stream. For testing or manual dispatch:
|
||||
|
||||
```typescript
|
||||
import { eventBus } from '@/lib/events';
|
||||
|
||||
eventBus.dispatch({
|
||||
name: 'llm:chunk',
|
||||
sessionId: 'session-123',
|
||||
content: 'Hello',
|
||||
chunkType: 'text',
|
||||
});
|
||||
```
|
||||
|
||||
### Subscribing to Events
|
||||
|
||||
For custom logic beyond the default handlers:
|
||||
|
||||
```typescript
|
||||
import { useEventBus } from '@/components/providers/EventBusProvider';
|
||||
|
||||
function MyComponent() {
|
||||
const bus = useEventBus();
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = bus.on('llm:response', (event) => {
|
||||
console.log('Response received:', event.content);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [bus]);
|
||||
}
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
Each handler corresponds to a StreamingEvent type from `@dexto/core`:
|
||||
|
||||
| Event | Handler | Store Updates |
|
||||
|-------|---------|---------------|
|
||||
| `llm:thinking` | `handleLLMThinking` | chatStore (processing=true), agentStore (status='thinking') |
|
||||
| `llm:chunk` | `handleLLMChunk` | chatStore (append to streaming message) |
|
||||
| `llm:response` | `handleLLMResponse` | chatStore (finalize message with metadata) |
|
||||
| `llm:tool-call` | `handleToolCall` | chatStore (add tool message), agentStore (status='executing_tool') |
|
||||
| `llm:tool-result` | `handleToolResult` | chatStore (update tool message with result) |
|
||||
| `llm:error` | `handleLLMError` | chatStore (set error, processing=false), agentStore (status='idle') |
|
||||
| `approval:request` | `handleApprovalRequest` | agentStore (status='awaiting_approval') |
|
||||
| `approval:response` | `handleApprovalResponse` | agentStore (status='idle') |
|
||||
| `run:complete` | `handleRunComplete` | chatStore (processing=false), agentStore (status='idle') |
|
||||
| `session:title-updated` | `handleSessionTitleUpdated` | (handled by TanStack Query) |
|
||||
| `message:dequeued` | `handleMessageDequeued` | chatStore (add user message from queue) |
|
||||
| `context:compressed` | `handleContextCompressed` | (log for debugging) |
|
||||
|
||||
## Adding New Handlers
|
||||
|
||||
1. Define the handler function in `handlers.ts`:
|
||||
|
||||
```typescript
|
||||
function handleMyNewEvent(event: EventByName<'my:event'>): void {
|
||||
const { sessionId, data } = event;
|
||||
// Update stores as needed
|
||||
useSomeStore.getState().updateSomething(sessionId, data);
|
||||
}
|
||||
```
|
||||
|
||||
2. Register in `registerHandlers()`:
|
||||
|
||||
```typescript
|
||||
export function registerHandlers(): void {
|
||||
// ... existing handlers
|
||||
handlers.set('my:event', handleMyNewEvent);
|
||||
}
|
||||
```
|
||||
|
||||
3. Add tests in `handlers.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('handleMyNewEvent', () => {
|
||||
it('should update the store correctly', () => {
|
||||
const event = {
|
||||
name: 'my:event' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
data: 'test',
|
||||
};
|
||||
|
||||
handleMyNewEvent(event);
|
||||
|
||||
const state = useSomeStore.getState();
|
||||
expect(state.data).toBe('test');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test:unit packages/webui/lib/events/handlers.test.ts
|
||||
```
|
||||
|
||||
Each handler is tested in isolation to verify correct store updates.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Handler simplicity** - Handlers extract data from events and call store actions. No complex logic.
|
||||
|
||||
2. **Store-driven** - All state changes go through Zustand stores. Handlers don't mutate state directly.
|
||||
|
||||
3. **Type safety** - Events are strongly typed via StreamingEvent union from @dexto/core.
|
||||
|
||||
4. **Testability** - Each handler can be tested independently with mocked stores.
|
||||
|
||||
5. **Single responsibility** - One handler per event type, focused on one concern.
|
||||
|
||||
## Migration from useChat
|
||||
|
||||
The handler registry replaces the 200+ LOC switch statement in `useChat.ts`:
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
// In useChat.ts
|
||||
switch (event.name) {
|
||||
case 'llm:thinking':
|
||||
setProcessing(true);
|
||||
// ... more logic
|
||||
break;
|
||||
case 'llm:chunk':
|
||||
// ... 30+ lines
|
||||
break;
|
||||
// ... 10+ more cases
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
// In handlers.ts
|
||||
function handleLLMThinking(event) {
|
||||
useChatStore.getState().setProcessing(event.sessionId, true);
|
||||
useAgentStore.getState().setThinking(event.sessionId);
|
||||
}
|
||||
|
||||
function handleLLMChunk(event) {
|
||||
// Simple, focused logic
|
||||
}
|
||||
|
||||
// Register all handlers
|
||||
registerHandlers();
|
||||
```
|
||||
|
||||
This provides:
|
||||
- Better testability (test each handler independently)
|
||||
- Clearer separation of concerns
|
||||
- Easier to add/modify handlers
|
||||
- Type safety with EventByName helper
|
||||
353
dexto/packages/webui/lib/events/USAGE.md
Normal file
353
dexto/packages/webui/lib/events/USAGE.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# EventBus Usage Guide
|
||||
|
||||
Complete integration guide for the EventBus system with SSE event dispatching.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SSE Stream → useEventDispatch → EventBus → Middleware → Handlers → Stores → UI Updates
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Wrap App with EventBusProvider
|
||||
|
||||
```tsx
|
||||
// app/root.tsx or similar
|
||||
import { EventBusProvider } from '@/components/providers/EventBusProvider';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<EventBusProvider
|
||||
enableLogging={true} // Console logging (dev only by default)
|
||||
enableActivityLogging={true} // Event log store
|
||||
enableNotifications={true} // Toast notifications
|
||||
>
|
||||
<YourApp />
|
||||
</EventBusProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The provider automatically:
|
||||
- Registers middleware (logging, activity, notifications)
|
||||
- Sets up event handlers to dispatch to stores
|
||||
- Cleans up on unmount
|
||||
|
||||
### 2. Dispatch SSE Events
|
||||
|
||||
In your message streaming component:
|
||||
|
||||
```tsx
|
||||
import { createMessageStream } from '@dexto/client-sdk';
|
||||
import { useEventDispatch } from '@/lib/events';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useMessageStream(sessionId: string) {
|
||||
const { dispatchEvent } = useEventDispatch();
|
||||
|
||||
const sendMessage = async (message: string) => {
|
||||
const responsePromise = client.api['message-stream'].$post({
|
||||
json: { message, sessionId }
|
||||
});
|
||||
|
||||
const stream = createMessageStream(responsePromise);
|
||||
|
||||
for await (const event of stream) {
|
||||
// Dispatch to EventBus - handlers will update stores
|
||||
dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
return { sendMessage };
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Components React to Store Updates
|
||||
|
||||
Components subscribe to stores as usual:
|
||||
|
||||
```tsx
|
||||
import { useChatStore } from '@/lib/stores/chatStore';
|
||||
import { useAgentStore } from '@/lib/stores/agentStore';
|
||||
|
||||
export function ChatInterface({ sessionId }: { sessionId: string }) {
|
||||
// Get session state
|
||||
const sessionState = useChatStore(state =>
|
||||
state.getSessionState(sessionId)
|
||||
);
|
||||
|
||||
// Get agent status
|
||||
const agentStatus = useAgentStore(state => state.status);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sessionState.streamingMessage && (
|
||||
<StreamingMessage message={sessionState.streamingMessage} />
|
||||
)}
|
||||
|
||||
{sessionState.messages.map(msg => (
|
||||
<Message key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
{agentStatus === 'thinking' && <ThinkingIndicator />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Event Flow Examples
|
||||
|
||||
### Example 1: LLM Response
|
||||
|
||||
```
|
||||
1. SSE: llm:thinking
|
||||
→ Handler: handleLLMThinking
|
||||
→ Store: agentStore.setThinking(sessionId)
|
||||
→ Store: chatStore.setProcessing(sessionId, true)
|
||||
→ UI: Show thinking indicator
|
||||
|
||||
2. SSE: llm:chunk (content: "Hello")
|
||||
→ Handler: handleLLMChunk
|
||||
→ Store: chatStore.setStreamingMessage(sessionId, newMessage)
|
||||
→ UI: Show streaming message
|
||||
|
||||
3. SSE: llm:chunk (content: " world")
|
||||
→ Handler: handleLLMChunk
|
||||
→ Store: chatStore.appendToStreamingMessage(sessionId, " world")
|
||||
→ UI: Update streaming message
|
||||
|
||||
4. SSE: llm:response (tokenUsage, model)
|
||||
→ Handler: handleLLMResponse
|
||||
→ Store: chatStore.finalizeStreamingMessage(sessionId, metadata)
|
||||
→ UI: Move to messages array, show token count
|
||||
|
||||
5. SSE: run:complete
|
||||
→ Handler: handleRunComplete
|
||||
→ Store: agentStore.setIdle()
|
||||
→ Store: chatStore.setProcessing(sessionId, false)
|
||||
→ UI: Hide thinking indicator
|
||||
```
|
||||
|
||||
### Example 2: Tool Call
|
||||
|
||||
```
|
||||
1. SSE: llm:tool-call (toolName: "read_file", callId: "123")
|
||||
→ Handler: handleToolCall
|
||||
→ Store: chatStore.addMessage(sessionId, toolMessage)
|
||||
→ Store: agentStore.setExecutingTool(sessionId, "read_file")
|
||||
→ UI: Show tool message, show executing indicator
|
||||
|
||||
2. SSE: llm:tool-result (callId: "123", success: true, sanitized: "File contents")
|
||||
→ Handler: handleToolResult
|
||||
→ Store: chatStore.updateMessage(sessionId, messageId, { toolResult, success })
|
||||
→ UI: Update tool message with result
|
||||
```
|
||||
|
||||
### Example 3: Approval Request
|
||||
|
||||
```
|
||||
1. SSE: approval:request (type: TOOL_CONFIRMATION, toolName: "write_file")
|
||||
→ Handler: handleApprovalRequest
|
||||
→ Store: agentStore.setAwaitingApproval(sessionId)
|
||||
→ Middleware (notification): Show toast "Tool write_file needs approval"
|
||||
→ UI: Show approval dialog
|
||||
|
||||
2. User clicks "Approve"
|
||||
→ API: POST /api/approval/respond { status: "approved" }
|
||||
|
||||
3. SSE: approval:response (status: "approved")
|
||||
→ Handler: handleApprovalResponse
|
||||
→ Store: agentStore.setIdle()
|
||||
→ UI: Hide approval dialog, resume processing
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
### Logging Middleware
|
||||
|
||||
Logs all events to console (dev mode only by default):
|
||||
|
||||
```
|
||||
[EventBus] llm:thinking → sessionId: abc-123
|
||||
[EventBus] llm:chunk → sessionId: abc-123, chunkType: text, content: Hello
|
||||
```
|
||||
|
||||
### Activity Middleware
|
||||
|
||||
Logs events to EventLogStore for debugging panel:
|
||||
|
||||
```tsx
|
||||
import { useEventLogStore } from '@/lib/stores/eventLogStore';
|
||||
|
||||
export function DebugPanel() {
|
||||
const events = useEventLogStore(state => state.events);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.map(event => (
|
||||
<div key={event.id}>
|
||||
{event.timestamp} - {event.category} - {event.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Notification Middleware
|
||||
|
||||
Shows toast notifications for important events:
|
||||
|
||||
- `approval:request` → "Tool X needs your approval"
|
||||
- `llm:error` → Error message with recovery info
|
||||
- Session-aware: Only notifies for current session
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Test - Individual Handler
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ClientEventBus } from './EventBus';
|
||||
import { handleLLMChunk } from './handlers';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
|
||||
describe('handleLLMChunk', () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({ sessions: new Map() });
|
||||
});
|
||||
|
||||
it('should create streaming message on first chunk', () => {
|
||||
handleLLMChunk({
|
||||
name: 'llm:chunk',
|
||||
sessionId: 'test',
|
||||
content: 'Hello',
|
||||
chunkType: 'text',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState().getSessionState('test');
|
||||
expect(state.streamingMessage?.content).toBe('Hello');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Test - Full Flow
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ClientEventBus } from './EventBus';
|
||||
import { setupEventHandlers } from './handlers';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAgentStore } from '../stores/agentStore';
|
||||
|
||||
describe('EventBus Integration', () => {
|
||||
let bus: ClientEventBus;
|
||||
let cleanup: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new ClientEventBus();
|
||||
cleanup = setupEventHandlers(bus);
|
||||
|
||||
// Reset stores
|
||||
useChatStore.setState({ sessions: new Map() });
|
||||
useAgentStore.setState({ status: 'idle', /* ... */ });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should handle full LLM response flow', () => {
|
||||
// Thinking
|
||||
bus.dispatch({ name: 'llm:thinking', sessionId: 'test' });
|
||||
expect(useAgentStore.getState().status).toBe('thinking');
|
||||
|
||||
// Chunk
|
||||
bus.dispatch({
|
||||
name: 'llm:chunk',
|
||||
sessionId: 'test',
|
||||
content: 'Response',
|
||||
chunkType: 'text',
|
||||
});
|
||||
expect(useChatStore.getState().getSessionState('test').streamingMessage)
|
||||
.not.toBeNull();
|
||||
|
||||
// Complete
|
||||
bus.dispatch({
|
||||
name: 'llm:response',
|
||||
sessionId: 'test',
|
||||
tokenUsage: { totalTokens: 100 },
|
||||
});
|
||||
expect(useChatStore.getState().getSessionState('test').messages)
|
||||
.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Middleware
|
||||
|
||||
Create custom middleware to extend functionality:
|
||||
|
||||
```typescript
|
||||
import type { EventMiddleware } from '@/lib/events';
|
||||
|
||||
export const analyticsMiddleware: EventMiddleware = (event, next) => {
|
||||
// Track analytics
|
||||
if (event.name === 'llm:response') {
|
||||
analytics.track('llm_response', {
|
||||
sessionId: event.sessionId,
|
||||
model: event.model,
|
||||
tokens: event.tokenUsage?.totalTokens,
|
||||
});
|
||||
}
|
||||
|
||||
return next(event);
|
||||
};
|
||||
|
||||
// Use it
|
||||
<EventBusProvider middleware={[analyticsMiddleware]}>
|
||||
<App />
|
||||
</EventBusProvider>
|
||||
```
|
||||
|
||||
## Advanced: Direct Bus Access
|
||||
|
||||
For cases where you need direct access to the bus:
|
||||
|
||||
```tsx
|
||||
import { useEventBus } from '@/components/providers/EventBusProvider';
|
||||
|
||||
export function CustomComponent() {
|
||||
const bus = useEventBus();
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to specific event
|
||||
const sub = bus.on('llm:error', (event) => {
|
||||
console.error('LLM Error:', event.error);
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [bus]);
|
||||
|
||||
// Dispatch custom event
|
||||
const handleAction = () => {
|
||||
bus.dispatch({
|
||||
name: 'custom:event',
|
||||
sessionId: 'test',
|
||||
// ... custom data
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
1. **EventBusProvider** - Wrap your app, registers middleware and handlers
|
||||
2. **useEventDispatch** - Use in components to dispatch SSE events
|
||||
3. **Middleware** - Intercepts events (logging, activity, notifications)
|
||||
4. **Handlers** - Process events and update stores
|
||||
5. **Stores** - Hold state, trigger React re-renders
|
||||
6. **Components** - Subscribe to stores, render UI
|
||||
|
||||
All wired together automatically. Just dispatch events and let the system handle the rest.
|
||||
504
dexto/packages/webui/lib/events/handlers.test.ts
Normal file
504
dexto/packages/webui/lib/events/handlers.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* Event Handler Registry Tests
|
||||
*
|
||||
* Tests each handler to ensure correct store updates.
|
||||
* Uses Zustand's test utilities to spy on store actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { StreamingEvent } from '@dexto/core';
|
||||
import { ApprovalType, ApprovalStatus } from '@dexto/core';
|
||||
import {
|
||||
registerHandlers,
|
||||
getHandler,
|
||||
handleLLMThinking,
|
||||
handleLLMChunk,
|
||||
handleLLMResponse,
|
||||
handleToolCall,
|
||||
handleToolResult,
|
||||
handleLLMError,
|
||||
handleApprovalRequest,
|
||||
handleApprovalResponse,
|
||||
handleRunComplete,
|
||||
handleSessionTitleUpdated,
|
||||
handleMessageDequeued,
|
||||
handleContextCompacted,
|
||||
} from './handlers.js';
|
||||
import { useChatStore } from '../stores/chatStore.js';
|
||||
import { useAgentStore } from '../stores/agentStore.js';
|
||||
|
||||
// Mock generateMessageId to return predictable IDs
|
||||
vi.mock('../stores/chatStore.js', async () => {
|
||||
const actual = await vi.importActual('../stores/chatStore.js');
|
||||
return {
|
||||
...actual,
|
||||
generateMessageId: vi.fn(() => 'test-msg-id'),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Event Handler Registry', () => {
|
||||
const TEST_SESSION_ID = 'test-session';
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset stores before each test
|
||||
useChatStore.setState({ sessions: new Map() });
|
||||
useAgentStore.setState({
|
||||
status: 'idle',
|
||||
connectionStatus: 'disconnected',
|
||||
lastHeartbeat: null,
|
||||
activeSessionId: null,
|
||||
currentToolName: null,
|
||||
connectionError: null,
|
||||
reconnectAttempts: 0,
|
||||
});
|
||||
|
||||
// Initialize session in chat store
|
||||
useChatStore.getState().initSession(TEST_SESSION_ID);
|
||||
|
||||
// Clear mock calls
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Registry Management', () => {
|
||||
it('should register all handlers', () => {
|
||||
registerHandlers();
|
||||
|
||||
// Check that all expected handlers are registered
|
||||
expect(getHandler('llm:thinking')).toBeDefined();
|
||||
expect(getHandler('llm:chunk')).toBeDefined();
|
||||
expect(getHandler('llm:response')).toBeDefined();
|
||||
expect(getHandler('llm:tool-call')).toBeDefined();
|
||||
expect(getHandler('llm:tool-result')).toBeDefined();
|
||||
expect(getHandler('llm:error')).toBeDefined();
|
||||
expect(getHandler('approval:request')).toBeDefined();
|
||||
expect(getHandler('approval:response')).toBeDefined();
|
||||
expect(getHandler('run:complete')).toBeDefined();
|
||||
expect(getHandler('session:title-updated')).toBeDefined();
|
||||
expect(getHandler('message:dequeued')).toBeDefined();
|
||||
expect(getHandler('context:compacted')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered handlers', () => {
|
||||
registerHandlers();
|
||||
expect(getHandler('nonexistent:event')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMThinking', () => {
|
||||
it('should set processing=true and agent status to thinking', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'llm:thinking' }> = {
|
||||
name: 'llm:thinking',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
};
|
||||
|
||||
handleLLMThinking(event);
|
||||
|
||||
// Check chat store
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.processing).toBe(true);
|
||||
|
||||
// Check agent store
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('thinking');
|
||||
expect(agentState.activeSessionId).toBe(TEST_SESSION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMChunk', () => {
|
||||
it('should create streaming message on first chunk', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'llm:chunk' }> = {
|
||||
name: 'llm:chunk',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
chunkType: 'text',
|
||||
content: 'Hello',
|
||||
};
|
||||
|
||||
handleLLMChunk(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.streamingMessage).toBeDefined();
|
||||
expect(chatState.streamingMessage?.content).toBe('Hello');
|
||||
expect(chatState.streamingMessage?.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should append to existing streaming message', () => {
|
||||
// Create initial streaming message
|
||||
useChatStore.getState().setStreamingMessage(TEST_SESSION_ID, {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Hello',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const event: Extract<StreamingEvent, { name: 'llm:chunk' }> = {
|
||||
name: 'llm:chunk',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
chunkType: 'text',
|
||||
content: ' world',
|
||||
};
|
||||
|
||||
handleLLMChunk(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.streamingMessage?.content).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should handle reasoning chunks', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'llm:chunk' }> = {
|
||||
name: 'llm:chunk',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
chunkType: 'reasoning',
|
||||
content: 'Thinking...',
|
||||
};
|
||||
|
||||
handleLLMChunk(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.streamingMessage?.reasoning).toBe('Thinking...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMResponse', () => {
|
||||
it('should finalize streaming message with metadata', () => {
|
||||
// Create streaming message
|
||||
useChatStore.getState().setStreamingMessage(TEST_SESSION_ID, {
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'Response content',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const event: Extract<StreamingEvent, { name: 'llm:response' }> = {
|
||||
name: 'llm:response',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
content: 'Response content',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tokenUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalTokens: 30,
|
||||
},
|
||||
};
|
||||
|
||||
handleLLMResponse(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.streamingMessage).toBeNull();
|
||||
expect(chatState.messages).toHaveLength(1);
|
||||
expect(chatState.messages[0].tokenUsage).toEqual(event.tokenUsage);
|
||||
expect(chatState.messages[0].model).toBe('gpt-4');
|
||||
expect(chatState.messages[0].provider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleToolCall', () => {
|
||||
it('should add tool message to chat', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'llm:tool-call' }> = {
|
||||
name: 'llm:tool-call',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
toolName: 'calculator',
|
||||
args: { expression: '2+2' },
|
||||
callId: 'call-123',
|
||||
};
|
||||
|
||||
handleToolCall(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.messages).toHaveLength(1);
|
||||
expect(chatState.messages[0].role).toBe('tool');
|
||||
expect(chatState.messages[0].toolName).toBe('calculator');
|
||||
expect(chatState.messages[0].toolArgs).toEqual({ expression: '2+2' });
|
||||
expect(chatState.messages[0].toolCallId).toBe('call-123');
|
||||
|
||||
// Check agent status
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('executing_tool');
|
||||
expect(agentState.currentToolName).toBe('calculator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleToolResult', () => {
|
||||
it('should update tool message with result', () => {
|
||||
// Add tool message first
|
||||
useChatStore.getState().addMessage(TEST_SESSION_ID, {
|
||||
id: 'tool-msg',
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolName: 'calculator',
|
||||
toolCallId: 'call-123',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const event: Extract<StreamingEvent, { name: 'llm:tool-result' }> = {
|
||||
name: 'llm:tool-result',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
toolName: 'calculator',
|
||||
callId: 'call-123',
|
||||
success: true,
|
||||
sanitized: {
|
||||
content: [{ type: 'text', text: '4' }],
|
||||
meta: { toolName: 'calculator', toolCallId: 'call-123', success: true },
|
||||
},
|
||||
};
|
||||
|
||||
handleToolResult(event);
|
||||
|
||||
const message = useChatStore.getState().getMessage(TEST_SESSION_ID, 'tool-msg');
|
||||
expect(message?.toolResult).toEqual(event.sanitized);
|
||||
expect(message?.toolResultSuccess).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle approval metadata', () => {
|
||||
useChatStore.getState().addMessage(TEST_SESSION_ID, {
|
||||
id: 'tool-msg',
|
||||
role: 'tool',
|
||||
content: null,
|
||||
toolName: 'dangerous-tool',
|
||||
toolCallId: 'call-456',
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const event: Extract<StreamingEvent, { name: 'llm:tool-result' }> = {
|
||||
name: 'llm:tool-result',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
toolName: 'dangerous-tool',
|
||||
callId: 'call-456',
|
||||
success: true,
|
||||
sanitized: {
|
||||
content: [],
|
||||
meta: { toolName: 'dangerous-tool', toolCallId: 'call-456', success: true },
|
||||
},
|
||||
requireApproval: true,
|
||||
approvalStatus: 'approved',
|
||||
};
|
||||
|
||||
handleToolResult(event);
|
||||
|
||||
const message = useChatStore.getState().getMessage(TEST_SESSION_ID, 'tool-msg');
|
||||
expect(message?.requireApproval).toBe(true);
|
||||
expect(message?.approvalStatus).toBe('approved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLLMError', () => {
|
||||
it('should set error and stop processing', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'llm:error' }> = {
|
||||
name: 'llm:error',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
error: new Error('Test error'),
|
||||
context: 'test-context',
|
||||
recoverable: true,
|
||||
};
|
||||
|
||||
handleLLMError(event);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.error).toBeDefined();
|
||||
expect(chatState.error?.message).toBe('Test error');
|
||||
expect(chatState.error?.context).toBe('test-context');
|
||||
expect(chatState.error?.recoverable).toBe(true);
|
||||
expect(chatState.processing).toBe(false);
|
||||
|
||||
// Check agent status
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApprovalRequest', () => {
|
||||
it('should set agent status to awaiting approval', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'approval:request' }> = {
|
||||
name: 'approval:request',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
approvalId: 'approval-1',
|
||||
type: ApprovalType.TOOL_CONFIRMATION,
|
||||
metadata: {
|
||||
toolName: 'dangerous-tool',
|
||||
toolCallId: 'call-dangerous-1',
|
||||
args: {},
|
||||
},
|
||||
timeout: 30000,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
handleApprovalRequest(event);
|
||||
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('awaiting_approval');
|
||||
expect(agentState.activeSessionId).toBe(TEST_SESSION_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApprovalResponse', () => {
|
||||
it('should set agent to thinking when approved', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'approval:response' }> = {
|
||||
name: 'approval:response',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
approvalId: 'approval-1',
|
||||
status: ApprovalStatus.APPROVED,
|
||||
};
|
||||
|
||||
handleApprovalResponse(event);
|
||||
|
||||
const agentState = useAgentStore.getState();
|
||||
// Agent resumes execution after approval - set to thinking (not idle)
|
||||
expect(agentState.status).toBe('thinking');
|
||||
});
|
||||
|
||||
it('should set agent to idle when rejected', () => {
|
||||
const event: Extract<StreamingEvent, { name: 'approval:response' }> = {
|
||||
name: 'approval:response',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
approvalId: 'approval-1',
|
||||
status: ApprovalStatus.DENIED,
|
||||
};
|
||||
|
||||
handleApprovalResponse(event);
|
||||
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleRunComplete', () => {
|
||||
it('should stop processing and set agent to idle', () => {
|
||||
// Set up initial state
|
||||
useChatStore.getState().setProcessing(TEST_SESSION_ID, true);
|
||||
useAgentStore.getState().setThinking(TEST_SESSION_ID);
|
||||
|
||||
const event = {
|
||||
name: 'run:complete' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
finishReason: 'stop',
|
||||
stepCount: 3,
|
||||
};
|
||||
|
||||
handleRunComplete(event as any);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.processing).toBe(false);
|
||||
|
||||
const agentState = useAgentStore.getState();
|
||||
expect(agentState.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSessionTitleUpdated', () => {
|
||||
it('should log debug message (placeholder)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
const event: Extract<StreamingEvent, { name: 'session:title-updated' }> = {
|
||||
name: 'session:title-updated',
|
||||
sessionId: TEST_SESSION_ID,
|
||||
title: 'New Title',
|
||||
};
|
||||
|
||||
handleSessionTitleUpdated(event);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[handlers] session:title-updated',
|
||||
TEST_SESSION_ID,
|
||||
'New Title'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMessageDequeued', () => {
|
||||
it('should add user message with text content', () => {
|
||||
const event = {
|
||||
name: 'message:dequeued' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
count: 1,
|
||||
ids: ['queued-1'],
|
||||
coalesced: false,
|
||||
content: [{ type: 'text', text: 'Queued message' }],
|
||||
};
|
||||
|
||||
handleMessageDequeued(event as any);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.messages).toHaveLength(1);
|
||||
expect(chatState.messages[0].role).toBe('user');
|
||||
expect(chatState.messages[0].content).toBe('Queued message');
|
||||
});
|
||||
|
||||
it('should handle image attachments', () => {
|
||||
const event = {
|
||||
name: 'message:dequeued' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
count: 1,
|
||||
ids: ['queued-1'],
|
||||
coalesced: false,
|
||||
content: [
|
||||
{ type: 'text', text: 'Check this out' },
|
||||
{ type: 'image', image: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
};
|
||||
|
||||
handleMessageDequeued(event as any);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.messages[0].imageData).toEqual({
|
||||
image: 'base64data',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file attachments', () => {
|
||||
const event = {
|
||||
name: 'message:dequeued' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
count: 1,
|
||||
ids: ['queued-1'],
|
||||
coalesced: false,
|
||||
content: [
|
||||
{ type: 'text', text: 'Here is a file' },
|
||||
{
|
||||
type: 'file',
|
||||
data: 'file-data',
|
||||
mimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handleMessageDequeued(event as any);
|
||||
|
||||
const chatState = useChatStore.getState().getSessionState(TEST_SESSION_ID);
|
||||
expect(chatState.messages[0].fileData).toEqual({
|
||||
data: 'file-data',
|
||||
mimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleContextCompacted', () => {
|
||||
it('should log debug message', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
const event = {
|
||||
name: 'context:compacted' as const,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
originalTokens: 10000,
|
||||
compactedTokens: 5000,
|
||||
originalMessages: 20,
|
||||
compactedMessages: 10,
|
||||
strategy: 'llm-based',
|
||||
reason: 'overflow',
|
||||
};
|
||||
|
||||
handleContextCompacted(event as any);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const call = consoleSpy.mock.calls[0];
|
||||
expect(call[0]).toContain('Context compacted');
|
||||
expect(call[0]).toContain('10,000 → 5,000 tokens');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
793
dexto/packages/webui/lib/events/handlers.ts
Normal file
793
dexto/packages/webui/lib/events/handlers.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
/**
|
||||
* Event Handler Registry
|
||||
*
|
||||
* Maps StreamingEvent types to Zustand store actions.
|
||||
* Replaces the 200+ LOC switch statement in useChat.ts with a registry pattern.
|
||||
*
|
||||
* Each handler is responsible for:
|
||||
* - Extracting relevant data from the event
|
||||
* - Calling the appropriate store action(s)
|
||||
* - Keeping side effects simple and focused
|
||||
*
|
||||
* @see packages/webui/components/hooks/useChat.ts (original implementation)
|
||||
*/
|
||||
|
||||
import type { StreamingEvent, ApprovalStatus } from '@dexto/core';
|
||||
import { useChatStore, generateMessageId } from '../stores/chatStore.js';
|
||||
import { useAgentStore } from '../stores/agentStore.js';
|
||||
import { useApprovalStore } from '../stores/approvalStore.js';
|
||||
import { usePreferenceStore } from '../stores/preferenceStore.js';
|
||||
import { useTodoStore } from '../stores/todoStore.js';
|
||||
import type { ClientEventBus } from './EventBus.js';
|
||||
import { captureTokenUsage } from '../analytics/capture.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generic event handler function
|
||||
*/
|
||||
type EventHandler<T = StreamingEvent> = (event: T) => void;
|
||||
|
||||
/**
|
||||
* Extract specific event type by name
|
||||
* For events not in StreamingEvent, we use a broader constraint
|
||||
*/
|
||||
type EventByName<T extends string> =
|
||||
Extract<StreamingEvent, { name: T }> extends never
|
||||
? { name: T; sessionId: string; [key: string]: any }
|
||||
: Extract<StreamingEvent, { name: T }>;
|
||||
|
||||
// =============================================================================
|
||||
// Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Map of event names to their handlers
|
||||
* Uses string as key type to support all event names
|
||||
*/
|
||||
const handlers = new Map<string, EventHandler<any>>();
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Finalizes any in-progress streaming message for a session.
|
||||
* This ensures proper message ordering when tool calls or approvals arrive
|
||||
* while the assistant is still streaming content.
|
||||
*/
|
||||
function finalizeStreamingIfNeeded(sessionId: string): void {
|
||||
const chatStore = useChatStore.getState();
|
||||
const sessionState = chatStore.getSessionState(sessionId);
|
||||
|
||||
if (sessionState.streamingMessage) {
|
||||
// Move streaming message to messages array before adding new messages
|
||||
chatStore.finalizeStreamingMessage(sessionId, {});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handler Implementations
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* llm:thinking - LLM started thinking
|
||||
* Sets processing=true and agent status to 'thinking'
|
||||
*/
|
||||
function handleLLMThinking(event: EventByName<'llm:thinking'>): void {
|
||||
const { sessionId } = event;
|
||||
|
||||
// Update chat state
|
||||
useChatStore.getState().setProcessing(sessionId, true);
|
||||
|
||||
// Update agent status
|
||||
useAgentStore.getState().setThinking(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* llm:chunk - LLM sent streaming chunk
|
||||
* Appends content to streaming message (text or reasoning)
|
||||
*
|
||||
* When streaming is disabled (user preference), chunks are skipped
|
||||
* and the full content comes via llm:response instead.
|
||||
*/
|
||||
function handleLLMChunk(event: EventByName<'llm:chunk'>): void {
|
||||
// Check user streaming preference
|
||||
const isStreaming = usePreferenceStore.getState().isStreaming;
|
||||
if (!isStreaming) {
|
||||
// Skip chunk updates when streaming is disabled
|
||||
// llm:response will provide the full content
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, content, chunkType = 'text' } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Check if streaming message exists
|
||||
const sessionState = chatStore.getSessionState(sessionId);
|
||||
|
||||
if (!sessionState.streamingMessage) {
|
||||
// Create new streaming message
|
||||
const newMessage = {
|
||||
id: generateMessageId(),
|
||||
role: 'assistant' as const,
|
||||
content: chunkType === 'text' ? content : '',
|
||||
reasoning: chunkType === 'reasoning' ? content : undefined,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
};
|
||||
chatStore.setStreamingMessage(sessionId, newMessage);
|
||||
} else {
|
||||
// Append to existing streaming message
|
||||
chatStore.appendToStreamingMessage(sessionId, content, chunkType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* llm:response - LLM sent final response
|
||||
* Finalizes streaming message OR creates assistant message if needed
|
||||
*
|
||||
* Handles three scenarios:
|
||||
* 1. Streaming mode: streaming message exists → finalize with content and metadata
|
||||
* 2. Non-streaming mode: no streaming message → create new assistant message
|
||||
* 3. Multi-turn: assistant message already in messages array → update it
|
||||
*/
|
||||
function handleLLMResponse(event: EventByName<'llm:response'>): void {
|
||||
const { sessionId, content, tokenUsage, model, provider, estimatedInputTokens } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
const sessionState = chatStore.getSessionState(sessionId);
|
||||
const finalContent = typeof content === 'string' ? content : '';
|
||||
|
||||
// Check if there's a streaming message to finalize
|
||||
if (sessionState.streamingMessage) {
|
||||
// Finalize streaming message with content and metadata
|
||||
chatStore.finalizeStreamingMessage(sessionId, {
|
||||
content: finalContent,
|
||||
tokenUsage,
|
||||
...(model && { model }),
|
||||
...(provider && { provider }),
|
||||
});
|
||||
|
||||
// Track token usage analytics before returning
|
||||
if (tokenUsage && (tokenUsage.inputTokens || tokenUsage.outputTokens)) {
|
||||
// Calculate estimate accuracy if both estimate and actual are available
|
||||
let estimateAccuracyPercent: number | undefined;
|
||||
if (estimatedInputTokens !== undefined && tokenUsage.inputTokens) {
|
||||
const diff = estimatedInputTokens - tokenUsage.inputTokens;
|
||||
estimateAccuracyPercent = Math.round((diff / tokenUsage.inputTokens) * 100);
|
||||
}
|
||||
|
||||
captureTokenUsage({
|
||||
sessionId,
|
||||
provider,
|
||||
model,
|
||||
inputTokens: tokenUsage.inputTokens,
|
||||
outputTokens: tokenUsage.outputTokens,
|
||||
reasoningTokens: tokenUsage.reasoningTokens,
|
||||
totalTokens: tokenUsage.totalTokens,
|
||||
cacheReadTokens: tokenUsage.cacheReadTokens,
|
||||
cacheWriteTokens: tokenUsage.cacheWriteTokens,
|
||||
estimatedInputTokens,
|
||||
estimateAccuracyPercent,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No streaming message - find the most recent assistant message in this turn
|
||||
// This handles cases where streaming was finalized before tool calls
|
||||
const messages = sessionState.messages;
|
||||
|
||||
// Look for the most recent assistant message (may have tool messages after it)
|
||||
let recentAssistantMsg = null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === 'assistant') {
|
||||
recentAssistantMsg = msg;
|
||||
break;
|
||||
}
|
||||
// Stop searching if we hit a user message (different turn)
|
||||
if (msg.role === 'user') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (recentAssistantMsg) {
|
||||
// Update existing assistant message with final content and metadata
|
||||
chatStore.updateMessage(sessionId, recentAssistantMsg.id, {
|
||||
content: finalContent || recentAssistantMsg.content,
|
||||
tokenUsage,
|
||||
...(model && { model }),
|
||||
...(provider && { provider }),
|
||||
});
|
||||
} else if (finalContent) {
|
||||
// No assistant message exists - create one with the final content
|
||||
// This handles non-streaming mode or first response
|
||||
chatStore.addMessage(sessionId, {
|
||||
id: generateMessageId(),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
tokenUsage,
|
||||
...(model && { model }),
|
||||
...(provider && { provider }),
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Track token usage analytics (at end, after all processing)
|
||||
if (tokenUsage && (tokenUsage.inputTokens || tokenUsage.outputTokens)) {
|
||||
// Calculate estimate accuracy if both estimate and actual are available
|
||||
let estimateAccuracyPercent: number | undefined;
|
||||
if (estimatedInputTokens !== undefined && tokenUsage.inputTokens) {
|
||||
const diff = estimatedInputTokens - tokenUsage.inputTokens;
|
||||
estimateAccuracyPercent = Math.round((diff / tokenUsage.inputTokens) * 100);
|
||||
}
|
||||
|
||||
captureTokenUsage({
|
||||
sessionId,
|
||||
provider,
|
||||
model,
|
||||
inputTokens: tokenUsage.inputTokens,
|
||||
outputTokens: tokenUsage.outputTokens,
|
||||
reasoningTokens: tokenUsage.reasoningTokens,
|
||||
totalTokens: tokenUsage.totalTokens,
|
||||
cacheReadTokens: tokenUsage.cacheReadTokens,
|
||||
cacheWriteTokens: tokenUsage.cacheWriteTokens,
|
||||
estimatedInputTokens,
|
||||
estimateAccuracyPercent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* llm:tool-call - LLM requested a tool call
|
||||
* Adds a tool message to the chat
|
||||
*
|
||||
* Checks if an approval message already exists for this tool to avoid duplicates.
|
||||
* This handles cases where approval:request arrives before llm:tool-call.
|
||||
*/
|
||||
function handleToolCall(event: EventByName<'llm:tool-call'>): void {
|
||||
const { sessionId, toolName, args, callId } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Finalize any streaming message to maintain proper sequence
|
||||
finalizeStreamingIfNeeded(sessionId);
|
||||
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
|
||||
// Check if there's already a message for this tool call (from approval:request)
|
||||
// The approval message uses the approvalId which may equal callId
|
||||
const existingMessage = messages.find(
|
||||
(m) => m.role === 'tool' && m.toolCallId === callId && m.toolResult === undefined
|
||||
);
|
||||
|
||||
if (existingMessage) {
|
||||
// Approval message already exists - update with args if needed
|
||||
chatStore.updateMessage(sessionId, existingMessage.id, {
|
||||
toolArgs: args,
|
||||
});
|
||||
console.debug('[handlers] Tool call message already exists:', existingMessage.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for pending approval messages that don't have a result yet
|
||||
// Match by: 1) exact toolName, 2) toolName without prefix, 3) any pending approval
|
||||
const stripPrefix = (name: string) =>
|
||||
name
|
||||
.replace(/^(internal--|custom--|mcp--[^-]+--|mcp__[^_]+__)/, '')
|
||||
.replace(/^(internal__|custom__)/, '');
|
||||
const cleanToolName = stripPrefix(toolName);
|
||||
|
||||
const pendingApprovalMessage = messages.find((m) => {
|
||||
if (m.role !== 'tool' || m.toolResult !== undefined) return false;
|
||||
if (m.requireApproval !== true || m.approvalStatus !== 'pending') return false;
|
||||
|
||||
// Match by toolName (exact or stripped)
|
||||
if (m.toolName === toolName) return true;
|
||||
if (m.toolName && stripPrefix(m.toolName) === cleanToolName) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (pendingApprovalMessage) {
|
||||
// Update existing approval message with the callId and args
|
||||
chatStore.updateMessage(sessionId, pendingApprovalMessage.id, {
|
||||
toolCallId: callId,
|
||||
toolArgs: args,
|
||||
});
|
||||
console.debug(
|
||||
'[handlers] Updated existing approval message with callId:',
|
||||
pendingApprovalMessage.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create tool message
|
||||
const toolMessage = {
|
||||
id: `tool-${callId}`,
|
||||
role: 'tool' as const,
|
||||
content: null,
|
||||
toolName,
|
||||
toolArgs: args,
|
||||
toolCallId: callId,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
};
|
||||
|
||||
chatStore.addMessage(sessionId, toolMessage);
|
||||
|
||||
// Update agent status
|
||||
useAgentStore.getState().setExecutingTool(sessionId, toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* llm:tool-result - LLM returned a tool result
|
||||
* Updates the tool message with the result
|
||||
*
|
||||
* Finds the tool message by multiple strategies:
|
||||
* 1. Direct match by toolCallId
|
||||
* 2. Message with id `tool-${callId}` or `approval-${callId}`
|
||||
* 3. Most recent pending tool message (fallback)
|
||||
*/
|
||||
function handleToolResult(event: EventByName<'llm:tool-result'>): void {
|
||||
const { sessionId, callId, success, sanitized, requireApproval, approvalStatus } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Try to find the tool message
|
||||
let message = callId ? chatStore.getMessageByToolCallId(sessionId, callId) : undefined;
|
||||
|
||||
// If not found by toolCallId, try by message ID patterns
|
||||
if (!message && callId) {
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
message = messages.find((m) => m.id === `tool-${callId}` || m.id === `approval-${callId}`);
|
||||
}
|
||||
|
||||
// If still not found, find the most recent pending tool message
|
||||
if (!message) {
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
const pendingTools = messages
|
||||
.filter((m) => m.role === 'tool' && m.toolResult === undefined)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Prioritize approval messages
|
||||
message = pendingTools.find((m) => m.id.startsWith('approval-')) || pendingTools[0];
|
||||
}
|
||||
|
||||
if (message) {
|
||||
// Update with result - include toolResultMeta for display data
|
||||
chatStore.updateMessage(sessionId, message.id, {
|
||||
toolResult: sanitized,
|
||||
toolResultMeta: sanitized?.meta,
|
||||
toolResultSuccess: success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
});
|
||||
} else {
|
||||
console.warn('[handlers] Could not find tool message to update for callId:', callId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* llm:error - LLM encountered an error
|
||||
* Sets error state and stops processing
|
||||
*/
|
||||
function handleLLMError(event: EventByName<'llm:error'>): void {
|
||||
const { sessionId, error, context, recoverable } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Set error in chat store
|
||||
chatStore.setError(sessionId, {
|
||||
id: generateMessageId(),
|
||||
message: error?.message || 'Unknown error',
|
||||
timestamp: Date.now(),
|
||||
context,
|
||||
recoverable,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
// Stop processing
|
||||
chatStore.setProcessing(sessionId, false);
|
||||
|
||||
// Update agent status
|
||||
useAgentStore.getState().setIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* approval:request - User approval requested
|
||||
* Adds approval to store, creates/updates tool message, and sets agent status to awaiting approval
|
||||
*
|
||||
* Creates a tool message with approval state so the UI can render approve/reject inline.
|
||||
*/
|
||||
function handleApprovalRequest(event: EventByName<'approval:request'>): void {
|
||||
const sessionId = event.sessionId || '';
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Finalize any streaming message to maintain proper sequence
|
||||
if (sessionId) {
|
||||
finalizeStreamingIfNeeded(sessionId);
|
||||
}
|
||||
|
||||
// The event IS the approval request
|
||||
useApprovalStore.getState().addApproval(event);
|
||||
|
||||
// Extract tool info from the approval event
|
||||
const approvalId = (event as any).approvalId;
|
||||
const toolName = (event as any).metadata?.toolName || (event as any).toolName || 'unknown';
|
||||
const toolArgs = (event as any).metadata?.args || (event as any).args || {};
|
||||
const approvalType = (event as any).type;
|
||||
|
||||
// Helper to strip prefixes for matching
|
||||
const stripPrefix = (name: string) =>
|
||||
name
|
||||
.replace(/^(internal--|custom--|mcp--[^-]+--|mcp__[^_]+__)/, '')
|
||||
.replace(/^(internal__|custom__)/, '');
|
||||
const cleanToolName = stripPrefix(toolName);
|
||||
|
||||
// Check if there's already a tool message for this approval
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
const existingToolMessage = messages.find((m) => {
|
||||
if (m.role !== 'tool' || m.toolResult !== undefined) return false;
|
||||
// Already has approval - skip
|
||||
if (m.requireApproval === true) return false;
|
||||
// Match by toolName (exact or stripped)
|
||||
if (m.toolName === toolName) return true;
|
||||
if (m.toolName && stripPrefix(m.toolName) === cleanToolName) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (existingToolMessage) {
|
||||
// Update existing tool message with approval info
|
||||
chatStore.updateMessage(sessionId, existingToolMessage.id, {
|
||||
requireApproval: true,
|
||||
approvalStatus: 'pending',
|
||||
});
|
||||
console.debug(
|
||||
'[handlers] Updated existing tool message with approval:',
|
||||
existingToolMessage.id
|
||||
);
|
||||
} else if (sessionId) {
|
||||
// Check if there's already a pending approval message to avoid duplicates
|
||||
const existingApprovalMessage = messages.find(
|
||||
(m) =>
|
||||
m.role === 'tool' &&
|
||||
m.requireApproval === true &&
|
||||
m.approvalStatus === 'pending' &&
|
||||
m.toolResult === undefined &&
|
||||
(m.toolName === toolName ||
|
||||
(m.toolName && stripPrefix(m.toolName) === cleanToolName))
|
||||
);
|
||||
|
||||
if (existingApprovalMessage) {
|
||||
console.debug(
|
||||
'[handlers] Approval message already exists:',
|
||||
existingApprovalMessage.id
|
||||
);
|
||||
} else {
|
||||
// Create a new tool message with approval state
|
||||
const approvalMessage = {
|
||||
id: `approval-${approvalId}`,
|
||||
role: 'tool' as const,
|
||||
content: null,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolCallId: approvalId, // Use approvalId as callId for correlation
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
requireApproval: true,
|
||||
approvalStatus: 'pending' as const,
|
||||
// Store approval metadata for rendering (elicitation, command, etc.)
|
||||
...(approvalType && { approvalType }),
|
||||
};
|
||||
chatStore.addMessage(sessionId, approvalMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Update agent status
|
||||
if (sessionId) {
|
||||
useAgentStore.getState().setAwaitingApproval(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* approval:response - User approval response received
|
||||
* Processes response in store, updates tool message status, and sets agent status back to thinking or idle
|
||||
*/
|
||||
function handleApprovalResponse(event: EventByName<'approval:response'>): void {
|
||||
const { status } = event;
|
||||
const sessionId = (event as any).sessionId || '';
|
||||
const approvalId = (event as any).approvalId;
|
||||
|
||||
// The event IS the approval response
|
||||
useApprovalStore.getState().processResponse(event);
|
||||
|
||||
// Update the tool message's approval status for audit trail
|
||||
if (sessionId && approvalId) {
|
||||
const chatStore = useChatStore.getState();
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
|
||||
// Find the approval message by ID pattern
|
||||
const approvalMessage = messages.find(
|
||||
(m) =>
|
||||
m.id === `approval-${approvalId}` ||
|
||||
(m.toolCallId === approvalId && m.requireApproval)
|
||||
);
|
||||
|
||||
if (approvalMessage) {
|
||||
const approvalStatus =
|
||||
status === ('approved' as ApprovalStatus) ? 'approved' : 'rejected';
|
||||
chatStore.updateMessage(sessionId, approvalMessage.id, {
|
||||
approvalStatus,
|
||||
// Mark rejected approvals as failed so UI shows error state
|
||||
...(approvalStatus === 'rejected' && { toolResultSuccess: false }),
|
||||
});
|
||||
console.debug(
|
||||
'[handlers] Updated approval status:',
|
||||
approvalMessage.id,
|
||||
approvalStatus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update agent status based on approval
|
||||
// ApprovalStatus.APPROVED means approved, others mean rejected/cancelled
|
||||
const approved = status === ('approved' as ApprovalStatus);
|
||||
|
||||
if (approved) {
|
||||
// Agent resumes execution after approval - set to thinking since it's actively processing.
|
||||
// Don't set to idle (agent isn't idle) or keep at awaiting_approval (no longer waiting).
|
||||
if (sessionId) {
|
||||
useAgentStore.getState().setThinking(sessionId);
|
||||
}
|
||||
} else {
|
||||
// Rejected/cancelled - go idle and stop processing
|
||||
useAgentStore.getState().setIdle();
|
||||
// Also stop processing since the run may be terminated
|
||||
if (sessionId) {
|
||||
useChatStore.getState().setProcessing(sessionId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* run:complete - Agent run completed
|
||||
* Sets processing=false and agent status to idle
|
||||
*/
|
||||
function handleRunComplete(event: EventByName<'run:complete'>): void {
|
||||
const { sessionId } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Stop processing
|
||||
chatStore.setProcessing(sessionId, false);
|
||||
|
||||
// Update agent status
|
||||
useAgentStore.getState().setIdle();
|
||||
}
|
||||
|
||||
/**
|
||||
* session:title-updated - Session title updated
|
||||
* Handled by TanStack Query invalidation, placeholder for completeness
|
||||
*/
|
||||
function handleSessionTitleUpdated(event: EventByName<'session:title-updated'>): void {
|
||||
// This is handled by TanStack Query invalidation
|
||||
// Placeholder for registry completeness
|
||||
console.debug('[handlers] session:title-updated', event.sessionId, event.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* message:dequeued - Queued message was dequeued
|
||||
* Adds user message to chat (from queue)
|
||||
*/
|
||||
function handleMessageDequeued(event: EventByName<'message:dequeued'>): void {
|
||||
const { sessionId, content } = event;
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
// Extract text from content parts
|
||||
const textContent = content
|
||||
.filter((part): part is Extract<typeof part, { type: 'text' }> => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n');
|
||||
|
||||
// Extract image attachment if present
|
||||
const imagePart = content.find(
|
||||
(part): part is Extract<typeof part, { type: 'image' }> => part.type === 'image'
|
||||
);
|
||||
|
||||
// Extract file attachment if present
|
||||
const filePart = content.find(
|
||||
(part): part is Extract<typeof part, { type: 'file' }> => part.type === 'file'
|
||||
);
|
||||
|
||||
if (textContent || content.length > 0) {
|
||||
// Create user message
|
||||
// Note: Only include imageData if image is a string (base64 or URL)
|
||||
const imageDataValue =
|
||||
imagePart && typeof imagePart.image === 'string'
|
||||
? {
|
||||
image: imagePart.image,
|
||||
mimeType: imagePart.mimeType ?? 'image/jpeg',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const userMessage = {
|
||||
id: generateMessageId(),
|
||||
role: 'user' as const,
|
||||
content: textContent || '[attachment]',
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
imageData: imageDataValue,
|
||||
fileData: filePart
|
||||
? {
|
||||
data: typeof filePart.data === 'string' ? filePart.data : '',
|
||||
mimeType: filePart.mimeType,
|
||||
filename: filePart.filename,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
chatStore.addMessage(sessionId, userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* context:compacted - Context was compacted (inline compaction)
|
||||
* Log for now (future: add to activity store)
|
||||
*/
|
||||
function handleContextCompacted(event: EventByName<'context:compacted'>): void {
|
||||
console.debug(
|
||||
`[handlers] Context compacted: ${event.originalTokens.toLocaleString()} → ${event.compactedTokens.toLocaleString()} tokens (${event.originalMessages} → ${event.compactedMessages} messages) via ${event.strategy}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* service:event - Extensible service event for non-core services
|
||||
* Handles agent-spawner progress events and todo update events
|
||||
*/
|
||||
function handleServiceEvent(event: EventByName<'service:event'>): void {
|
||||
const { service, event: eventType, toolCallId, sessionId, data } = event;
|
||||
|
||||
// Handle agent-spawner progress events
|
||||
if (service === 'agent-spawner' && eventType === 'progress' && toolCallId && sessionId) {
|
||||
const chatStore = useChatStore.getState();
|
||||
const progressData = data as {
|
||||
task: string;
|
||||
agentId: string;
|
||||
toolsCalled: number;
|
||||
currentTool: string;
|
||||
currentArgs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Find and update the tool message
|
||||
const messages = chatStore.getMessages(sessionId);
|
||||
const toolMessage = messages.find((m) => m.role === 'tool' && m.toolCallId === toolCallId);
|
||||
|
||||
if (toolMessage) {
|
||||
chatStore.updateMessage(sessionId, toolMessage.id, {
|
||||
subAgentProgress: {
|
||||
task: progressData.task,
|
||||
agentId: progressData.agentId,
|
||||
toolsCalled: progressData.toolsCalled,
|
||||
currentTool: progressData.currentTool,
|
||||
currentArgs: progressData.currentArgs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle todo update events
|
||||
if (service === 'todo' && eventType === 'updated' && sessionId) {
|
||||
const todoData = data as {
|
||||
todos: Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
content: string;
|
||||
activeForm: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
position: number;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}>;
|
||||
stats: { created: number; updated: number; deleted: number };
|
||||
};
|
||||
|
||||
// Update todo store with new todos
|
||||
useTodoStore.getState().setTodos(sessionId, todoData.todos);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registry Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Register all handlers in the registry
|
||||
* Call this once during initialization
|
||||
*/
|
||||
export function registerHandlers(): void {
|
||||
// Clear existing handlers
|
||||
handlers.clear();
|
||||
|
||||
// Register each handler
|
||||
handlers.set('llm:thinking', handleLLMThinking);
|
||||
handlers.set('llm:chunk', handleLLMChunk);
|
||||
handlers.set('llm:response', handleLLMResponse);
|
||||
handlers.set('llm:tool-call', handleToolCall);
|
||||
handlers.set('llm:tool-result', handleToolResult);
|
||||
handlers.set('llm:error', handleLLMError);
|
||||
handlers.set('approval:request', handleApprovalRequest);
|
||||
handlers.set('approval:response', handleApprovalResponse);
|
||||
handlers.set('run:complete', handleRunComplete);
|
||||
handlers.set('session:title-updated', handleSessionTitleUpdated);
|
||||
handlers.set('message:dequeued', handleMessageDequeued);
|
||||
handlers.set('context:compacted', handleContextCompacted);
|
||||
handlers.set('service:event', handleServiceEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a handler for a specific event name
|
||||
*
|
||||
* @param name - Event name
|
||||
* @returns Handler function or undefined if not registered
|
||||
*/
|
||||
export function getHandler(name: string): EventHandler | undefined {
|
||||
return handlers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for the EventBus
|
||||
* Registers all handlers and subscribes them to the bus
|
||||
*
|
||||
* @param bus - ClientEventBus instance
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const bus = useEventBus();
|
||||
* useEffect(() => {
|
||||
* const cleanup = setupEventHandlers(bus);
|
||||
* return cleanup;
|
||||
* }, [bus]);
|
||||
* ```
|
||||
*/
|
||||
export function setupEventHandlers(bus: ClientEventBus): () => void {
|
||||
// Register handlers
|
||||
registerHandlers();
|
||||
|
||||
// Subscribe each handler to the bus
|
||||
const subscriptions: Array<{ unsubscribe: () => void }> = [];
|
||||
|
||||
handlers.forEach((handler, eventName) => {
|
||||
// Cast to any to bypass strict typing - handlers map uses string keys
|
||||
// but bus.on expects specific event names. This is safe because
|
||||
// registerHandlers() only adds valid event names.
|
||||
const subscription = bus.on(eventName as any, handler);
|
||||
subscriptions.push(subscription);
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
subscriptions.forEach((sub) => sub.unsubscribe());
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exports
|
||||
// =============================================================================
|
||||
|
||||
// Export individual handlers for testing
|
||||
export {
|
||||
handleLLMThinking,
|
||||
handleLLMChunk,
|
||||
handleLLMResponse,
|
||||
handleToolCall,
|
||||
handleToolResult,
|
||||
handleLLMError,
|
||||
handleApprovalRequest,
|
||||
handleApprovalResponse,
|
||||
handleRunComplete,
|
||||
handleSessionTitleUpdated,
|
||||
handleMessageDequeued,
|
||||
handleContextCompacted,
|
||||
handleServiceEvent,
|
||||
};
|
||||
56
dexto/packages/webui/lib/events/index.ts
Normal file
56
dexto/packages/webui/lib/events/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Client Event Bus
|
||||
*
|
||||
* Centralized event system for the WebUI.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { eventBus, loggingMiddleware } from '@/lib/events';
|
||||
*
|
||||
* // Configure middleware
|
||||
* eventBus.use(loggingMiddleware);
|
||||
*
|
||||
* // Subscribe to events
|
||||
* eventBus.on('llm:chunk', (event) => {
|
||||
* console.log('Chunk:', event.content);
|
||||
* });
|
||||
*
|
||||
* // Dispatch events
|
||||
* eventBus.dispatch({ name: 'llm:thinking', sessionId: 'abc' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Core event bus
|
||||
export { ClientEventBus, eventBus } from './EventBus.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
StreamingEvent,
|
||||
StreamingEventName,
|
||||
EventByName,
|
||||
ClientEvent,
|
||||
ClientEventName,
|
||||
EventMiddleware,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
ConnectionStatusEvent,
|
||||
} from './types.js';
|
||||
|
||||
export { isEventType, isConnectionStatusEvent } from './types.js';
|
||||
|
||||
// Middleware
|
||||
export {
|
||||
loggingMiddleware,
|
||||
createLoggingMiddleware,
|
||||
configureLogging,
|
||||
resetLoggingConfig,
|
||||
activityMiddleware,
|
||||
notificationMiddleware,
|
||||
type LoggingConfig,
|
||||
} from './middleware/index.js';
|
||||
|
||||
// Event Handlers
|
||||
export { registerHandlers, getHandler, setupEventHandlers } from './handlers.js';
|
||||
|
||||
// Hooks
|
||||
export { useEventDispatch } from './useEventDispatch.js';
|
||||
512
dexto/packages/webui/lib/events/integration.test.ts
Normal file
512
dexto/packages/webui/lib/events/integration.test.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
103
dexto/packages/webui/lib/events/types.ts
Normal file
103
dexto/packages/webui/lib/events/types.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Event types for the WebUI client event bus
|
||||
*
|
||||
* Uses StreamingEvent from @dexto/core as the source of truth.
|
||||
* Only client-only events (like connection status) are defined locally.
|
||||
*/
|
||||
|
||||
import type { StreamingEvent, StreamingEventName } from '@dexto/core';
|
||||
|
||||
// Re-export core types for convenience
|
||||
export type { StreamingEvent, StreamingEventName };
|
||||
|
||||
/**
|
||||
* Type helper to extract a specific event by its name
|
||||
*
|
||||
* @example
|
||||
* type ChunkEvent = EventByName<'llm:chunk'>;
|
||||
* // { name: 'llm:chunk', content: string, chunkType: 'text' | 'reasoning', sessionId: string, ... }
|
||||
*/
|
||||
export type EventByName<T extends StreamingEventName> = Extract<StreamingEvent, { name: T }>;
|
||||
|
||||
/**
|
||||
* Client-only event for connection status changes
|
||||
* This event is generated locally, not from the server
|
||||
*/
|
||||
export interface ConnectionStatusEvent {
|
||||
name: 'connection:status';
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all events that can flow through the client event bus
|
||||
* Includes server events (StreamingEvent) + client-only events
|
||||
*/
|
||||
export type ClientEvent = StreamingEvent | ConnectionStatusEvent;
|
||||
|
||||
/**
|
||||
* Extract the event name from a ClientEvent
|
||||
*/
|
||||
export type ClientEventName = ClientEvent['name'];
|
||||
|
||||
/**
|
||||
* Middleware function signature
|
||||
*
|
||||
* Middleware receives an event and a next function.
|
||||
* It can:
|
||||
* - Pass the event through: next(event)
|
||||
* - Modify the event: next({ ...event, modified: true })
|
||||
* - Block the event: don't call next()
|
||||
* - Perform side effects (logging, notifications, etc.)
|
||||
*
|
||||
* @example
|
||||
* const loggingMiddleware: EventMiddleware = (event, next) => {
|
||||
* console.log('Event:', event.name);
|
||||
* next(event);
|
||||
* };
|
||||
*/
|
||||
export type EventMiddleware = (event: ClientEvent, next: (event: ClientEvent) => void) => void;
|
||||
|
||||
/**
|
||||
* Event handler function signature
|
||||
*
|
||||
* Handlers are called after middleware processing completes.
|
||||
* They receive the final event and perform state updates.
|
||||
*
|
||||
* @example
|
||||
* const handleChunk: EventHandler<EventByName<'llm:chunk'>> = (event) => {
|
||||
* chatStore.appendToStreamingMessage(event.sessionId, event.content);
|
||||
* };
|
||||
*/
|
||||
export type EventHandler<T extends ClientEvent = ClientEvent> = (event: T) => void;
|
||||
|
||||
/**
|
||||
* Subscription returned when registering a handler
|
||||
* Call unsubscribe() to remove the handler
|
||||
*/
|
||||
export interface EventSubscription {
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an event is a specific type
|
||||
*
|
||||
* @example
|
||||
* if (isEventType(event, 'llm:chunk')) {
|
||||
* // event is narrowed to EventByName<'llm:chunk'>
|
||||
* console.log(event.content);
|
||||
* }
|
||||
*/
|
||||
export function isEventType<T extends ClientEventName>(
|
||||
event: ClientEvent,
|
||||
name: T
|
||||
): event is Extract<ClientEvent, { name: T }> {
|
||||
return event.name === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for connection status events (client-only)
|
||||
*/
|
||||
export function isConnectionStatusEvent(event: ClientEvent): event is ConnectionStatusEvent {
|
||||
return event.name === 'connection:status';
|
||||
}
|
||||
42
dexto/packages/webui/lib/events/useEventDispatch.ts
Normal file
42
dexto/packages/webui/lib/events/useEventDispatch.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Event Dispatch Hook
|
||||
*
|
||||
* Provides a hook for components to dispatch SSE events to the event bus.
|
||||
* Use this when receiving events from the message stream or other SSE sources.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { StreamingEvent } from '@dexto/core';
|
||||
import { useEventBus } from '@/components/providers/EventBusProvider.js';
|
||||
|
||||
/**
|
||||
* Hook to dispatch SSE events to the event bus
|
||||
*
|
||||
* @returns Object with dispatchEvent function
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MessageStream() {
|
||||
* const { dispatchEvent } = useEventDispatch();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* const eventSource = createMessageStream(responsePromise);
|
||||
* for await (const event of eventSource) {
|
||||
* dispatchEvent(event); // Dispatches to event bus
|
||||
* }
|
||||
* }, [dispatchEvent]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useEventDispatch() {
|
||||
const bus = useEventBus();
|
||||
|
||||
const dispatchEvent = useCallback(
|
||||
(event: StreamingEvent) => {
|
||||
bus.dispatch(event);
|
||||
},
|
||||
[bus]
|
||||
);
|
||||
|
||||
return { dispatchEvent };
|
||||
}
|
||||
Reference in New Issue
Block a user