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,149 @@
# MCP Server Registry
This directory contains the MCP (Model Context Protocol) server registry system for Dexto.
## Structure
- `serverRegistry.ts` - The main registry service that manages MCP server entries
- `server-registry-data.json` - External JSON file containing all built-in server definitions
## Adding New MCP Servers
To add a new MCP server to the registry, simply edit the `server-registry-data.json` file. No code changes are required!
### Server Entry Format
Each server entry should follow this structure:
```json
{
"id": "unique-server-id",
"name": "Display Name",
"description": "Brief description of what this server does",
"category": "productivity|research|creative|development",
"icon": "📁",
"config": {
"type": "stdio|http|sse",
"command": "npx|uvx|python",
"args": ["-y", "package-name"],
"env": {
"API_KEY": ""
},
"timeout": 30000
},
"tags": ["tag1", "tag2"],
"isOfficial": true,
"isInstalled": false,
"requirements": {
"platform": "all|windows|mac|linux",
"node": ">=18.0.0",
"python": ">=3.10"
},
"author": "Author Name",
"homepage": "https://github.com/author/repo",
"matchIds": ["server-id", "alternative-id"]
}
```
### Configuration Types
#### Stdio (Node.js/npm)
```json
{
"type": "stdio",
"command": "npx",
"args": ["-y", "package-name"],
"env": {
"API_KEY": ""
}
}
```
#### Stdio (Python/uvx)
```json
{
"type": "stdio",
"command": "uvx",
"args": ["package-name"]
}
```
#### HTTP/SSE
```json
{
"type": "http",
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer $API_KEY"
}
}
```
### Categories
- **productivity** - File operations, task management, workflow tools
- **research** - Search, data analysis, information gathering
- **creative** - Image editing, music creation, content generation
- **development** - Code analysis, debugging, development tools
### Icons
Use appropriate emojis for each server type:
- 📁 File operations
- 🔍 Search/research
- 🖼️ Image/media
- 🎵 Audio/music
- 🌐 Web/browser
- 📋 Task management
- 🤗 AI/ML models
## Benefits of External JSON
1. **No rebuilds required** - Add servers by editing JSON only
2. **Easy maintenance** - All server data in one place
3. **Version control friendly** - Track server additions in git
4. **Non-developer friendly** - Anyone can add servers without touching code
5. **Consistent structure** - Enforced schema for all entries
## Example: Adding Tavily Search
```json
{
"id": "tavily",
"name": "Tavily Search",
"description": "Web search and research using Tavily AI search engine",
"category": "research",
"icon": "🔍",
"config": {
"type": "stdio",
"command": "npx",
"args": ["-y", "tavily-mcp@0.1.3"],
"env": {
"TAVILY_API_KEY": ""
},
"timeout": 30000
},
"tags": ["search", "web", "research", "ai"],
"isOfficial": false,
"isInstalled": false,
"requirements": { "platform": "all", "node": ">=18.0.0" },
"author": "Tavily AI",
"homepage": "https://www.npmjs.com/package/tavily-mcp",
"matchIds": ["tavily"]
}
```
## Testing
After adding a new server to the JSON file:
1. Restart the Dexto WebUI
2. Navigate to the server registry
3. Verify the new server appears in the list
4. Test adding it to an agent
## Troubleshooting
- **Server not appearing**: Check JSON syntax and restart WebUI
- **Import errors**: Ensure the JSON file is valid and accessible
- **Type errors**: Verify the server entry matches the expected schema

View File

@@ -0,0 +1,47 @@
// Non-React capture helpers for use in event handlers.
// These functions work outside of React context (e.g., in handlers.ts).
import posthog from 'posthog-js';
import type { LLMTokensConsumedEvent } from '@dexto/analytics';
/**
* Check if analytics is enabled by looking for the injected config.
*/
function isAnalyticsEnabled(): boolean {
if (typeof window === 'undefined') return false;
return !!(window as any).__DEXTO_ANALYTICS__;
}
/**
* Get base context properties included with every event.
*/
function getBaseContext() {
if (typeof window === 'undefined') {
return { app: 'dexto-webui' };
}
const config = (window as any).__DEXTO_ANALYTICS__;
return {
app: 'dexto-webui',
app_version: config?.appVersion ?? 'unknown',
};
}
/**
* Capture LLM token usage event.
* Called from event handlers (non-React context).
*
* @param params - Token usage data (source is automatically set to 'webui')
*/
export function captureTokenUsage(params: Omit<LLMTokensConsumedEvent, 'source'>): void {
if (!isAnalyticsEnabled()) return;
try {
posthog.capture('dexto_llm_tokens_consumed', {
...getBaseContext(),
source: 'webui',
...params,
});
} catch {
// Analytics should never break the app
}
}

View File

@@ -0,0 +1,38 @@
// WebUI analytics event definitions for PostHog.
// These events track user activation, retention, and feature usage.
//
// All events use unified names (dexto_*) with a `source` property to
// distinguish CLI vs WebUI. This enables simpler PostHog dashboards.
import type { SharedAnalyticsEventMap, FileAttachedEvent } from '@dexto/analytics';
/**
* Base context automatically included with every WebUI event.
* Populated by the AnalyticsProvider.
*/
export interface BaseEventContext {
app?: 'dexto-webui';
app_version?: string;
browser?: string;
browser_version?: string;
os?: string;
screen_width?: number;
screen_height?: number;
session_id?: string;
}
/**
* WebUI analytics event map extending shared events with WebUI-specific events.
*
* IMPORTANT: If an event is also tracked by CLI, move it to SharedAnalyticsEventMap
* in @dexto/analytics to avoid duplication.
*/
export interface WebUIAnalyticsEventMap extends SharedAnalyticsEventMap {
// WebUI-specific events (not supported by CLI)
dexto_file_attached: FileAttachedEvent;
}
export type WebUIAnalyticsEventName = keyof WebUIAnalyticsEventMap;
export type WebUIAnalyticsEventPayload<Name extends WebUIAnalyticsEventName> =
WebUIAnalyticsEventMap[Name];

View File

