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