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:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View 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);
});
});
});

View 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();

View 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

View 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.

View 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();
});
});
});

View 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,
};

View 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';

View 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();
});
});
});

View 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',
});
});
});
});

View 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 },
});
}
};

View 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';

View 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);
}
};
}

View 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);
});
});
});

View 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);
}
};

View 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';
}

View 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 };
}