@@ -0,0 +1,210 @@
// Convenience hook for tracking analytics events in WebUI components.
//
// Uses shared event names (dexto_*) with source: 'webui' for unified dashboards.
import { useCallback, useRef } from 'react';
import { useAnalyticsContext } from './provider.js';
import type {
MessageSentEvent,
SessionCreatedEvent,
SessionResetEvent,
ToolCalledEvent,
ToolResultEvent,
LLMSwitchedEvent,
SessionSwitchedEvent,
AgentSwitchedEvent,
FileAttachedEvent,
ImageAttachedEvent,
MCPServerConnectedEvent,
} from '@dexto/analytics';
export interface UseAnalyticsReturn {
capture: ReturnType<typeof useAnalyticsContext>['capture'];
enabled: boolean;
isReady: boolean;
// Convenience tracking methods (source: 'webui' added automatically)
trackMessageSent: (params: Omit<MessageSentEvent, 'messageCount' | 'source'>) => void;
trackSessionCreated: (params: Omit<SessionCreatedEvent, 'source'>) => void;
trackSessionSwitched: (params: Omit<SessionSwitchedEvent, 'source'>) => void;
trackSessionReset: (params: Omit<SessionResetEvent, 'source'>) => void;
trackAgentSwitched: (params: Omit<AgentSwitchedEvent, 'source'>) => void;
trackToolCalled: (params: Omit<ToolCalledEvent, 'source'>) => void;
trackToolResult: (params: Omit<ToolResultEvent, 'source'>) => void;
trackLLMSwitched: (params: Omit<LLMSwitchedEvent, 'source'>) => void;
trackFileAttached: (params: Omit<FileAttachedEvent, 'source'>) => void;
trackImageAttached: (params: Omit<ImageAttachedEvent, 'source'>) => void;
trackMCPServerConnected: (params: Omit<MCPServerConnectedEvent, 'source'>) => void;
}
/**
* Hook for tracking analytics events with convenience methods.
* Automatically handles message counting per session.
*/
export function useAnalytics(): UseAnalyticsReturn {
const { capture, enabled, isReady } = useAnalyticsContext();
// Track message count per session
const messageCountRef = useRef<Record<string, number>>({});
const trackMessageSent = useCallback(
(params: Omit<MessageSentEvent, 'messageCount' | 'source'>) => {
if (!enabled) return;
try {
// Increment message count for this session
const sessionId = params.sessionId;
messageCountRef.current[sessionId] = (messageCountRef.current[sessionId] || 0) + 1;
capture('dexto_message_sent', {
source: 'webui',
...params,
messageCount: messageCountRef.current[sessionId],
});
} catch (error) {
// Analytics should never break the app - fail silently
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackSessionCreated = useCallback(
(params: Omit<SessionCreatedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_session_created', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackSessionSwitched = useCallback(
(params: Omit<SessionSwitchedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_session_switched', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackSessionReset = useCallback(
(params: Omit<SessionResetEvent, 'source'>) => {
if (!enabled) return;
try {
// Reset message count for this session
messageCountRef.current[params.sessionId] = 0;
capture('dexto_session_reset', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackAgentSwitched = useCallback(
(params: Omit<AgentSwitchedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_agent_switched', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackToolCalled = useCallback(
(params: Omit<ToolCalledEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_tool_called', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackToolResult = useCallback(
(params: Omit<ToolResultEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_tool_result', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackLLMSwitched = useCallback(
(params: Omit<LLMSwitchedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_llm_switched', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackFileAttached = useCallback(
(params: Omit<FileAttachedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_file_attached', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackImageAttached = useCallback(
(params: Omit<ImageAttachedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_image_attached', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
const trackMCPServerConnected = useCallback(
(params: Omit<MCPServerConnectedEvent, 'source'>) => {
if (!enabled) return;
try {
capture('dexto_mcp_server_connected', { source: 'webui', ...params });
} catch (error) {
console.warn('Analytics tracking failed:', error);
}
},
[capture, enabled]
);
return {
capture,
enabled,
isReady,
trackMessageSent,
trackSessionCreated,
trackSessionSwitched,
trackSessionReset,
trackAgentSwitched,
trackToolCalled,
trackToolResult,
trackLLMSwitched,
trackFileAttached,
trackImageAttached,
trackMCPServerConnected,
};
}

View File

@@ -0,0 +1,15 @@
// Main exports for WebUI analytics library.
//
// All event types should be imported directly from @dexto/analytics.
export { AnalyticsProvider, useAnalyticsContext } from './provider.js';
export { useAnalytics } from './hook.js';
export { captureTokenUsage } from './capture.js';
// WebUI-specific type maps (for type-safe capture)
export type {
WebUIAnalyticsEventName,
WebUIAnalyticsEventPayload,
WebUIAnalyticsEventMap,
BaseEventContext,
} from './events.js';

View File

@@ -0,0 +1,145 @@
// packages/webui/lib/analytics/provider.tsx
// React Context Provider for WebUI analytics using PostHog JS SDK.
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import posthog from 'posthog-js';
import type {
WebUIAnalyticsEventName,
WebUIAnalyticsEventPayload,
BaseEventContext,
} from './events.js';
interface AnalyticsConfig {
distinctId: string;
posthogKey: string;
posthogHost: string;
appVersion: string;
}
interface AnalyticsContextType {
capture: <Name extends WebUIAnalyticsEventName>(
event: Name,
properties?: WebUIAnalyticsEventPayload<Name>
) => void;
enabled: boolean;
isReady: boolean;
}
const AnalyticsContext = createContext<AnalyticsContextType | undefined>(undefined);
interface AnalyticsProviderProps {
children: ReactNode;
}
/**
* Get analytics config injected during app initialization.
* Returns null if analytics disabled or config not available.
*/
function getAnalyticsConfig(): AnalyticsConfig | null {
if (typeof window === 'undefined') return null;
return (window as any).__DEXTO_ANALYTICS__ ?? null;
}
/**
* Get base context properties included with every event.
*/
function getBaseContext(): BaseEventContext {
if (typeof window === 'undefined') {
return {
app: 'dexto-webui',
};
}
return {
app: 'dexto-webui',
app_version: getAnalyticsConfig()?.appVersion ?? 'unknown',
browser: navigator.userAgent.split(' ').pop()?.split('/')[0],
browser_version: navigator.userAgent.split(' ').pop()?.split('/')[1],
os: navigator.platform,
screen_width: window.screen.width,
screen_height: window.screen.height,
// session_id will be managed by PostHog automatically
};
}
export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
const [enabled, setEnabled] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const config = getAnalyticsConfig();
if (!config) {
// Analytics disabled or config not available
setEnabled(false);
setIsReady(true);
return;
}
try {
// Initialize PostHog
posthog.init(config.posthogKey, {
api_host: config.posthogHost,
person_profiles: 'identified_only', // Only create profiles for identified users
loaded: (posthogInstance) => {
// Use the distinct ID from CLI (unified tracking)
posthogInstance.identify(config.distinctId);
setEnabled(true);
setIsReady(true);
},
autocapture: false, // Disable automatic event capture
capture_pageview: false, // We'll manually track page views for better control
disable_session_recording: true, // Disable session replay (privacy)
disable_surveys: true, // Disable surveys
opt_out_capturing_by_default: false,
});
} catch (error) {
console.error('Failed to initialize analytics:', error);
setEnabled(false);
setIsReady(true);
}
// Cleanup on unmount - always reset to clear identity
return () => {
try {
posthog.reset(); // Always clear identity on unmount
} catch {
// Ignore errors if PostHog wasn't initialized
}
};
}, []); // Run once on mount
const capture = <Name extends WebUIAnalyticsEventName>(
event: Name,
properties?: WebUIAnalyticsEventPayload<Name>
) => {
if (!enabled || !isReady) return;
try {
posthog.capture(event, {
...getBaseContext(),
...properties,
});
} catch (error) {
console.error('Failed to capture analytics event:', error);
}
};
return (
<AnalyticsContext.Provider value={{ capture, enabled, isReady }}>
{children}
</AnalyticsContext.Provider>
);
}
/**
* Hook to access analytics from any component.
* Must be used within an AnalyticsProvider.
*/
export function useAnalyticsContext(): AnalyticsContextType {
const context = useContext(AnalyticsContext);
if (!context) {
throw new Error('useAnalyticsContext must be used within an AnalyticsProvider');
}
return context;
}

View File

@@ -0,0 +1,178 @@
/**
* API Error Handling Utilities
*
* Extracts error messages from Dexto API responses which can be in multiple formats:
* 1. DextoRuntimeError: { code, message, scope, type, context?, recovery?, traceId }
* 2. DextoValidationError: { name, message, issues[], traceId }
* 3. Wrapped errors: { message, context: { issues: [...] }, ... }
* 4. Hono OpenAPI errors: { success: false, error: { issues: [...] } }
*
* Priority order for extraction:
* 1. context.issues[0].message (wrapped validation errors)
* 2. issues[0].message (direct validation errors)
* 3. error.issues[0].message (Hono OpenAPI validation errors)
* 4. error (some routes use this as a string)
* 5. message (standard field)
* 6. Fallback message
*/
/** Shape of a single validation issue from core */
export interface DextoIssue {
code: string;
message: string;
scope: string;
type: string;
severity: 'error' | 'warning';
path?: Array<string | number>;
context?: unknown;
}
/** DextoRuntimeError response shape */
export interface DextoRuntimeErrorResponse {
code: string;
message: string;
scope: string;
type: string;
context?: {
issues?: DextoIssue[];
[key: string]: unknown;
};
recovery?: string | string[];
traceId: string;
endpoint?: string;
method?: string;
}
/** DextoValidationError response shape */
export interface DextoValidationErrorResponse {
name: 'DextoValidationError';
message: string;
issues: DextoIssue[];
traceId: string;
errorCount: number;
warningCount: number;
endpoint?: string;
method?: string;
}
/** Union of possible error response shapes */
export type DextoErrorResponse =
| DextoRuntimeErrorResponse
| DextoValidationErrorResponse
| { error?: string; message?: string; [key: string]: unknown };
/**
* Extract the most relevant error message from a Dexto API error response
*
* @param errorData - The parsed JSON error response from the API
* @param fallback - Fallback message if no error can be extracted
* @returns The most specific error message available
*
* @example
* ```ts
* const res = await fetch('/api/agents/switch', {...});
* if (!res.ok) {
* const errorData = await res.json().catch(() => ({}));
* const message = extractErrorMessage(errorData, 'Failed to switch agent');
* throw new Error(message);
* }
* ```
*/
export function extractErrorMessage(
errorData: Partial<DextoErrorResponse>,
fallback: string
): string {
// Priority 1: Check for wrapped validation errors (context.issues[0].message)
// This handles DextoRuntimeError wrapping DextoValidationError
const runtimeError = errorData as Partial<DextoRuntimeErrorResponse>;
if (runtimeError.context?.issues && Array.isArray(runtimeError.context.issues)) {
const firstIssue = runtimeError.context.issues[0];
if (firstIssue?.message) {
return firstIssue.message;
}
}
// Priority 2: Check for direct validation errors (issues[0].message)
// This handles unwrapped DextoValidationError
const issues = (errorData as DextoValidationErrorResponse).issues;
if (issues && Array.isArray(issues)) {
const firstIssue = issues[0];
if (firstIssue?.message) {
return firstIssue.message;
}
}
// Priority 3: Check for Hono OpenAPI validation errors (error.issues[0].message)
// Hono's Zod validation returns { success: false, error: { issues: [...] } }
const honoError = (errorData as any).error;
if (honoError && typeof honoError === 'object' && Array.isArray(honoError.issues)) {
const firstIssue = honoError.issues[0];
if (firstIssue?.message) {
return firstIssue.message;
}
}
// Priority 4: Check for generic error field as string (some routes use this)
if (typeof honoError === 'string' && honoError.length > 0) {
return honoError;
}
// Priority 5: Check for message field (standard field on all error types)
if (typeof errorData.message === 'string' && errorData.message.length > 0) {
return errorData.message;
}
// Priority 6: Fallback
return fallback;
}
/**
* Extract full error details for logging/debugging
*
* @param errorData - The parsed JSON error response
* @returns Object with all available error information
*/
export function extractErrorDetails(errorData: Partial<DextoErrorResponse>): {
message: string;
code?: string;
scope?: string;
type?: string;
traceId?: string;
recovery?: string | string[];
issues?: DextoIssue[];
endpoint?: string;
method?: string;
} {
const code = (errorData as DextoRuntimeErrorResponse).code;
const scope = (errorData as DextoRuntimeErrorResponse).scope;
const type = (errorData as DextoRuntimeErrorResponse).type;
const traceId = (errorData as DextoRuntimeErrorResponse | DextoValidationErrorResponse).traceId;
const recovery = (errorData as DextoRuntimeErrorResponse).recovery;
const endpoint = (errorData as DextoRuntimeErrorResponse | DextoValidationErrorResponse)
.endpoint;
const method = (errorData as DextoRuntimeErrorResponse | DextoValidationErrorResponse).method;
// Get issues from either wrapped or direct validation errors or Hono OpenAPI errors
let issues: DextoIssue[] | undefined;
const runtimeErr = errorData as Partial<DextoRuntimeErrorResponse>;
if (runtimeErr.context?.issues) {
issues = runtimeErr.context.issues;
} else if ((errorData as DextoValidationErrorResponse).issues) {
issues = (errorData as DextoValidationErrorResponse).issues;
} else if ((errorData as any).error?.issues) {
// Handle Hono OpenAPI validation errors
issues = (errorData as any).error.issues;
}
return {
message: extractErrorMessage(errorData, 'An error occurred'),
code,
scope,
type,
traceId,
recovery,
issues,
endpoint,
method,
};
}

View File

@@ -0,0 +1,15 @@
/**
* Get the API URL for making requests.
*
* In production: WebUI is served from the same Hono server as the API (same-origin).
* In development: Vite proxies /api/* requests to the API server (still same-origin from browser perspective).
*/
export function getApiUrl(): string {
if (typeof window === 'undefined') {
// SSR fallback (not used in Vite, but kept for safety)
return 'http://localhost:3001';
}
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}

View File

@@ -0,0 +1,10 @@
import { createDextoClient } from '@dexto/client-sdk';
import { getApiUrl } from './api-url';
/**
* Centralized typed API client for the Web UI.
* Uses the Hono typed client from @dexto/client-sdk.
*/
export const client = createDextoClient({
baseUrl: getApiUrl(),
});

View File

@@ -0,0 +1,111 @@
/**
* Date formatting utilities using native Intl API
* Zero-bundle-cost alternative to date-fns
*
* Browser support: All modern browsers (2020+)
*/
/**
* Format date in locale-specific format
* @example formatDate(Date.now()) → "Nov 3, 2025"
*/
export function formatDate(timestamp: number, locale = 'en'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(timestamp);
}
/**
* Format time in locale-specific format
* @example formatTime(Date.now()) → "8:30 PM"
*/
export function formatTime(timestamp: number, locale = 'en'): string {
return new Intl.DateTimeFormat(locale, {
hour: 'numeric',
minute: '2-digit',
}).format(timestamp);
}
/**
* Format date and time together
* @example formatDateTime(Date.now()) → "Nov 3, 2025, 8:30 PM"
*/
export function formatDateTime(timestamp: number, locale = 'en'): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(timestamp);
}
/**
* Format relative time (e.g., "2 hours ago", "in 3 days")
* @example formatRelativeTime(Date.now() - 7200000) → "2 hours ago"
*/
export function formatRelativeTime(timestamp: number, locale = 'en'): string {
const now = Date.now();
const diff = timestamp - now;
const absDiff = Math.abs(diff);
// Define time units in milliseconds
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
const month = 30 * day;
const year = 365 * day;
let value: number;
let unit: Intl.RelativeTimeFormatUnit;
if (absDiff < minute) {
value = Math.round(diff / 1000);
unit = 'second';
} else if (absDiff < hour) {
value = Math.round(diff / minute);
unit = 'minute';
} else if (absDiff < day) {
value = Math.round(diff / hour);
unit = 'hour';
} else if (absDiff < week) {
value = Math.round(diff / day);
unit = 'day';
} else if (absDiff < month) {
value = Math.round(diff / week);
unit = 'week';
} else if (absDiff < year) {
value = Math.round(diff / month);
unit = 'month';
} else {
value = Math.round(diff / year);
unit = 'year';
}
return new Intl.RelativeTimeFormat(locale, {
numeric: 'auto',
}).format(value, unit);
}
/**
* Format date in ISO format (YYYY-MM-DD)
* @example formatISO(Date.now()) → "2025-11-03"
*/
export function formatISO(timestamp: number): string {
return new Date(timestamp).toISOString().split('T')[0];
}
/**
* Format compact date (shorter format for space-constrained UI)
* @example formatCompact(Date.now()) → "11/3/25"
*/
export function formatCompact(timestamp: number, locale = 'en'): string {
return new Intl.DateTimeFormat(locale, {
year: '2-digit',
month: 'numeric',
day: 'numeric',
}).format(timestamp);
}

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

View File

@@ -0,0 +1,78 @@
export type SlashParse = {
isSlash: boolean;
command: string;
argsArray: string[];
argsText: string;
};
function parseQuotedArguments(input: string): string[] {
const args: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';
let i = 0;
while (i < input.length) {
const char = input[i];
const nextChar = input[i + 1];
if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true;
quoteChar = char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
quoteChar = '';
} else if (!inQuotes && char === ' ') {
if (current) {
args.push(current);
current = '';
}
} else if (char === '\\' && nextChar) {
current += nextChar;
i++;
} else {
current += char;
}
i++;
}
if (current) args.push(current);
return args.filter(Boolean);
}
export function parseSlashInput(input: string): SlashParse {
const trimmed = input.trim();
if (!trimmed.startsWith('/'))
return { isSlash: false, command: '', argsArray: [], argsText: '' };
const parts = parseQuotedArguments(trimmed.slice(1));
const command = parts[0] || '';
const argsArray = parts.slice(1);
const argsText = argsArray.join(' ');
return { isSlash: true, command, argsArray, argsText };
}
export type ParsedArgs = {
keyValues: Record<string, string>;
positional: string[];
};
// Split tokens into key=value pairs and positional tokens.
// Supports quoted tokens already handled by parseQuotedArguments.
export function splitKeyValueAndPositional(tokens: string[]): ParsedArgs {
const keyValues: Record<string, string> = {};
const positional: string[] = [];
for (const tok of tokens) {
const eq = tok.indexOf('=');
if (eq > 0) {
const key = tok.slice(0, eq).trim();
const val = tok.slice(eq + 1);
if (key.length > 0) {
keyValues[key] = val;
continue;
}
}
positional.push(tok);
}
return { keyValues, positional };
}

View File

@@ -0,0 +1,111 @@
/**
* Centralized query key factory for TanStack Query
*
* Benefits:
* - Single source of truth for all query keys
* - TypeScript autocomplete support
* - Hierarchical invalidation (e.g., invalidate all agent queries)
* - Prevents typos and inconsistencies
*
* Usage:
* - useQuery({ queryKey: queryKeys.agents.all, ... })
* - queryClient.invalidateQueries({ queryKey: queryKeys.agents.all })
*/
import type { ServerRegistryFilter } from '@dexto/registry';
export const queryKeys = {
// Agent-related queries
agents: {
all: ['agents'] as const,
path: ['agentPath'] as const,
},
// Agent configuration queries
agent: {
config: ['agent', 'config'] as const,
},
// LLM configuration queries
llm: {
current: (sessionId: string | null | undefined) =>
['llm', 'current', sessionId ?? null] as const,
catalog: ['llm', 'catalog'] as const,
customModels: ['llm', 'customModels'] as const,
},
// Session-related queries
sessions: {
all: ['sessions'] as const,
detail: (sessionId: string) => ['sessions', 'detail', sessionId] as const,
history: (sessionId: string) => ['sessions', 'history', sessionId] as const,
},
// Search queries
search: {
messages: (query: string, sessionId?: string, limit?: number) =>
['search', 'messages', query, sessionId, limit] as const,
sessions: (query: string) => ['search', 'sessions', query] as const,
},
// Greeting queries
greeting: (sessionId: string | null | undefined) =>
['greeting', sessionId ?? 'default'] as const,
// Memory queries
memories: {
all: ['memories'] as const,
},
// Resource queries
resources: {
all: ['resources'] as const,
},
// Server registry queries
serverRegistry: (filter: ServerRegistryFilter) => ['serverRegistry', filter] as const,
// Prompt queries
prompts: {
all: ['prompts'] as const,
},
// MCP Server queries
servers: {
all: ['servers'] as const,
detail: (serverId: string) => ['servers', 'detail', serverId] as const,
tools: (serverId: string) => ['servers', 'tools', serverId] as const,
},
// Tools queries (all tools from all sources)
tools: {
all: ['tools'] as const,
},
// Message queue queries
queue: {
list: (sessionId: string) => ['queue', sessionId] as const,
},
// Approval queries
approvals: {
pending: (sessionId: string) => ['approvals', 'pending', sessionId] as const,
},
// Discovery queries (available providers and capabilities)
discovery: {
all: ['discovery'] as const,
},
// Model queries (local GGUF and Ollama)
models: {
local: ['models', 'local'] as const,
ollama: (baseURL?: string) => ['models', 'ollama', baseURL ?? 'default'] as const,
validateLocal: ['models', 'validateLocal'] as const,
},
// Dexto auth queries
dextoAuth: {
status: ['dextoAuth', 'status'] as const,
},
} as const;

View File

@@ -0,0 +1,85 @@
import type { McpServerConfig } from '@dexto/core';
import type { ServerRegistryEntry } from '@dexto/registry';
const PLACEHOLDER_EXACT_MATCHES = new Set([
'placeholder',
'your-api-key',
'your_api_key',
'enter-your-token',
'xxx',
'...',
'todo',
]);
const PLACEHOLDER_SUBSTRINGS = [
'your-',
'your_',
'your ',
'enter-',
'enter_',
'enter ',
'placeholder',
'api_key',
'api-key',
'api key',
'secret',
'token',
'password',
'xxx',
'...',
];
const PLACEHOLDER_PREFIX_PATTERNS = [/^your[\s_-]?/, /^enter[\s_-]?/];
export function hasEmptyOrPlaceholderValue(obj: Record<string, string>): boolean {
return Object.values(obj).some((value) => {
if (!value || value.trim() === '') {
return true;
}
const normalized = value.trim().toLowerCase();
if (PLACEHOLDER_EXACT_MATCHES.has(normalized)) {
return true;
}
if (PLACEHOLDER_PREFIX_PATTERNS.some((pattern) => pattern.test(normalized))) {
return true;
}
return PLACEHOLDER_SUBSTRINGS.some((token) => normalized.includes(token));
});
}
export function buildConfigFromRegistryEntry(entry: ServerRegistryEntry): McpServerConfig {
const baseTimeout = entry.config.timeout ?? 30000;
switch (entry.config.type) {
case 'stdio':
return {
type: 'stdio',
command: entry.config.command ?? '',
args: entry.config.args ?? [],
env: entry.config.env ?? {},
timeout: baseTimeout,
connectionMode: 'lenient',
};
case 'sse':
return {
type: 'sse',
url: entry.config.url ?? '',
headers: entry.config.headers ?? {},
timeout: baseTimeout,
connectionMode: 'lenient',
};
case 'http':
default:
return {
type: 'http',
url: entry.config.url ?? '',
headers: entry.config.headers ?? {},
timeout: baseTimeout,
connectionMode: 'lenient',
};
}
}

View File

@@ -0,0 +1,194 @@
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
import { serverRegistry as sharedRegistry } from '@dexto/registry';
import { client } from './client';
/**
* MCP Server Registry Service
* Manages a registry of available MCP servers that can be quickly added to agents
*
* The built-in registry data is loaded from an external JSON file (server-registry-data.json)
* to make it easy to add new servers without rebuilding the codebase.
*/
export class ServerRegistryService {
private static instance: ServerRegistryService;
private registryEntries: ServerRegistryEntry[] = [];
private isInitialized = false;
private constructor() {
// Private constructor for singleton
}
private static normalizeId(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
static getInstance(): ServerRegistryService {
if (!ServerRegistryService.instance) {
ServerRegistryService.instance = new ServerRegistryService();
}
return ServerRegistryService.instance;
}
/**
* Initialize the registry with default entries and load from external sources
*/
async initialize(): Promise<void> {
if (this.isInitialized) return;
// Load built-in registry entries from shared @dexto/registry package
this.registryEntries = await this.getBuiltinEntries();
// Load custom entries from localStorage
// TODO: Load from external registry sources (GitHub, npm, etc.)
// await this.loadExternalRegistries();
this.isInitialized = true;
}
/**
* Get all registry entries with optional filtering
*/
async getEntries(filter?: ServerRegistryFilter): Promise<ServerRegistryEntry[]> {
await this.initialize();
let filtered = [...this.registryEntries];
if (filter?.category) {
filtered = filtered.filter((entry) => entry.category === filter.category);
}
if (filter?.tags?.length) {
filtered = filtered.filter((entry) =>
filter.tags!.some((tag) => entry.tags.includes(tag))
);
}
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
filtered = filtered.filter(
(entry) =>
entry.name.toLowerCase().includes(searchLower) ||
entry.description.toLowerCase().includes(searchLower) ||
entry.tags.some((tag) => tag.toLowerCase().includes(searchLower))
);
}
if (filter?.installed !== undefined) {
filtered = filtered.filter((entry) => entry.isInstalled === filter.installed);
}
if (filter?.official !== undefined) {
filtered = filtered.filter((entry) => entry.isOfficial === filter.official);
}
return filtered.sort((a, b) => {
// Sort by: installed first, then official, then name
if (a.isInstalled !== b.isInstalled) {
return a.isInstalled ? -1 : 1;
}
if (a.isOfficial !== b.isOfficial) {
return a.isOfficial ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
/**
* Update an existing registry entry
*/
async updateEntry(id: string, updates: Partial<ServerRegistryEntry>): Promise<boolean> {
const index = this.registryEntries.findIndex((entry) => entry.id === id);
if (index === -1) return false;
this.registryEntries[index] = {
...this.registryEntries[index],
...updates,
};
return true;
}
/**
* Mark a server as installed/uninstalled
*/
async setInstalled(id: string, installed: boolean): Promise<boolean> {
return this.updateEntry(id, { isInstalled: installed });
}
/**
* Sync registry installed status with current server states
* This handles both disconnected and deleted servers
*/
async syncWithServerStatus(): Promise<void> {
try {
// Ensure registry is initialized before syncing
await this.initialize();
if (this.registryEntries.length === 0) {
console.warn('Registry entries not available for status sync');
return;
}
// Fetch current server states
const response = await client.api.mcp.servers.$get();
if (!response.ok) {
throw new Error(`Failed to fetch servers: ${response.status}`);
}
const data = await response.json();
const servers = data.servers || [];
// Create set for all server IDs
const allServerIds = new Set<string>();
servers.forEach((server: { id?: string; status?: string }) => {
if (!server?.id || typeof server.id !== 'string') return;
const normalizedId = ServerRegistryService.normalizeId(server.id);
allServerIds.add(normalizedId);
});
// Update registry entries based on server status
for (const entry of this.registryEntries) {
const aliases = [entry.id, entry.name, ...(entry.matchIds || [])]
.filter(Boolean)
.map((x) => ServerRegistryService.normalizeId(String(x)));
const hasMatchingServer = aliases.some((alias) => allServerIds.has(alias));
// Note: We could also track connection status separately in the future (e.g., maintain a connectedIds set)
// Update installed status:
// - If no matching server exists, mark as uninstalled
// - If server exists but is not connected, still consider as installed (just disconnected)
// - If server is connected, mark as installed
const shouldBeInstalled = hasMatchingServer;
if (entry.isInstalled !== shouldBeInstalled) {
entry.isInstalled = shouldBeInstalled;
}
}
} catch (error) {
// Non-fatal error, log and continue
console.warn('Failed to sync registry with server status:', error);
}
}
/**
* Get server configuration for connecting
*/
async getServerConfig(id: string): Promise<ServerRegistryEntry | null> {
await this.initialize();
return this.registryEntries.find((entry) => entry.id === id) || null;
}
/**
* Built-in registry entries for popular MCP servers
* Loaded from shared @dexto/registry package
*/
private async getBuiltinEntries(): Promise<ServerRegistryEntry[]> {
return sharedRegistry.getEntries();
}
}
// Export singleton instance
export const serverRegistry = ServerRegistryService.getInstance();

View File

@@ -0,0 +1,237 @@
# WebUI State Management Architecture
## Hybrid Context + Zustand Approach
The WebUI uses a **hybrid architecture** combining React Context and Zustand stores. This isn't redundant—each serves a distinct purpose.
## Why Zustand?
### The Key Reason: Event Handlers Run Outside React
```typescript
// Event handlers are plain functions, not React components
function handleLLMThinking(event: EventByName<'llm:thinking'>): void {
const { sessionId } = event;
// ✅ Imperative access - works outside React
useChatStore.getState().setProcessing(sessionId, true);
useAgentStore.getState().setThinking(sessionId);
}
```
**Event handlers can't use React hooks.** They need imperative state access from outside the component tree. Zustand provides this through `.getState()`.
With React Context, you'd need hacky global variables or complex callback registration—exactly what Zustand does, but type-safe and battle-tested.
### Secondary Benefits
1. **Granular subscriptions** - Components only re-render when their specific slice changes
2. **Multi-session state** - Efficient Map-based per-session storage
3. **No provider hell** - No need for nested provider components
4. **DevTools** - Time-travel debugging and state inspection
## Architecture Pattern
```
┌─────────────────────────────────────────┐
│ React Components │
│ - Use ChatContext for actions │
│ - Use Zustand stores for state │
└────────────┬────────────────────────────┘
┌──────┴──────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Context │ │ Zustand │
│ │ │ │
│ Actions │ │ State │
│ Hooks │ │ │
│ Query │ │ Pure │
└──────────┘ └────┬─────┘
┌────────┴────────┐
│ Event Handlers │
│ (outside React) │
└─────────────────┘
```
## When to Use What
### Use Zustand Stores For:
- ✅ State that needs access from **outside React** (event handlers, middleware)
-**Per-session state** (messages, errors, processing)
-**High-frequency updates** (streaming, real-time events)
- ✅ State accessed by **many components** (current session, notifications)
**Examples**: `chatStore`, `sessionStore`, `approvalStore`, `notificationStore`, `agentStore`
### Use React Context For:
-**React-specific orchestration** (combining hooks, effects, callbacks)
-**API integration** (TanStack Query, mutations)
-**Lifecycle management** (connection setup, cleanup)
-**Derived state** that depends on multiple sources
**Examples**: `ChatContext`, `EventBusProvider`
## Store Organization
```
lib/stores/
├── README.md # This file
├── index.ts # Barrel exports
├── chatStore.ts # Per-session messages, streaming, errors
├── sessionStore.ts # Current session, navigation state
├── approvalStore.ts # Approval requests with queueing
├── notificationStore.ts # Toast notifications
├── agentStore.ts # Agent status, connection, heartbeat
└── eventLogStore.ts # Event history for debugging
```
## Usage Patterns
### Reading State in Components
```typescript
import { useChatStore } from '@/lib/stores/chatStore';
import { useSessionStore } from '@/lib/stores/sessionStore';
function ChatApp() {
// Granular subscription - only re-renders when messages change
const messages = useChatStore((s) => {
if (!currentSessionId) return EMPTY_MESSAGES;
const session = s.sessions.get(currentSessionId);
return session?.messages ?? EMPTY_MESSAGES;
});
// Simple value access
const currentSessionId = useSessionStore((s) => s.currentSessionId);
// ...
}
```
### Updating State from Event Handlers
```typescript
import { useChatStore } from '@/lib/stores/chatStore';
import { useAgentStore } from '@/lib/stores/agentStore';
function handleLLMChunk(event: EventByName<'llm:chunk'>): void {
const { sessionId, content, chunkType } = event;
// Imperative access from outside React
const chatStore = useChatStore.getState();
chatStore.appendToStreamingMessage(sessionId, content, chunkType);
}
```
### Updating State from Context/Components
```typescript
// In ChatContext or components with hooks
const setProcessing = useCallback((sessionId: string, isProcessing: boolean) => {
useChatStore.getState().setProcessing(sessionId, isProcessing);
}, []);
```
## Event Flow
```
SSE Stream (server)
useChat.ts (line 219)
eventBus.dispatch(event)
Middleware Pipeline (logging, activity, notifications)
Event Handlers (handlers.ts)
Zustand Stores (.getState() imperative updates)
React Components (via selectors, triggers re-render)
```
## Best Practices
### 1. Use Stable References for Empty Arrays
```typescript
// ✅ DO: Prevents infinite re-render loops
const EMPTY_MESSAGES: Message[] = [];
const messages = useChatStore((s) => {
if (!currentSessionId) return EMPTY_MESSAGES;
return s.sessions.get(currentSessionId)?.messages ?? EMPTY_MESSAGES;
});
// ❌ DON'T: Creates new array reference on every render
const messages = useChatStore((s) => {
if (!currentSessionId) return []; // New reference each time!
return s.sessions.get(currentSessionId)?.messages ?? [];
});
```
### 2. Selector Efficiency
```typescript
// ✅ DO: Narrow selectors for specific data
const processing = useChatStore((s) => {
const session = s.sessions.get(currentSessionId);
return session?.processing ?? false;
});
// ❌ DON'T: Selecting entire store triggers unnecessary re-renders
const store = useChatStore(); // Re-renders on any store change!
const processing = store.sessions.get(currentSessionId)?.processing;
```
### 3. Imperative vs Hook Usage
```typescript
// ✅ In React components - use hook
function MyComponent() {
const messages = useChatStore((s) => s.getMessages(sessionId));
// ...
}
// ✅ In event handlers - use .getState()
function handleEvent(event) {
useChatStore.getState().addMessage(sessionId, message);
}
// ❌ DON'T: Use hooks outside React
function handleEvent(event) {
const store = useChatStore(); // ❌ Can't use hooks here!
}
```
## Testing
All stores have comprehensive test coverage. See `*.test.ts` files for examples:
```typescript
import { useChatStore } from './chatStore';
describe('chatStore', () => {
beforeEach(() => {
useChatStore.setState({ sessions: new Map() });
});
it('should add message to session', () => {
const store = useChatStore.getState();
store.addMessage('session-1', { id: 'msg-1', ... });
const messages = store.getMessages('session-1');
expect(messages).toHaveLength(1);
});
});
```
## Related Documentation
- Event system: `packages/webui/lib/events/README.md`
- Event handlers: `packages/webui/lib/events/handlers.ts`
- Event middleware: `packages/webui/lib/events/middleware/`
- Main architecture: `/docs` (to be updated)

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { useAgentStore } from './agentStore.js';
describe('agentStore', () => {
beforeEach(() => {
// Reset store to default state
useAgentStore.setState({
status: 'idle',
connectionStatus: 'disconnected',
lastHeartbeat: null,
activeSessionId: null,
currentToolName: null,
connectionError: null,
reconnectAttempts: 0,
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('status actions', () => {
it('should set status with setStatus', () => {
useAgentStore.getState().setStatus('thinking', 'session-1');
expect(useAgentStore.getState().status).toBe('thinking');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
});
it('should clear activeSessionId when setting to idle', () => {
useAgentStore.getState().setStatus('thinking', 'session-1');
useAgentStore.getState().setStatus('idle');
expect(useAgentStore.getState().activeSessionId).toBeNull();
});
it('should set thinking status', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
expect(useAgentStore.getState().currentToolName).toBeNull();
});
it('should set executing tool status with tool name', () => {
useAgentStore.getState().setExecutingTool('session-1', 'read_file');
expect(useAgentStore.getState().status).toBe('executing_tool');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
expect(useAgentStore.getState().currentToolName).toBe('read_file');
});
it('should set awaiting approval status', () => {
useAgentStore.getState().setAwaitingApproval('session-1');
expect(useAgentStore.getState().status).toBe('awaiting_approval');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
});
it('should set idle and clear all', () => {
useAgentStore.getState().setExecutingTool('session-1', 'bash');
useAgentStore.getState().setIdle();
expect(useAgentStore.getState().status).toBe('idle');
expect(useAgentStore.getState().activeSessionId).toBeNull();
expect(useAgentStore.getState().currentToolName).toBeNull();
});
it('should clear tool name when transitioning from executing_tool to other status', () => {
useAgentStore.getState().setExecutingTool('session-1', 'bash');
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().currentToolName).toBeNull();
});
});
describe('connection actions', () => {
it('should set connection status', () => {
useAgentStore.getState().setConnectionStatus('connected');
expect(useAgentStore.getState().connectionStatus).toBe('connected');
});
it('should handle setConnected', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));
useAgentStore.setState({
connectionError: 'Previous error',
reconnectAttempts: 5,
});
useAgentStore.getState().setConnected();
expect(useAgentStore.getState().connectionStatus).toBe('connected');
expect(useAgentStore.getState().connectionError).toBeNull();
expect(useAgentStore.getState().reconnectAttempts).toBe(0);
expect(useAgentStore.getState().lastHeartbeat).toBe(Date.now());
});
it('should handle setDisconnected without error', () => {
useAgentStore.getState().setDisconnected();
expect(useAgentStore.getState().connectionStatus).toBe('disconnected');
expect(useAgentStore.getState().connectionError).toBeNull();
});
it('should handle setDisconnected with error', () => {
useAgentStore.getState().setDisconnected('Network error');
expect(useAgentStore.getState().connectionStatus).toBe('disconnected');
expect(useAgentStore.getState().connectionError).toBe('Network error');
});
it('should handle setReconnecting', () => {
useAgentStore.getState().setReconnecting();
expect(useAgentStore.getState().connectionStatus).toBe('reconnecting');
});
it('should update heartbeat timestamp', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00'));
useAgentStore.getState().updateHeartbeat();
expect(useAgentStore.getState().lastHeartbeat).toBe(Date.now());
});
it('should increment reconnect attempts', () => {
useAgentStore.getState().incrementReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(1);
useAgentStore.getState().incrementReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(2);
});
it('should reset reconnect attempts', () => {
useAgentStore.setState({ reconnectAttempts: 5 });
useAgentStore.getState().resetReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(0);
});
});
describe('selectors', () => {
it('isBusy should return true when not idle', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isBusy()).toBe(true);
});
it('isBusy should return false when idle', () => {
expect(useAgentStore.getState().isBusy()).toBe(false);
});
it('isConnected should return true when connected', () => {
useAgentStore.getState().setConnected();
expect(useAgentStore.getState().isConnected()).toBe(true);
});
it('isConnected should return false when disconnected', () => {
expect(useAgentStore.getState().isConnected()).toBe(false);
});
it('isConnected should return false when reconnecting', () => {
useAgentStore.getState().setReconnecting();
expect(useAgentStore.getState().isConnected()).toBe(false);
});
it('isActiveForSession should return true for matching session', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isActiveForSession('session-1')).toBe(true);
});
it('isActiveForSession should return false for different session', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isActiveForSession('session-2')).toBe(false);
});
it('isActiveForSession should return false when idle', () => {
expect(useAgentStore.getState().isActiveForSession('session-1')).toBe(false);
});
it('getHeartbeatAge should return null when no heartbeat', () => {
expect(useAgentStore.getState().getHeartbeatAge()).toBeNull();
});
it('getHeartbeatAge should return age in milliseconds', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00'));
useAgentStore.getState().updateHeartbeat();
// Advance time by 5 seconds
vi.advanceTimersByTime(5000);
expect(useAgentStore.getState().getHeartbeatAge()).toBe(5000);
});
});
describe('status transitions', () => {
it('should handle full lifecycle: idle -> thinking -> executing -> idle', () => {
expect(useAgentStore.getState().status).toBe('idle');
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
useAgentStore.getState().setExecutingTool('session-1', 'read_file');
expect(useAgentStore.getState().status).toBe('executing_tool');
useAgentStore.getState().setIdle();
expect(useAgentStore.getState().status).toBe('idle');
});
it('should handle approval flow: thinking -> awaiting_approval -> idle', () => {
useAgentStore.getState().setThinking('session-1');
useAgentStore.getState().setAwaitingApproval('session-1');
expect(useAgentStore.getState().status).toBe('awaiting_approval');
// After approval, back to thinking or idle
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
});
});
});

View File

@@ -0,0 +1,298 @@
/**
* Agent Store
*
* Manages the agent's status and connection state.
* This is global state (not per-session) as there's one agent connection.
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Agent's current activity status
*/
export type AgentStatus =
| 'idle' // Ready for input
| 'thinking' // Processing/generating response
| 'executing_tool' // Running a tool
| 'awaiting_approval'; // Waiting for user approval
/**
* Connection status to the backend
*/
export type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
/**
* Agent state
*/
export interface AgentState {
/**
* Current agent activity status
*/
status: AgentStatus;
/**
* Connection status to the backend
*/
connectionStatus: ConnectionStatus;
/**
* Timestamp of last heartbeat (for connection health monitoring)
*/
lastHeartbeat: number | null;
/**
* Currently active session for the agent (for status context)
*/
activeSessionId: string | null;
/**
* Name of the tool currently being executed (if any)
*/
currentToolName: string | null;
/**
* Error message if connection failed
*/
connectionError: string | null;
/**
* Number of reconnection attempts
*/
reconnectAttempts: number;
}
// =============================================================================
// Store Interface
// =============================================================================
interface AgentStore extends AgentState {
// -------------------------------------------------------------------------
// Status Actions
// -------------------------------------------------------------------------
/**
* Set the agent's activity status
*/
setStatus: (status: AgentStatus, sessionId?: string) => void;
/**
* Set status to thinking
*/
setThinking: (sessionId: string) => void;
/**
* Set status to executing tool
*/
setExecutingTool: (sessionId: string, toolName: string) => void;
/**
* Set status to awaiting approval
*/
setAwaitingApproval: (sessionId: string) => void;
/**
* Set status to idle
*/
setIdle: () => void;
// -------------------------------------------------------------------------
// Connection Actions
// -------------------------------------------------------------------------
/**
* Set the connection status
*/
setConnectionStatus: (status: ConnectionStatus) => void;
/**
* Mark connection as established
*/
setConnected: () => void;
/**
* Mark connection as lost
*/
setDisconnected: (error?: string) => void;
/**
* Mark as attempting reconnection
*/
setReconnecting: () => void;
/**
* Update the heartbeat timestamp
*/
updateHeartbeat: () => void;
/**
* Increment reconnection attempt counter
*/
incrementReconnectAttempts: () => void;
/**
* Reset reconnection attempt counter
*/
resetReconnectAttempts: () => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Check if the agent is busy (not idle)
*/
isBusy: () => boolean;
/**
* Check if connected
*/
isConnected: () => boolean;
/**
* Check if the agent is working on a specific session
*/
isActiveForSession: (sessionId: string) => boolean;
/**
* Get time since last heartbeat (ms), or null if no heartbeat
*/
getHeartbeatAge: () => number | null;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: AgentState = {
status: 'idle',
connectionStatus: 'disconnected',
lastHeartbeat: null,
activeSessionId: null,
currentToolName: null,
connectionError: null,
reconnectAttempts: 0,
};
// =============================================================================
// Store Implementation
// =============================================================================
export const useAgentStore = create<AgentStore>()((set, get) => ({
...defaultState,
// -------------------------------------------------------------------------
// Status Actions
// -------------------------------------------------------------------------
setStatus: (status, sessionId) => {
set({
status,
activeSessionId: sessionId ?? (status === 'idle' ? null : get().activeSessionId),
// Clear tool name if not executing
currentToolName: status === 'executing_tool' ? get().currentToolName : null,
});
},
setThinking: (sessionId) => {
set({
status: 'thinking',
activeSessionId: sessionId,
currentToolName: null,
});
},
setExecutingTool: (sessionId, toolName) => {
set({
status: 'executing_tool',
activeSessionId: sessionId,
currentToolName: toolName,
});
},
setAwaitingApproval: (sessionId) => {
set({
status: 'awaiting_approval',
activeSessionId: sessionId,
currentToolName: null,
});
},
setIdle: () => {
set({
status: 'idle',
activeSessionId: null,
currentToolName: null,
});
},
// -------------------------------------------------------------------------
// Connection Actions
// -------------------------------------------------------------------------
setConnectionStatus: (status) => {
set({ connectionStatus: status });
},
setConnected: () => {
set({
connectionStatus: 'connected',
connectionError: null,
reconnectAttempts: 0,
lastHeartbeat: Date.now(),
});
},
setDisconnected: (error) => {
set({
connectionStatus: 'disconnected',
connectionError: error ?? null,
});
},
setReconnecting: () => {
set({
connectionStatus: 'reconnecting',
});
},
updateHeartbeat: () => {
set({ lastHeartbeat: Date.now() });
},
incrementReconnectAttempts: () => {
set((state) => ({
reconnectAttempts: state.reconnectAttempts + 1,
}));
},
resetReconnectAttempts: () => {
set({ reconnectAttempts: 0 });
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
isBusy: () => {
return get().status !== 'idle';
},
isConnected: () => {
return get().connectionStatus === 'connected';
},
isActiveForSession: (sessionId) => {
const state = get();
return state.status !== 'idle' && state.activeSessionId === sessionId;
},
getHeartbeatAge: () => {
const { lastHeartbeat } = get();
if (lastHeartbeat === null) return null;
return Date.now() - lastHeartbeat;
},
}));

View File

@@ -0,0 +1,331 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useApprovalStore } from './approvalStore.js';
import type { ApprovalRequest, ApprovalResponse } from '@dexto/core';
import { ApprovalType, ApprovalStatus } from '@dexto/core';
// Helper to create test approval requests
function createTestApprovalRequest(overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
return {
approvalId: `approval-${Math.random().toString(36).slice(2, 11)}`,
type: ApprovalType.TOOL_CONFIRMATION,
sessionId: 'test-session',
timeout: 30000,
timestamp: new Date(),
metadata: {
toolName: 'test_tool',
args: {},
},
...overrides,
} as ApprovalRequest;
}
// Helper to create test approval responses
function createTestApprovalResponse(
approvalId: string,
status: ApprovalStatus,
overrides: Partial<ApprovalResponse> = {}
): ApprovalResponse {
return {
approvalId,
status,
sessionId: 'test-session',
...overrides,
} as ApprovalResponse;
}
describe('approvalStore', () => {
beforeEach(() => {
// Reset store to default state
useApprovalStore.setState({
pendingApproval: null,
queue: [],
});
});
describe('addApproval', () => {
it('should set pendingApproval when empty', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().pendingApproval).toEqual(request);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should queue when pendingApproval exists', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
expect(useApprovalStore.getState().pendingApproval).toEqual(request1);
expect(useApprovalStore.getState().queue).toHaveLength(1);
expect(useApprovalStore.getState().queue[0]).toEqual(request2);
});
it('should queue multiple requests in order', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
expect(useApprovalStore.getState().pendingApproval).toEqual(request1);
expect(useApprovalStore.getState().queue).toHaveLength(2);
expect(useApprovalStore.getState().queue[0]).toEqual(request2);
expect(useApprovalStore.getState().queue[1]).toEqual(request3);
});
});
describe('processResponse', () => {
it('should clear pendingApproval for approved status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(
request.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should clear pendingApproval for denied status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(request.approvalId, ApprovalStatus.DENIED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should clear pendingApproval for cancelled status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(
request.approvalId,
ApprovalStatus.CANCELLED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should process next in queue after terminal status', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
const response = createTestApprovalResponse(
request1.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should handle multiple queued items', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
// Process first
const response1 = createTestApprovalResponse(
request1.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response1);
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(1);
expect(useApprovalStore.getState().queue[0]).toEqual(request3);
// Process second
const response2 = createTestApprovalResponse(
request2.approvalId,
ApprovalStatus.DENIED
);
useApprovalStore.getState().processResponse(response2);
expect(useApprovalStore.getState().pendingApproval).toEqual(request3);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should not process response for mismatched approvalId', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse('wrong-id', ApprovalStatus.APPROVED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toEqual(request);
});
it('should handle response when no pending approval', () => {
const response = createTestApprovalResponse('some-id', ApprovalStatus.APPROVED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
});
describe('clearApproval', () => {
it('should clear current and process next in queue', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should set pendingApproval to null when queue is empty', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should handle clearApproval when nothing is pending', () => {
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
});
describe('clearAll', () => {
it('should clear everything', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
useApprovalStore.getState().clearAll();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
});
describe('selectors', () => {
describe('getPendingCount', () => {
it('should return 0 when nothing is pending', () => {
expect(useApprovalStore.getState().getPendingCount()).toBe(0);
});
it('should return 1 when only pendingApproval exists', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().getPendingCount()).toBe(1);
});
it('should return correct count with queue', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
expect(useApprovalStore.getState().getPendingCount()).toBe(3);
});
});
describe('getPendingForSession', () => {
it('should return empty array for session with no approvals', () => {
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(0);
});
it('should return only approvals for specified session', () => {
const request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const request2 = createTestApprovalRequest({ sessionId: 'session-2' });
const request3 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(2);
expect(result[0]).toEqual(request1);
expect(result[1]).toEqual(request3);
});
it('should include both pending and queued for session', () => {
const request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const request2 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(2);
});
});
describe('hasPendingApproval', () => {
it('should return false when nothing is pending', () => {
expect(useApprovalStore.getState().hasPendingApproval()).toBe(false);
});
it('should return true when approval is pending', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().hasPendingApproval()).toBe(true);
});
it('should return false after clearing', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().hasPendingApproval()).toBe(false);
});
});
});
describe('session isolation', () => {
it('should keep different sessions separate in queue', () => {
const session1Request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const session2Request1 = createTestApprovalRequest({ sessionId: 'session-2' });
const session1Request2 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(session1Request1);
useApprovalStore.getState().addApproval(session2Request1);
useApprovalStore.getState().addApproval(session1Request2);
const session1Pending = useApprovalStore.getState().getPendingForSession('session-1');
const session2Pending = useApprovalStore.getState().getPendingForSession('session-2');
expect(session1Pending).toHaveLength(2);
expect(session2Pending).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,158 @@
/**
* Approval Store
*
* Manages approval requests from the agent using Zustand.
* Handles queueing when multiple approvals arrive simultaneously.
*
* Flow:
* 1. approval:request event → addApproval() → sets pendingApproval or queues
* 2. User responds via UI → sends response to agent
* 3. approval:response event → processResponse() → clears and processes next
*/
import { create } from 'zustand';
import { ApprovalStatus } from '@dexto/core';
import type { ApprovalRequest, ApprovalResponse } from '@dexto/core';
export interface PendingApproval {
request: ApprovalRequest;
timestamp: number;
}
interface ApprovalStore {
// Current pending approval being displayed
pendingApproval: ApprovalRequest | null;
// Queue of pending approvals waiting to be displayed
queue: ApprovalRequest[];
// Actions
addApproval: (request: ApprovalRequest) => void;
processResponse: (response: ApprovalResponse) => void;
clearApproval: () => void;
clearAll: () => void;
// Selectors
getPendingCount: () => number;
getPendingForSession: (sessionId: string) => ApprovalRequest[];
hasPendingApproval: () => boolean;
}
/**
* Check if approval response status is terminal (ends the approval)
*/
function isTerminalStatus(status: ApprovalStatus): boolean {
return (
status === ApprovalStatus.APPROVED ||
status === ApprovalStatus.DENIED ||
status === ApprovalStatus.CANCELLED
);
}
export const useApprovalStore = create<ApprovalStore>((set, get) => ({
pendingApproval: null,
queue: [],
/**
* Add a new approval request
* If there's already a pending approval, queue it
*/
addApproval: (request: ApprovalRequest) => {
set((state) => {
// If there's already a pending approval, add to queue
if (state.pendingApproval) {
return {
queue: [...state.queue, request],
};
}
// Otherwise, set as pending
return {
pendingApproval: request,
};
});
},
/**
* Process an approval response
* If status is terminal (approved/denied/cancelled), clear pending and process next
*/
processResponse: (response: ApprovalResponse) => {
set((state) => {
// Only process if this response matches the current pending approval
if (state.pendingApproval?.approvalId !== response.approvalId) {
return state;
}
// If terminal status, clear pending and process next
if (isTerminalStatus(response.status)) {
// Get next from queue
const [next, ...rest] = state.queue;
return {
pendingApproval: next ?? null,
queue: rest,
};
}
// Non-terminal status (e.g., future streaming updates), keep pending
return state;
});
},
/**
* Clear current approval and process next in queue
* Used for manual dismissal or timeout
*/
clearApproval: () => {
set((state) => {
const [next, ...rest] = state.queue;
return {
pendingApproval: next ?? null,
queue: rest,
};
});
},
/**
* Clear all pending approvals
* Used when switching sessions or resetting state
*/
clearAll: () => {
set({
pendingApproval: null,
queue: [],
});
},
/**
* Get total count of pending approvals (current + queued)
*/
getPendingCount: () => {
const state = get();
return (state.pendingApproval ? 1 : 0) + state.queue.length;
},
/**
* Get all pending approvals for a specific session
*/
getPendingForSession: (sessionId: string) => {
const state = get();
const results: ApprovalRequest[] = [];
if (state.pendingApproval?.sessionId === sessionId) {
results.push(state.pendingApproval);
}
results.push(...state.queue.filter((req) => req.sessionId === sessionId));
return results;
},
/**
* Check if there's a pending approval
*/
hasPendingApproval: () => {
return get().pendingApproval !== null;
},
}));

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useChatStore, generateMessageId, type Message } from './chatStore.js';
// Helper to create a test message
function createTestMessage(overrides: Partial<Message> = {}): Message {
return {
id: generateMessageId(),
role: 'user',
content: 'Test message',
createdAt: Date.now(),
...overrides,
};
}
describe('chatStore', () => {
const sessionId = 'test-session';
beforeEach(() => {
// Reset store to default state
useChatStore.setState({ sessions: new Map() });
});
describe('generateMessageId', () => {
it('should generate unique IDs', () => {
const id1 = generateMessageId();
const id2 = generateMessageId();
expect(id1).not.toBe(id2);
});
it('should start with msg- prefix', () => {
const id = generateMessageId();
expect(id.startsWith('msg-')).toBe(true);
});
});
describe('initSession', () => {
it('should initialize a session with default state', () => {
useChatStore.getState().initSession(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.messages).toEqual([]);
expect(state.streamingMessage).toBeNull();
expect(state.processing).toBe(false);
expect(state.error).toBeNull();
expect(state.loadingHistory).toBe(false);
});
it('should not overwrite existing session', () => {
const message = createTestMessage();
useChatStore.getState().initSession(sessionId);
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().initSession(sessionId);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
});
});
describe('addMessage', () => {
it('should add a message to a session', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(message);
});
it('should create session if not exists', () => {
const message = createTestMessage();
useChatStore.getState().addMessage('new-session', message);
const messages = useChatStore.getState().getMessages('new-session');
expect(messages).toHaveLength(1);
});
it('should append multiple messages in order', () => {
const msg1 = createTestMessage({ content: 'First' });
const msg2 = createTestMessage({ content: 'Second' });
useChatStore.getState().addMessage(sessionId, msg1);
useChatStore.getState().addMessage(sessionId, msg2);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe('First');
expect(messages[1].content).toBe('Second');
});
});
describe('updateMessage', () => {
it('should update an existing message', () => {
const message = createTestMessage({ content: 'Original' });
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().updateMessage(sessionId, message.id, {
content: 'Updated',
});
const updated = useChatStore.getState().getMessage(sessionId, message.id);
expect(updated?.content).toBe('Updated');
});
it('should not modify state for non-existent message', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().updateMessage(sessionId, 'non-existent', {
content: 'Updated',
});
// State should be unchanged - original message still present
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe(message.content);
});
it('should not modify state for non-existent session', () => {
useChatStore.getState().updateMessage('non-existent', 'msg-id', {
content: 'Updated',
});
expect(useChatStore.getState().sessions.has('non-existent')).toBe(false);
});
});
describe('removeMessage', () => {
it('should remove a message from a session', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().removeMessage(sessionId, message.id);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(0);
});
it('should not affect other messages', () => {
const msg1 = createTestMessage({ content: 'Keep' });
const msg2 = createTestMessage({ content: 'Remove' });
useChatStore.getState().addMessage(sessionId, msg1);
useChatStore.getState().addMessage(sessionId, msg2);
useChatStore.getState().removeMessage(sessionId, msg2.id);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('Keep');
});
});
describe('clearMessages', () => {
it('should clear all messages in a session', () => {
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().clearMessages(sessionId);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(0);
});
it('should also clear streaming message', () => {
const streaming = createTestMessage({ role: 'assistant' });
useChatStore.getState().setStreamingMessage(sessionId, streaming);
useChatStore.getState().clearMessages(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
});
describe('streaming message', () => {
it('should set streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: '' });
useChatStore.getState().setStreamingMessage(sessionId, message);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toEqual(message);
});
it('should clear streaming message', () => {
const message = createTestMessage({ role: 'assistant' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().setStreamingMessage(sessionId, null);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
it('should append text content to streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: 'Hello' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().appendToStreamingMessage(sessionId, ' World');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage?.content).toBe('Hello World');
});
it('should append reasoning content to streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: '', reasoning: '' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().appendToStreamingMessage(sessionId, 'Thinking...', 'reasoning');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage?.reasoning).toBe('Thinking...');
});
it('should not append if no streaming message', () => {
// Should not throw
useChatStore.getState().appendToStreamingMessage(sessionId, 'Test');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
it('should finalize streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: 'Response' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().finalizeStreamingMessage(sessionId, {
tokenUsage: { totalTokens: 100 },
});
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
expect(state.messages).toHaveLength(1);
expect(state.messages[0].content).toBe('Response');
expect(state.messages[0].tokenUsage?.totalTokens).toBe(100);
});
it('should not finalize if no streaming message', () => {
useChatStore.getState().finalizeStreamingMessage(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.messages).toHaveLength(0);
});
});
describe('state flags', () => {
it('should set processing flag', () => {
useChatStore.getState().setProcessing(sessionId, true);
expect(useChatStore.getState().getSessionState(sessionId).processing).toBe(true);
useChatStore.getState().setProcessing(sessionId, false);
expect(useChatStore.getState().getSessionState(sessionId).processing).toBe(false);
});
it('should set error state', () => {
const error = {
id: 'error-1',
message: 'Test error',
timestamp: Date.now(),
};
useChatStore.getState().setError(sessionId, error);
expect(useChatStore.getState().getSessionState(sessionId).error).toEqual(error);
useChatStore.getState().setError(sessionId, null);
expect(useChatStore.getState().getSessionState(sessionId).error).toBeNull();
});
it('should set loading history flag', () => {
useChatStore.getState().setLoadingHistory(sessionId, true);
expect(useChatStore.getState().getSessionState(sessionId).loadingHistory).toBe(true);
});
});
describe('removeSession', () => {
it('should remove a session completely', () => {
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().removeSession(sessionId);
expect(useChatStore.getState().sessions.has(sessionId)).toBe(false);
});
});
describe('selectors', () => {
it('getSessionState should return default for unknown session', () => {
const state = useChatStore.getState().getSessionState('unknown');
expect(state.messages).toEqual([]);
expect(state.processing).toBe(false);
});
it('getMessage should find message by ID', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
const found = useChatStore.getState().getMessage(sessionId, message.id);
expect(found).toEqual(message);
});
it('getMessage should return undefined for unknown ID', () => {
const found = useChatStore.getState().getMessage(sessionId, 'unknown');
expect(found).toBeUndefined();
});
it('getMessageByToolCallId should find tool message', () => {
const message = createTestMessage({
role: 'tool',
toolCallId: 'tool-call-123',
});
useChatStore.getState().addMessage(sessionId, message);
const found = useChatStore
.getState()
.getMessageByToolCallId(sessionId, 'tool-call-123');
expect(found).toEqual(message);
});
});
describe('session isolation', () => {
it('should keep sessions separate', () => {
const session1 = 'session-1';
const session2 = 'session-2';
useChatStore
.getState()
.addMessage(session1, createTestMessage({ content: 'Session 1' }));
useChatStore
.getState()
.addMessage(session2, createTestMessage({ content: 'Session 2' }));
expect(useChatStore.getState().getMessages(session1)).toHaveLength(1);
expect(useChatStore.getState().getMessages(session2)).toHaveLength(1);
expect(useChatStore.getState().getMessages(session1)[0].content).toBe('Session 1');
expect(useChatStore.getState().getMessages(session2)[0].content).toBe('Session 2');
});
it('should not affect other sessions when clearing', () => {
const session1 = 'session-1';
const session2 = 'session-2';
useChatStore.getState().addMessage(session1, createTestMessage());
useChatStore.getState().addMessage(session2, createTestMessage());
useChatStore.getState().clearMessages(session1);
expect(useChatStore.getState().getMessages(session1)).toHaveLength(0);
expect(useChatStore.getState().getMessages(session2)).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,552 @@
/**
* Chat Store
*
* Manages message state per session using Zustand.
* Each session has isolated message state to support multi-session scenarios.
*/
import { create } from 'zustand';
import type { InternalMessage, Issue, SanitizedToolResult, LLMProvider } from '@dexto/core';
import type { TextPart, ImagePart, AudioPart, FilePart, FileData, UIResourcePart } from '@/types';
// =============================================================================
// Types
// =============================================================================
/**
* UI Message role - excludes 'system' which is filtered out before reaching UI
*/
export type UIMessageRole = 'user' | 'assistant' | 'tool';
/**
* Tool result type for UI messages
* Broader than SanitizedToolResult to handle legacy formats and edge cases
*/
export type ToolResult =
| SanitizedToolResult
| { error: string | Record<string, unknown> }
| string
| Record<string, unknown>;
/**
* Sub-agent progress data for spawn_agent tool calls
*/
export interface SubAgentProgress {
/** Short task description */
task: string;
/** Agent ID (e.g., 'explore-agent') */
agentId: string;
/** Number of tools called by the sub-agent */
toolsCalled: number;
/** Current tool being executed */
currentTool: string;
/** Current tool arguments (optional) */
currentArgs?: Record<string, unknown>;
}
/**
* Message in the chat UI
* Extends core InternalMessage with UI-specific fields
* Note: Excludes 'system' role as system messages are not displayed in UI
*/
export interface Message extends Omit<InternalMessage, 'content' | 'role'> {
id: string;
role: UIMessageRole;
createdAt: number;
content: string | null | Array<TextPart | ImagePart | AudioPart | FilePart | UIResourcePart>;
// User attachments
imageData?: { image: string; mimeType: string };
fileData?: FileData;
// Tool-related fields
toolName?: string;
toolArgs?: Record<string, unknown>;
toolCallId?: string;
toolResult?: ToolResult;
toolResultMeta?: SanitizedToolResult['meta'];
toolResultSuccess?: boolean;
/** Sub-agent progress data (for spawn_agent tool calls) */
subAgentProgress?: SubAgentProgress;
// Approval fields
requireApproval?: boolean;
approvalStatus?: 'pending' | 'approved' | 'rejected';
// LLM metadata
tokenUsage?: {
inputTokens?: number;
outputTokens?: number;
reasoningTokens?: number;
totalTokens?: number;
};
reasoning?: string;
model?: string;
provider?: LLMProvider;
// Session reference
sessionId?: string;
}
/**
* Error state for a session
*/
export interface ErrorMessage {
id: string;
message: string;
timestamp: number;
context?: string;
recoverable?: boolean;
sessionId?: string;
anchorMessageId?: string;
detailedIssues?: Issue[];
}
/**
* State for a single session
*/
export interface SessionChatState {
messages: Message[];
streamingMessage: Message | null;
processing: boolean;
error: ErrorMessage | null;
loadingHistory: boolean;
}
/**
* Default state for a new session
*/
const defaultSessionState: SessionChatState = {
messages: [],
streamingMessage: null,
processing: false,
error: null,
loadingHistory: false,
};
// =============================================================================
// Store Interface
// =============================================================================
interface ChatStore {
/**
* Session states keyed by session ID
*/
sessions: Map<string, SessionChatState>;
// -------------------------------------------------------------------------
// Message Actions
// -------------------------------------------------------------------------
/**
* Add a message to a session
*/
addMessage: (sessionId: string, message: Message) => void;
/**
* Update an existing message
*/
updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
/**
* Remove a message from a session
*/
removeMessage: (sessionId: string, messageId: string) => void;
/**
* Clear all messages in a session
*/
clearMessages: (sessionId: string) => void;
/**
* Set all messages for a session at once
*/
setMessages: (sessionId: string, messages: Message[]) => void;
/**
* Initialize or replace session state with history
*/
initFromHistory: (sessionId: string, messages: Message[]) => void;
// -------------------------------------------------------------------------
// Streaming Actions
// -------------------------------------------------------------------------
/**
* Set the current streaming message for a session
*/
setStreamingMessage: (sessionId: string, message: Message | null) => void;
/**
* Append content to the streaming message
*/
appendToStreamingMessage: (
sessionId: string,
content: string,
chunkType?: 'text' | 'reasoning'
) => void;
/**
* Finalize streaming message (move to messages array)
*/
finalizeStreamingMessage: (sessionId: string, updates?: Partial<Message>) => void;
// -------------------------------------------------------------------------
// State Actions
// -------------------------------------------------------------------------
/**
* Set processing state for a session
*/
setProcessing: (sessionId: string, processing: boolean) => void;
/**
* Set error state for a session
*/
setError: (sessionId: string, error: ErrorMessage | null) => void;
/**
* Set loading history state for a session
*/
setLoadingHistory: (sessionId: string, loading: boolean) => void;
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
/**
* Initialize a session with default state
*/
initSession: (sessionId: string) => void;
/**
* Remove a session completely
*/
removeSession: (sessionId: string) => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Get state for a session (creates default if not exists)
*/
getSessionState: (sessionId: string) => SessionChatState;
/**
* Get messages for a session
*/
getMessages: (sessionId: string) => Message[];
/**
* Get a specific message by ID
*/
getMessage: (sessionId: string, messageId: string) => Message | undefined;
/**
* Find message by tool call ID
*/
getMessageByToolCallId: (sessionId: string, toolCallId: string) => Message | undefined;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get or create session state
*/
function getOrCreateSession(
sessions: Map<string, SessionChatState>,
sessionId: string
): SessionChatState {
const existing = sessions.get(sessionId);
if (existing) return existing;
return { ...defaultSessionState };
}
/**
* Generate unique message ID
*/
export function generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useChatStore = create<ChatStore>()((set, get) => ({
sessions: new Map(),
// -------------------------------------------------------------------------
// Message Actions
// -------------------------------------------------------------------------
addMessage: (sessionId, message) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages: [...sessionState.messages, message],
});
return { sessions: newSessions };
});
},
updateMessage: (sessionId, messageId, updates) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
const messageIndex = sessionState.messages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) return state;
const newMessages = [...sessionState.messages];
newMessages[messageIndex] = { ...newMessages[messageIndex], ...updates };
newSessions.set(sessionId, {
...sessionState,
messages: newMessages,
});
return { sessions: newSessions };
});
},
removeMessage: (sessionId, messageId) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
newSessions.set(sessionId, {
...sessionState,
messages: sessionState.messages.filter((m) => m.id !== messageId),
});
return { sessions: newSessions };
});
},
clearMessages: (sessionId) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
newSessions.set(sessionId, {
...sessionState,
messages: [],
streamingMessage: null,
});
return { sessions: newSessions };
});
},
setMessages: (sessionId, messages) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages,
});
return { sessions: newSessions };
});
},
initFromHistory: (sessionId, messages) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages,
processing: false,
error: null,
streamingMessage: null,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Streaming Actions
// -------------------------------------------------------------------------
setStreamingMessage: (sessionId, message) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
streamingMessage: message,
});
return { sessions: newSessions };
});
},
appendToStreamingMessage: (sessionId, content, chunkType = 'text') => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
if (!sessionState.streamingMessage) return state;
const currentMessage = sessionState.streamingMessage;
let updatedMessage: Message;
if (chunkType === 'reasoning') {
// Append to reasoning field
updatedMessage = {
...currentMessage,
reasoning: (currentMessage.reasoning || '') + content,
};
} else {
// Append to content
const currentContent =
typeof currentMessage.content === 'string' ? currentMessage.content : '';
updatedMessage = {
...currentMessage,
content: currentContent + content,
};
}
newSessions.set(sessionId, {
...sessionState,
streamingMessage: updatedMessage,
});
return { sessions: newSessions };
});
},
finalizeStreamingMessage: (sessionId, updates = {}) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
if (!sessionState.streamingMessage) return state;
const finalizedMessage: Message = {
...sessionState.streamingMessage,
...updates,
};
// Ensure messages array exists (defensive)
const existingMessages = sessionState.messages ?? [];
newSessions.set(sessionId, {
...sessionState,
messages: [...existingMessages, finalizedMessage],
streamingMessage: null,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// State Actions
// -------------------------------------------------------------------------
setProcessing: (sessionId, processing) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
processing,
});
return { sessions: newSessions };
});
},
setError: (sessionId, error) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
error,
});
return { sessions: newSessions };
});
},
setLoadingHistory: (sessionId, loading) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
loadingHistory: loading,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
initSession: (sessionId) => {
set((state) => {
if (state.sessions.has(sessionId)) return state;
const newSessions = new Map(state.sessions);
newSessions.set(sessionId, { ...defaultSessionState });
return { sessions: newSessions };
});
},
removeSession: (sessionId) => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(sessionId);
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
getSessionState: (sessionId) => {
const state = get().sessions.get(sessionId);
return state ?? { ...defaultSessionState };
},
getMessages: (sessionId) => {
return get().getSessionState(sessionId).messages;
},
getMessage: (sessionId, messageId) => {
return get()
.getMessages(sessionId)
.find((m) => m.id === messageId);
},
getMessageByToolCallId: (sessionId, toolCallId) => {
return get()
.getMessages(sessionId)
.find((m) => m.toolCallId === toolCallId);
},
}));

