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