View File

@@ -0,0 +1,394 @@
/**
* Event Log Store Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useEventLogStore } from './eventLogStore.js';
describe('eventLogStore', () => {
beforeEach(() => {
// Reset store to default state
useEventLogStore.setState({
events: [],
maxEvents: 1000,
});
});
describe('addEvent', () => {
it('should add event with generated id', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent started processing',
timestamp: Date.now(),
sessionId: 'session-1',
});
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(1);
expect(events[0].id).toMatch(/^evt-\d+-[a-z0-9]+$/);
expect(events[0].name).toBe('llm:thinking');
expect(events[0].category).toBe('agent');
expect(events[0].sessionId).toBe('session-1');
});
it('should add multiple events in order', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'First event',
timestamp: 1000,
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Second event',
timestamp: 2000,
});
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(2);
expect(events[0].description).toBe('First event');
expect(events[1].description).toBe('Second event');
});
it('should store metadata', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:tool-call',
category: 'tool',
description: 'Tool call',
timestamp: Date.now(),
metadata: {
toolName: 'read_file',
args: { path: '/test.txt' },
},
});
const { events } = useEventLogStore.getState();
expect(events[0].metadata).toEqual({
toolName: 'read_file',
args: { path: '/test.txt' },
});
});
});
describe('maxEvents limit', () => {
it('should cap events at maxEvents', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
setMaxEvents(3);
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(3);
// Should keep the newest 3
expect(events[0].description).toBe('Event 2');
expect(events[1].description).toBe('Event 3');
expect(events[2].description).toBe('Event 4');
});
it('should trim existing events when maxEvents is reduced', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(5);
// Reduce maxEvents to 2
setMaxEvents(2);
events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
// Should keep the newest 2
expect(events[0].description).toBe('Event 3');
expect(events[1].description).toBe('Event 4');
});
it('should not trim if maxEvents is increased', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
setMaxEvents(3);
// Add 3 events
for (let i = 0; i < 3; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(3);
// Increase maxEvents
setMaxEvents(100);
events = useEventLogStore.getState().events;
expect(events).toHaveLength(3);
});
});
describe('clearEvents', () => {
it('should clear all events', () => {
const { addEvent, clearEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Event 2',
timestamp: Date.now(),
});
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
clearEvents();
events = useEventLogStore.getState().events;
expect(events).toHaveLength(0);
});
});
describe('clearSessionEvents', () => {
it('should remove only matching session events', () => {
const { addEvent, clearSessionEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 1 event',
timestamp: Date.now(),
sessionId: 'session-1',
});
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 2 event',
timestamp: Date.now(),
sessionId: 'session-2',
});
addEvent({
name: 'connection:status',
category: 'system',
description: 'No session event',
timestamp: Date.now(),
});
expect(useEventLogStore.getState().events).toHaveLength(3);
clearSessionEvents('session-1');
const events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
expect(events[0].description).toBe('Session 2 event');
expect(events[1].description).toBe('No session event');
});
it('should handle clearing non-existent session', () => {
const { addEvent, clearSessionEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event',
timestamp: Date.now(),
sessionId: 'session-1',
});
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(1);
clearSessionEvents('non-existent');
events = useEventLogStore.getState().events;
expect(events).toHaveLength(1);
});
});
describe('getEventsBySession', () => {
it('should filter events by session id', () => {
const { addEvent, getEventsBySession } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 1 event 1',
timestamp: Date.now(),
sessionId: 'session-1',
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Session 2 event',
timestamp: Date.now(),
sessionId: 'session-2',
});
addEvent({
name: 'llm:chunk',
category: 'agent',
description: 'Session 1 event 2',
timestamp: Date.now(),
sessionId: 'session-1',
});
const session1Events = getEventsBySession('session-1');
expect(session1Events).toHaveLength(2);
expect(session1Events[0].description).toBe('Session 1 event 1');
expect(session1Events[1].description).toBe('Session 1 event 2');
});
it('should return empty array for non-existent session', () => {
const { addEvent, getEventsBySession } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event',
timestamp: Date.now(),
sessionId: 'session-1',
});
const events = getEventsBySession('non-existent');
expect(events).toHaveLength(0);
});
});
describe('getEventsByCategory', () => {
it('should filter events by category', () => {
const { addEvent, getEventsByCategory } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:tool-call',
category: 'tool',
description: 'Tool event',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Agent event 2',
timestamp: Date.now(),
});
const agentEvents = getEventsByCategory('agent');
expect(agentEvents).toHaveLength(2);
expect(agentEvents[0].description).toBe('Agent event 1');
expect(agentEvents[1].description).toBe('Agent event 2');
const toolEvents = getEventsByCategory('tool');
expect(toolEvents).toHaveLength(1);
expect(toolEvents[0].description).toBe('Tool event');
});
it('should return empty array for category with no events', () => {
const { addEvent, getEventsByCategory } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent event',
timestamp: Date.now(),
});
const approvalEvents = getEventsByCategory('approval');
expect(approvalEvents).toHaveLength(0);
});
});
describe('getRecentEvents', () => {
it('should return correct number of recent events', () => {
const { addEvent, getRecentEvents } = useEventLogStore.getState();
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
const recent = getRecentEvents(3);
expect(recent).toHaveLength(3);
// Should get the last 3
expect(recent[0].description).toBe('Event 2');
expect(recent[1].description).toBe('Event 3');
expect(recent[2].description).toBe('Event 4');
});
it('should return all events if limit exceeds count', () => {
const { addEvent, getRecentEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Event 2',
timestamp: Date.now(),
});
const recent = getRecentEvents(10);
expect(recent).toHaveLength(2);
});
it('should return empty array if no events', () => {
const { getRecentEvents } = useEventLogStore.getState();
const recent = getRecentEvents(5);
expect(recent).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,185 @@
/**
* Event Log Store
*
* Stores activity events for debugging and monitoring.
* Provides an audit trail of all events flowing through the event bus.
*/
import { create } from 'zustand';
import type { StreamingEventName } from '@dexto/core';
// =============================================================================
// Types
// =============================================================================
/**
* Event categories for organization and filtering
*/
export type EventCategory = 'agent' | 'tool' | 'system' | 'user' | 'approval';
/**
* Activity event stored in the log
*/
export interface ActivityEvent {
/**
* Unique event ID
*/
id: string;
/**
* Event name from SSE
*/
name: StreamingEventName | string;
/**
* Event category
*/
category: EventCategory;
/**
* Human-readable description
*/
description: string;
/**
* Timestamp when event was logged
*/
timestamp: number;
/**
* Session ID if event is session-scoped
*/
sessionId?: string;
/**
* Additional metadata (full event payload)
*/
metadata?: Record<string, unknown>;
}
// =============================================================================
// Store Interface
// =============================================================================
interface EventLogStore {
/**
* Stored events (newest last)
*/
events: ActivityEvent[];
/**
* Maximum number of events to keep
*/
maxEvents: number;
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
/**
* Add a new event to the log
*/
addEvent: (event: Omit<ActivityEvent, 'id'>) => void;
/**
* Clear all events
*/
clearEvents: () => void;
/**
* Clear events for a specific session
*/
clearSessionEvents: (sessionId: string) => void;
/**
* Set the maximum number of events to keep
*/
setMaxEvents: (max: number) => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Get events for a specific session
*/
getEventsBySession: (sessionId: string) => ActivityEvent[];
/**
* Get events by category
*/
getEventsByCategory: (category: EventCategory) => ActivityEvent[];
/**
* Get most recent N events
*/
getRecentEvents: (limit: number) => ActivityEvent[];
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useEventLogStore = create<EventLogStore>()((set, get) => ({
events: [],
maxEvents: 1000,
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
addEvent: (event) => {
const id = `evt-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
set((state) => {
const newEvents = [...state.events, { ...event, id }];
// Trim to maxEvents, keeping newest
if (newEvents.length > state.maxEvents) {
return { events: newEvents.slice(-state.maxEvents) };
}
return { events: newEvents };
});
},
clearEvents: () => {
set({ events: [] });
},
clearSessionEvents: (sessionId) => {
set((state) => ({
events: state.events.filter((event) => event.sessionId !== sessionId),
}));
},
setMaxEvents: (max) => {
set((state) => {
// If reducing max, trim events immediately
if (state.events.length > max) {
return {
maxEvents: max,
events: state.events.slice(-max),
};
}
return { maxEvents: max };
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
getEventsBySession: (sessionId) => {
return get().events.filter((event) => event.sessionId === sessionId);
},
getEventsByCategory: (category) => {
return get().events.filter((event) => event.category === category);
},
getRecentEvents: (limit) => {
const events = get().events;
return events.slice(-limit);
},
}));

View File

@@ -0,0 +1,66 @@
/**
* Store Exports
*
* Central export point for all Zustand stores.
* Import stores from here rather than individual files.
*/
// Chat store - per-session message state
export { useChatStore, generateMessageId } from './chatStore.js';
export type { Message, ErrorMessage, SessionChatState } from './chatStore.js';
// Session store - current session navigation state
export { useSessionStore } from './sessionStore.js';
export type { SessionState } from './sessionStore.js';
// Agent store - agent status and connection state
export { useAgentStore } from './agentStore.js';
export type { AgentStatus, ConnectionStatus, AgentState } from './agentStore.js';
// Notification store - toast notifications
export { useNotificationStore } from './notificationStore.js';
export type { Toast, ToastIntent } from './notificationStore.js';
// Event log store - activity logging for debugging
export { useEventLogStore } from './eventLogStore.js';
export type { ActivityEvent, EventCategory } from './eventLogStore.js';
// Approval store - approval request queue management
export { useApprovalStore } from './approvalStore.js';
export type { PendingApproval } from './approvalStore.js';
// Preference store - user preferences with localStorage persistence
export { usePreferenceStore } from './preferenceStore.js';
export type { PreferenceState } from './preferenceStore.js';
// Todo store - agent task tracking
export { useTodoStore } from './todoStore.js';
export type { Todo, TodoStatus } from './todoStore.js';
// Selectors - shared selector hooks for common patterns
export {
// Constants
EMPTY_MESSAGES,
// Session selectors
useCurrentSessionId,
useIsWelcomeState,
useIsSessionOperationPending,
useIsReplayingHistory,
// Chat selectors
useSessionMessages,
useStreamingMessage,
useAllMessages,
useSessionProcessing,
useSessionError,
useSessionLoadingHistory,
// Agent selectors
useCurrentToolName,
useAgentStatus,
useConnectionStatus,
useIsAgentBusy,
useIsAgentConnected,
useAgentActiveSession,
// Combined selectors
useSessionChatState,
useAgentState,
} from './selectors.js';

View File

@@ -0,0 +1,165 @@
/**
* Tests for notificationStore
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useNotificationStore } from './notificationStore.js';
describe('notificationStore', () => {
beforeEach(() => {
// Reset store to default state
useNotificationStore.setState({ toasts: [], maxToasts: 5 });
});
describe('addToast', () => {
it('should add a toast with generated id and timestamp', () => {
const { addToast } = useNotificationStore.getState();
addToast({
title: 'Test Toast',
intent: 'info',
});
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('Test Toast');
expect(toasts[0].intent).toBe('info');
expect(toasts[0].id).toMatch(/^toast-/);
expect(toasts[0].timestamp).toBeGreaterThan(0);
});
it('should add multiple toasts', () => {
const { addToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'success' });
addToast({ title: 'Toast 3', intent: 'warning' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(3);
expect(toasts[0].title).toBe('Toast 1');
expect(toasts[1].title).toBe('Toast 2');
expect(toasts[2].title).toBe('Toast 3');
});
it('should include optional fields when provided', () => {
const { addToast } = useNotificationStore.getState();
addToast({
title: 'Test',
description: 'Description text',
intent: 'danger',
duration: 10000,
sessionId: 'session-123',
});
const { toasts } = useNotificationStore.getState();
expect(toasts[0].description).toBe('Description text');
expect(toasts[0].duration).toBe(10000);
expect(toasts[0].sessionId).toBe('session-123');
});
it('should enforce maxToasts limit by removing oldest', () => {
const { addToast } = useNotificationStore.getState();
// Add 6 toasts (max is 5)
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
addToast({ title: 'Toast 4', intent: 'info' });
addToast({ title: 'Toast 5', intent: 'info' });
addToast({ title: 'Toast 6', intent: 'info' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(5);
// Oldest (Toast 1) should be removed
expect(toasts[0].title).toBe('Toast 2');
expect(toasts[4].title).toBe('Toast 6');
});
it('should enforce custom maxToasts limit', () => {
// Set custom max toasts
useNotificationStore.setState({ maxToasts: 3 });
const { addToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
addToast({ title: 'Toast 4', intent: 'info' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(3);
expect(toasts[0].title).toBe('Toast 2');
expect(toasts[2].title).toBe('Toast 4');
});
});
describe('removeToast', () => {
it('should remove toast by id', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
const { toasts } = useNotificationStore.getState();
const toastId = toasts[0].id;
removeToast(toastId);
const updatedToasts = useNotificationStore.getState().toasts;
expect(updatedToasts).toHaveLength(1);
expect(updatedToasts[0].title).toBe('Toast 2');
});
it('should do nothing if id does not exist', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
removeToast('non-existent-id');
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
});
it('should remove all toasts with same id', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
const { toasts: toasts1 } = useNotificationStore.getState();
const toastId = toasts1[0].id;
addToast({ title: 'Toast 2', intent: 'info' });
removeToast(toastId);
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('Toast 2');
});
});
describe('clearAll', () => {
it('should remove all toasts', () => {
const { addToast, clearAll } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
expect(useNotificationStore.getState().toasts).toHaveLength(3);
clearAll();
expect(useNotificationStore.getState().toasts).toHaveLength(0);
});
it('should work when there are no toasts', () => {
const { clearAll } = useNotificationStore.getState();
clearAll();
expect(useNotificationStore.getState().toasts).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,114 @@
/**
* Notification Store
*
* Manages toast notifications for the WebUI.
* Toasts are displayed in the bottom-right corner and auto-dismiss after a duration.
*/
import { create } from 'zustand';
/**
* Toast intent determines the visual styling
*/
export type ToastIntent = 'info' | 'success' | 'warning' | 'danger';
/**
* Toast notification interface
*/
export interface Toast {
/** Unique identifier */
id: string;
/** Toast title (required) */
title: string;
/** Optional description/body text */
description?: string;
/** Visual intent/severity */
intent: ToastIntent;
/** Auto-dismiss duration in milliseconds (default: 5000) */
duration?: number;
/** Session ID for "Go to session" action */
sessionId?: string;
/** Creation timestamp */
timestamp: number;
}
/**
* Notification store state
*/
interface NotificationStore {
/** Active toast notifications */
toasts: Toast[];
/** Maximum number of toasts to show simultaneously */
maxToasts: number;
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
/**
* Add a new toast notification
* Automatically generates ID and timestamp
*/
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>) => void;
/**
* Remove a toast by ID
*/
removeToast: (id: string) => void;
/**
* Clear all toasts
*/
clearAll: () => void;
}
/**
* Generate a unique toast ID
*/
function generateToastId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Default values
*/
const DEFAULT_MAX_TOASTS = 5;
/**
* Notification store implementation
*/
export const useNotificationStore = create<NotificationStore>()((set, _get) => ({
toasts: [],
maxToasts: DEFAULT_MAX_TOASTS,
addToast: (toast) => {
const newToast: Toast = {
...toast,
id: generateToastId(),
timestamp: Date.now(),
};
set((state) => {
const newToasts = [...state.toasts, newToast];
// Enforce max toasts limit (remove oldest)
if (newToasts.length > state.maxToasts) {
return {
toasts: newToasts.slice(newToasts.length - state.maxToasts),
};
}
return { toasts: newToasts };
});
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
clearAll: () => {
set({ toasts: [] });
},
}));

View File

@@ -0,0 +1,86 @@
/**
* Preference Store Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { usePreferenceStore } from './preferenceStore.js';
// Mock localStorage for Node.js test environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// Assign to global
global.localStorage = localStorageMock as any;
describe('preferenceStore', () => {
beforeEach(() => {
// Reset to default state before each test
usePreferenceStore.setState({ isStreaming: true });
// Clear localStorage
localStorage.clear();
});
describe('Initialization', () => {
it('should initialize with default values', () => {
const state = usePreferenceStore.getState();
expect(state.isStreaming).toBe(true);
});
});
describe('setStreaming', () => {
it('should update streaming preference to false', () => {
const store = usePreferenceStore.getState();
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
});
it('should update streaming preference to true', () => {
const store = usePreferenceStore.getState();
// Set to false first
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
// Then back to true
store.setStreaming(true);
expect(usePreferenceStore.getState().isStreaming).toBe(true);
});
});
describe('localStorage persistence', () => {
it('should have persist middleware configured', () => {
// The store uses zustand persist middleware with 'dexto-preferences' key
// In browser environment, this will automatically persist to localStorage
// Here we just verify the store works correctly
const store = usePreferenceStore.getState();
// Change preference
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
// Change it back
store.setStreaming(true);
expect(usePreferenceStore.getState().isStreaming).toBe(true);
// Note: Actual localStorage persistence is tested in browser/e2e tests
// The persist middleware is a well-tested zustand feature
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Preference Store
*
* Manages user preferences with localStorage persistence.
* Uses zustand persist middleware for automatic sync.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// =============================================================================
// Types
// =============================================================================
/**
* User preference state
*/
export interface PreferenceState {
/**
* Whether streaming mode is enabled (SSE vs sync)
* @default true
*/
isStreaming: boolean;
}
// =============================================================================
// Store Interface
// =============================================================================
interface PreferenceStore extends PreferenceState {
/**
* Toggle streaming mode
*/
setStreaming: (enabled: boolean) => void;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: PreferenceState = {
isStreaming: true, // Default to streaming enabled
};
// =============================================================================
// Store Implementation
// =============================================================================
export const usePreferenceStore = create<PreferenceStore>()(
persist(
(set) => ({
...defaultState,
setStreaming: (enabled) => {
set({ isStreaming: enabled });
},
}),
{
name: 'dexto-preferences', // localStorage key
version: 1,
}
)
);

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface RecentAgent {
id: string;
name: string;
path: string;
lastUsed: number;
}
interface RecentAgentsStore {
recentAgents: RecentAgent[];
addRecentAgent: (agent: { id: string; name: string; path: string }) => void;
clearRecentAgents: () => void;
}
const MAX_RECENT_AGENTS = 5;
export const useRecentAgentsStore = create<RecentAgentsStore>()(
persist(
(set) => ({
recentAgents: [],
addRecentAgent: (agent) =>
set((state) => {
const filtered = state.recentAgents.filter((a) => a.path !== agent.path);
const updated: RecentAgent[] = [
{ ...agent, lastUsed: Date.now() },
...filtered,
].slice(0, MAX_RECENT_AGENTS);
return { recentAgents: updated };
}),
clearRecentAgents: () => set({ recentAgents: [] }),
}),
{
name: 'dexto:recentAgents',
}
)
);

View File

@@ -0,0 +1,255 @@
/**
* Store Selectors
*
* Centralized selector hooks for Zustand stores.
* These hooks encapsulate common selector patterns to:
* - Reduce duplication across components
* - Ensure consistent null-safety handling
* - Provide stable references to prevent re-renders
*
* @example
* ```tsx
* // Instead of repeating this pattern:
* const messages = useChatStore((s) => {
* if (!sessionId) return EMPTY_MESSAGES;
* return s.sessions.get(sessionId)?.messages ?? EMPTY_MESSAGES;
* });
*
* // Use:
* const messages = useSessionMessages(sessionId);
* ```
*/
import { useMemo } from 'react';
import { useChatStore, type Message, type ErrorMessage } from './chatStore.js';
import { useSessionStore } from './sessionStore.js';
import { useAgentStore, type AgentStatus, type ConnectionStatus } from './agentStore.js';
// =============================================================================
// Constants
// =============================================================================
/**
* Stable empty array reference to prevent re-renders.
* Using a constant reference ensures React's shallow comparison
* doesn't trigger unnecessary re-renders when there are no messages.
*/
export const EMPTY_MESSAGES: Message[] = [];
// =============================================================================
// Session Selectors
// =============================================================================
/**
* Get the current session ID
*/
export function useCurrentSessionId(): string | null {
return useSessionStore((s) => s.currentSessionId);
}
/**
* Check if in welcome state (no active session)
*/
export function useIsWelcomeState(): boolean {
return useSessionStore((s) => s.isWelcomeState);
}
/**
* Check if a session operation is in progress
*/
export function useIsSessionOperationPending(): boolean {
return useSessionStore(
(s) => s.isCreatingSession || s.isSwitchingSession || s.isLoadingHistory
);
}
/**
* Check if history replay is in progress (for suppressing notifications)
*/
export function useIsReplayingHistory(): boolean {
return useSessionStore((s) => s.isReplayingHistory);
}
// =============================================================================
// Chat Selectors
// =============================================================================
/**
* Get messages for a session (without streaming message)
*
* @param sessionId - Session ID or null
* @returns Array of messages, empty array if no session
*/
export function useSessionMessages(sessionId: string | null): Message[] {
return useChatStore((s) => {
if (!sessionId) return EMPTY_MESSAGES;
const session = s.sessions.get(sessionId);
return session?.messages ?? EMPTY_MESSAGES;
});
}
/**
* Get the streaming message for a session
*
* @param sessionId - Session ID or null
* @returns Streaming message or null
*/
export function useStreamingMessage(sessionId: string | null): Message | null {
return useChatStore((s) => {
if (!sessionId) return null;
const session = s.sessions.get(sessionId);
return session?.streamingMessage ?? null;
});
}
/**
* Get all messages including streaming message.
* Uses useMemo internally for stable reference when combining.
*
* @param sessionId - Session ID or null
* @returns Combined array of messages + streaming message
*/
export function useAllMessages(sessionId: string | null): Message[] {
const baseMessages = useSessionMessages(sessionId);
const streamingMessage = useStreamingMessage(sessionId);
return useMemo(() => {
if (streamingMessage) {
return [...baseMessages, streamingMessage];
}
return baseMessages;
}, [baseMessages, streamingMessage]);
}
/**
* Get processing state for a session
*
* @param sessionId - Session ID or null
* @returns True if session is processing
*/
export function useSessionProcessing(sessionId: string | null): boolean {
return useChatStore((s) => {
if (!sessionId) return false;
const session = s.sessions.get(sessionId);
return session?.processing ?? false;
});
}
/**
* Get error state for a session
*
* @param sessionId - Session ID or null
* @returns Error object or null
*/
export function useSessionError(sessionId: string | null): ErrorMessage | null {
return useChatStore((s) => {
if (!sessionId) return null;
const session = s.sessions.get(sessionId);
return session?.error ?? null;
});
}
/**
* Get loading history state for a session
*
* @param sessionId - Session ID or null
* @returns True if loading history
*/
export function useSessionLoadingHistory(sessionId: string | null): boolean {
return useChatStore((s) => {
if (!sessionId) return false;
const session = s.sessions.get(sessionId);
return session?.loadingHistory ?? false;
});
}
// =============================================================================
// Agent Selectors
// =============================================================================
/**
* Get the current tool name being executed
*/
export function useCurrentToolName(): string | null {
return useAgentStore((s) => s.currentToolName);
}
/**
* Get the agent's current status
*/
export function useAgentStatus(): AgentStatus {
return useAgentStore((s) => s.status);
}
/**
* Get the agent's connection status
*/
export function useConnectionStatus(): ConnectionStatus {
return useAgentStore((s) => s.connectionStatus);
}
/**
* Check if the agent is busy (not idle)
*/
export function useIsAgentBusy(): boolean {
return useAgentStore((s) => s.status !== 'idle');
}
/**
* Check if the agent is connected
*/
export function useIsAgentConnected(): boolean {
return useAgentStore((s) => s.connectionStatus === 'connected');
}
/**
* Get the active session ID for the agent
*/
export function useAgentActiveSession(): string | null {
return useAgentStore((s) => s.activeSessionId);
}
// =============================================================================
// Combined Selectors (Convenience Hooks)
// =============================================================================
/**
* Combined chat state for a session.
* Use this when a component needs multiple pieces of session state.
*
* @param sessionId - Session ID or null
* @returns Object with messages, processing, error, and loadingHistory
*
* @example
* ```tsx
* const { messages, processing, error } = useSessionChatState(sessionId);
* ```
*/
export function useSessionChatState(sessionId: string | null) {
const messages = useAllMessages(sessionId);
const processing = useSessionProcessing(sessionId);
const error = useSessionError(sessionId);
const loadingHistory = useSessionLoadingHistory(sessionId);
return { messages, processing, error, loadingHistory };
}
/**
* Combined agent state.
* Use this when a component needs multiple pieces of agent state.
*
* @returns Object with status, connectionStatus, currentToolName, and isBusy
*
* @example
* ```tsx
* const { status, isBusy, currentToolName } = useAgentState();
* ```
*/
export function useAgentState() {
const status = useAgentStatus();
const connectionStatus = useConnectionStatus();
const currentToolName = useCurrentToolName();
const isBusy = useIsAgentBusy();
return { status, connectionStatus, currentToolName, isBusy };
}

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSessionStore } from './sessionStore.js';
describe('sessionStore', () => {
beforeEach(() => {
// Reset store to default state
useSessionStore.setState({
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
});
});
describe('setCurrentSession', () => {
it('should set the current session ID', () => {
useSessionStore.getState().setCurrentSession('session-123');
expect(useSessionStore.getState().currentSessionId).toBe('session-123');
});
it('should set isWelcomeState to false when setting a session', () => {
useSessionStore.getState().setCurrentSession('session-123');
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should set isWelcomeState to true when setting null', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setCurrentSession(null);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().currentSessionId).toBeNull();
});
});
describe('setWelcomeState', () => {
it('should set welcome state to true and clear session', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setWelcomeState(true);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().currentSessionId).toBeNull();
});
it('should set welcome state to false without clearing session', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setWelcomeState(false);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
expect(useSessionStore.getState().currentSessionId).toBe('session-123');
});
});
describe('returnToWelcome', () => {
it('should reset to welcome state and clear all flags', () => {
useSessionStore.setState({
currentSessionId: 'session-123',
isWelcomeState: false,
isCreatingSession: true,
isSwitchingSession: true,
isReplayingHistory: true,
isLoadingHistory: true,
});
useSessionStore.getState().returnToWelcome();
expect(useSessionStore.getState().currentSessionId).toBeNull();
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isSwitchingSession).toBe(false);
expect(useSessionStore.getState().isReplayingHistory).toBe(false);
expect(useSessionStore.getState().isLoadingHistory).toBe(false);
});
});
describe('session creation flow', () => {
it('should handle beginSessionCreation', () => {
useSessionStore.getState().beginSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(true);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should handle completeSessionCreation', () => {
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().completeSessionCreation('new-session-id');
expect(useSessionStore.getState().currentSessionId).toBe('new-session-id');
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should handle cancelSessionCreation returning to welcome', () => {
// Start from welcome state
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().cancelSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
});
it('should handle cancelSessionCreation staying in session', () => {
// Start from existing session
useSessionStore.getState().setCurrentSession('existing-session');
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().cancelSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(false);
// Should stay on existing session, not go to welcome
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
});
describe('selectors', () => {
it('isSessionOperationPending should return true when creating', () => {
useSessionStore.setState({ isCreatingSession: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return true when switching', () => {
useSessionStore.setState({ isSwitchingSession: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return true when loading history', () => {
useSessionStore.setState({ isLoadingHistory: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return false when idle', () => {
expect(useSessionStore.getState().isSessionOperationPending()).toBe(false);
});
it('shouldSuppressNotifications should return true during replay', () => {
useSessionStore.setState({ isReplayingHistory: true });
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(true);
});
it('shouldSuppressNotifications should return true during switch', () => {
useSessionStore.setState({ isSwitchingSession: true });
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(true);
});
it('shouldSuppressNotifications should return false during normal operation', () => {
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(false);
});
});
describe('individual setters', () => {
it('should set creating session flag', () => {
useSessionStore.getState().setCreatingSession(true);
expect(useSessionStore.getState().isCreatingSession).toBe(true);
useSessionStore.getState().setCreatingSession(false);
expect(useSessionStore.getState().isCreatingSession).toBe(false);
});
it('should set switching session flag', () => {
useSessionStore.getState().setSwitchingSession(true);
expect(useSessionStore.getState().isSwitchingSession).toBe(true);
});
it('should set replaying history flag', () => {
useSessionStore.getState().setReplayingHistory(true);
expect(useSessionStore.getState().isReplayingHistory).toBe(true);
});
it('should set loading history flag', () => {
useSessionStore.getState().setLoadingHistory(true);
expect(useSessionStore.getState().isLoadingHistory).toBe(true);
});
});
});

View File

@@ -0,0 +1,236 @@
/**
* Session Store
*
* Manages the current session state and navigation state.
* Separate from chatStore which handles per-session message state.
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Session navigation and UI state
*/
export interface SessionState {
/**
* Currently active session ID (null = welcome state)
*/
currentSessionId: string | null;
/**
* Whether we're showing the welcome/landing screen
*/
isWelcomeState: boolean;
/**
* Session is being created (new session in progress)
*/
isCreatingSession: boolean;
/**
* Session switch in progress
*/
isSwitchingSession: boolean;
/**
* History replay in progress (suppress notifications during this)
*/
isReplayingHistory: boolean;
/**
* Loading history for a session
*/
isLoadingHistory: boolean;
}
// =============================================================================
// Store Interface
// =============================================================================
interface SessionStore extends SessionState {
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
/**
* Set the current active session
* Setting to null transitions to welcome state
*/
setCurrentSession: (sessionId: string | null) => void;
/**
* Explicitly set welcome state
*/
setWelcomeState: (isWelcome: boolean) => void;
/**
* Set session creation in progress
*/
setCreatingSession: (isCreating: boolean) => void;
/**
* Set session switch in progress
*/
setSwitchingSession: (isSwitching: boolean) => void;
/**
* Set history replay in progress
*/
setReplayingHistory: (isReplaying: boolean) => void;
/**
* Set history loading state
*/
setLoadingHistory: (isLoading: boolean) => void;
// -------------------------------------------------------------------------
// Composite Actions
// -------------------------------------------------------------------------
/**
* Return to welcome screen (clear current session)
*/
returnToWelcome: () => void;
/**
* Start creating a new session
*/
beginSessionCreation: () => void;
/**
* Complete session creation and activate the new session
* @param newSessionId - The newly created session ID
*/
completeSessionCreation: (newSessionId: string) => void;
/**
* Cancel session creation (e.g., on error)
*/
cancelSessionCreation: () => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Check if any session operation is in progress
*/
isSessionOperationPending: () => boolean;
/**
* Check if we should suppress notifications
* (during history replay or session operations)
*/
shouldSuppressNotifications: () => boolean;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: SessionState = {
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
};
// =============================================================================
// Store Implementation
// =============================================================================
export const useSessionStore = create<SessionStore>()((set, get) => ({
...defaultState,
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
setCurrentSession: (sessionId) => {
set({
currentSessionId: sessionId,
isWelcomeState: sessionId === null,
});
},
setWelcomeState: (isWelcome) => {
set({
isWelcomeState: isWelcome,
// Clear session when going to welcome
...(isWelcome ? { currentSessionId: null } : {}),
});
},
setCreatingSession: (isCreating) => {
set({ isCreatingSession: isCreating });
},
setSwitchingSession: (isSwitching) => {
set({ isSwitchingSession: isSwitching });
},
setReplayingHistory: (isReplaying) => {
set({ isReplayingHistory: isReplaying });
},
setLoadingHistory: (isLoading) => {
set({ isLoadingHistory: isLoading });
},
// -------------------------------------------------------------------------
// Composite Actions
// -------------------------------------------------------------------------
returnToWelcome: () => {
set({
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
});
},
beginSessionCreation: () => {
set({
isCreatingSession: true,
isWelcomeState: false,
});
},
completeSessionCreation: (newSessionId) => {
set({
currentSessionId: newSessionId,
isCreatingSession: false,
isWelcomeState: false,
});
},
cancelSessionCreation: () => {
set({
isCreatingSession: false,
// Return to welcome if we were there before
isWelcomeState: get().currentSessionId === null,
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
isSessionOperationPending: () => {
const state = get();
return state.isCreatingSession || state.isSwitchingSession || state.isLoadingHistory;
},
shouldSuppressNotifications: () => {
const state = get();
return state.isReplayingHistory || state.isSwitchingSession || state.isLoadingHistory;
},
}));

View File

@@ -0,0 +1,92 @@
/**
* Todo Store
*
* Manages todo/task state for agent workflow tracking.
* State is per-session and not persisted (todos come from server events).
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Todo status
*/
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* Todo item
*/
export interface Todo {
id: string;
sessionId: string;
content: string;
activeForm: string;
status: TodoStatus;
position: number;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* State per session
*/
interface SessionTodoState {
todos: Todo[];
}
// =============================================================================
// Store Interface
// =============================================================================
interface TodoStore {
/**
* Todo state by session ID
*/
sessions: Map<string, SessionTodoState>;
/**
* Get todos for a session
*/
getTodos: (sessionId: string) => Todo[];
/**
* Update todos for a session (replaces entire list)
*/
setTodos: (sessionId: string, todos: Todo[]) => void;
/**
* Clear todos for a session
*/
clearTodos: (sessionId: string) => void;
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useTodoStore = create<TodoStore>()((set, get) => ({
sessions: new Map(),
getTodos: (sessionId: string): Todo[] => {
return get().sessions.get(sessionId)?.todos ?? [];
},
setTodos: (sessionId: string, todos: Todo[]): void => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.set(sessionId, { todos });
return { sessions: newSessions };
});
},
clearTodos: (sessionId: string): void => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(sessionId);
return { sessions: newSessions };
});
},
}));

View File

@@ -0,0 +1,40 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { ResourceMetadata } from '@dexto/core';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Filter and sort resources by query, most recently modified first.
* @param resources - Array of resource metadata
* @param query - Search query string
* @param limit - Maximum number of results (default: 25)
* @returns Filtered and sorted array of resources
*/
export function filterAndSortResources(
resources: ResourceMetadata[],
query: string,
limit: number = 25
): ResourceMetadata[] {
const q = query.toLowerCase();
const parseDate = (val?: string | Date): number => {
if (!val) return 0;
const time = new Date(val).getTime();
return isNaN(time) ? 0 : time;
};
const sorted = [...resources].sort((a, b) => {
const aTime = parseDate(a.lastModified);
const bTime = parseDate(b.lastModified);
return bTime - aTime;
});
return sorted
.filter(
(r) =>
(r.name || '').toLowerCase().includes(q) ||
r.uri.toLowerCase().includes(q) ||
(r.serverName || '').toLowerCase().includes(q)
)
.slice(0, limit);
}