feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,458 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { ChatSession } from './chat-session.js';
import { type ValidatedLLMConfig } from '@core/llm/schemas.js';
import { LLMConfigSchema } from '@core/llm/schemas.js';
// Mock all dependencies
vi.mock('./history/factory.js', () => ({
createDatabaseHistoryProvider: vi.fn(),
}));
vi.mock('../llm/services/factory.js', () => ({
createLLMService: vi.fn(),
createVercelModel: vi.fn(),
}));
vi.mock('../context/compaction/index.js', () => ({
createCompactionStrategy: vi.fn(),
compactionRegistry: {
register: vi.fn(),
get: vi.fn(),
has: vi.fn(),
getTypes: vi.fn(),
getAll: vi.fn(),
clear: vi.fn(),
},
}));
vi.mock('../llm/registry.js', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('../llm/registry.js');
return {
...actual,
getEffectiveMaxInputTokens: vi.fn(),
};
});
vi.mock('../logger/index.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
silly: vi.fn(),
},
}));
import { createDatabaseHistoryProvider } from './history/factory.js';
import { createLLMService, createVercelModel } from '../llm/services/factory.js';
import { createCompactionStrategy } from '../context/compaction/index.js';
import { getEffectiveMaxInputTokens } from '../llm/registry.js';
import { createMockLogger } from '../logger/v2/test-utils.js';
const mockCreateDatabaseHistoryProvider = vi.mocked(createDatabaseHistoryProvider);
const mockCreateLLMService = vi.mocked(createLLMService);
const mockCreateVercelModel = vi.mocked(createVercelModel);
const mockCreateCompactionStrategy = vi.mocked(createCompactionStrategy);
const mockGetEffectiveMaxInputTokens = vi.mocked(getEffectiveMaxInputTokens);
describe('ChatSession', () => {
let chatSession: ChatSession;
let mockServices: any;
let mockHistoryProvider: any;
let mockLLMService: any;
let mockCache: any;
let mockDatabase: any;
let mockBlobStore: any;
let mockContextManager: any;
const mockLogger = createMockLogger();
const sessionId = 'test-session-123';
const mockLLMConfig = LLMConfigSchema.parse({
provider: 'openai',
model: 'gpt-5',
apiKey: 'test-key',
maxIterations: 50,
maxInputTokens: 128000,
});
beforeEach(() => {
vi.resetAllMocks();
// Mock history provider
mockHistoryProvider = {
addMessage: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockResolvedValue([]),
clearHistory: vi.fn().mockResolvedValue(undefined),
getMessageCount: vi.fn().mockResolvedValue(0),
};
// Mock LLM service
mockContextManager = {
resetConversation: vi.fn().mockResolvedValue(undefined),
};
mockLLMService = {
stream: vi.fn().mockResolvedValue('Mock response'),
switchLLM: vi.fn().mockResolvedValue(undefined),
getContextManager: vi.fn().mockReturnValue(mockContextManager),
eventBus: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
};
// Mock storage manager with proper getter structure
mockCache = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(true),
list: vi.fn().mockResolvedValue([]),
clear: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
getBackendType: vi.fn().mockReturnValue('memory'),
};
mockDatabase = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(true),
list: vi.fn().mockResolvedValue([]),
clear: vi.fn().mockResolvedValue(undefined),
append: vi.fn().mockResolvedValue(undefined),
getRange: vi.fn().mockResolvedValue([]),
getLength: vi.fn().mockResolvedValue(0),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
getBackendType: vi.fn().mockReturnValue('memory'),
};
mockBlobStore = {
store: vi.fn().mockResolvedValue({ id: 'test', uri: 'blob:test' }),
retrieve: vi.fn().mockResolvedValue({ data: '', metadata: {} }),
exists: vi.fn().mockResolvedValue(false),
delete: vi.fn().mockResolvedValue(undefined),
cleanup: vi.fn().mockResolvedValue(0),
getStats: vi.fn().mockResolvedValue({ count: 0, totalSize: 0, backendType: 'local' }),
listBlobs: vi.fn().mockResolvedValue([]),
getStoragePath: vi.fn().mockReturnValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn().mockReturnValue(true),
getStoreType: vi.fn().mockReturnValue('local'),
};
const mockStorageManager = {
getCache: vi.fn().mockReturnValue(mockCache),
getDatabase: vi.fn().mockReturnValue(mockDatabase),
getBlobStore: vi.fn().mockReturnValue(mockBlobStore),
disconnect: vi.fn().mockResolvedValue(undefined),
};
// Mock services
mockServices = {
stateManager: {
getLLMConfig: vi.fn().mockReturnValue(mockLLMConfig),
getRuntimeConfig: vi.fn().mockReturnValue({
llm: mockLLMConfig,
compression: { type: 'noop', enabled: true },
}),
updateLLM: vi.fn().mockReturnValue({ isValid: true, errors: [], warnings: [] }),
},
systemPromptManager: {
getSystemPrompt: vi.fn().mockReturnValue('System prompt'),
},
mcpManager: {
getAllTools: vi.fn().mockResolvedValue({}),
},
agentEventBus: {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
},
storageManager: mockStorageManager,
resourceManager: {
getBlobStore: vi.fn(),
readResource: vi.fn(),
listResources: vi.fn(),
},
toolManager: {
getAllTools: vi.fn().mockReturnValue([]),
},
pluginManager: {
executePlugins: vi.fn().mockImplementation(async (_point, payload) => payload),
cleanup: vi.fn(),
},
sessionManager: {
// Add sessionManager mock if needed
},
};
// Set up factory mocks
mockCreateDatabaseHistoryProvider.mockReturnValue(mockHistoryProvider);
mockCreateLLMService.mockReturnValue(mockLLMService);
mockCreateVercelModel.mockReturnValue('mock-model' as any);
mockCreateCompactionStrategy.mockResolvedValue(null); // No compaction for tests
mockGetEffectiveMaxInputTokens.mockReturnValue(128000);
// Create ChatSession instance
chatSession = new ChatSession(mockServices, sessionId, mockLogger);
});
afterEach(() => {
// Clean up any resources
if (chatSession) {
chatSession.dispose();
}
});
describe('Session Identity and Lifecycle', () => {
test('should maintain session identity throughout lifecycle', () => {
expect(chatSession.id).toBe(sessionId);
expect(chatSession.eventBus).toBeDefined();
});
test('should initialize with unified storage system', async () => {
await chatSession.init();
// Verify createDatabaseHistoryProvider is called with the database backend, sessionId, and logger
expect(mockCreateDatabaseHistoryProvider).toHaveBeenCalledWith(
mockDatabase,
sessionId,
expect.any(Object) // Logger object
);
});
test('should properly dispose resources to prevent memory leaks', () => {
const eventSpy = vi.spyOn(chatSession.eventBus, 'off');
chatSession.dispose();
chatSession.dispose(); // Should not throw on multiple calls
expect(eventSpy).toHaveBeenCalled();
});
});
describe('Event System Integration', () => {
test('should forward all session events to agent bus with session context', async () => {
await chatSession.init();
// Emit a session event
chatSession.eventBus.emit('llm:thinking');
expect(mockServices.agentEventBus.emit).toHaveBeenCalledWith(
'llm:thinking',
expect.objectContaining({
sessionId,
})
);
});
test('should handle events with no payload by adding session context', async () => {
await chatSession.init();
// Emit event without payload (using llm:thinking as example)
chatSession.eventBus.emit('llm:thinking');
expect(mockServices.agentEventBus.emit).toHaveBeenCalledWith('llm:thinking', {
sessionId,
});
});
test('should emit dexto:conversationReset event when conversation is reset', async () => {
await chatSession.init();
await chatSession.reset();
// Should reset conversation via ContextManager
expect(mockContextManager.resetConversation).toHaveBeenCalled();
// Should emit dexto:conversationReset event with session context
expect(mockServices.agentEventBus.emit).toHaveBeenCalledWith('session:reset', {
sessionId,
});
});
});
describe('LLM Configuration Management', () => {
beforeEach(async () => {
await chatSession.init();
});
test('should create new LLM service when configuration changes', async () => {
const newConfig: ValidatedLLMConfig = {
...mockLLMConfig,
maxInputTokens: 256000, // Change maxInputTokens
};
// Clear previous calls
mockCreateLLMService.mockClear();
await chatSession.switchLLM(newConfig);
// Should create a new LLM service with updated config
expect(mockCreateLLMService).toHaveBeenCalledWith(
newConfig,
mockServices.toolManager,
mockServices.systemPromptManager,
mockHistoryProvider,
chatSession.eventBus,
sessionId,
mockServices.resourceManager,
mockLogger,
null, // compaction strategy
undefined // compaction config
);
});
test('should create new LLM service during LLM switch', async () => {
const newConfig: ValidatedLLMConfig = {
...mockLLMConfig,
provider: 'anthropic',
model: 'claude-4-opus-20250514',
};
// Clear previous calls to createLLMService
mockCreateLLMService.mockClear();
await chatSession.switchLLM(newConfig);
// Should create a new LLM service with the new config
expect(mockCreateLLMService).toHaveBeenCalledWith(
newConfig,
mockServices.toolManager,
mockServices.systemPromptManager,
mockHistoryProvider,
chatSession.eventBus,
sessionId,
mockServices.resourceManager,
mockLogger,
null, // compaction strategy
undefined // compaction config
);
});
test('should emit LLM switched event with correct metadata', async () => {
const newConfig: ValidatedLLMConfig = {
...mockLLMConfig,
provider: 'anthropic',
model: 'claude-4-opus-20250514',
};
const eventSpy = vi.spyOn(chatSession.eventBus, 'emit');
await chatSession.switchLLM(newConfig);
expect(eventSpy).toHaveBeenCalledWith(
'llm:switched',
expect.objectContaining({
newConfig,
historyRetained: true,
})
);
});
});
describe('Error Handling and Resilience', () => {
test('should handle storage initialization failures gracefully', async () => {
mockCreateDatabaseHistoryProvider.mockImplementation(() => {
throw new Error('Storage initialization failed');
});
// The init method should throw the error since it doesn't catch it
await expect(chatSession.init()).rejects.toThrow('Storage initialization failed');
});
test('should handle LLM service creation failures', async () => {
mockCreateLLMService.mockImplementation(() => {
throw new Error('LLM service creation failed');
});
await expect(chatSession.init()).rejects.toThrow('LLM service creation failed');
});
test('should handle LLM switch failures and propagate errors', async () => {
await chatSession.init();
const newConfig: ValidatedLLMConfig = {
...mockLLMConfig,
provider: 'invalid-provider' as any,
};
mockCreateLLMService.mockImplementation(() => {
throw new Error('Invalid provider');
});
await expect(chatSession.switchLLM(newConfig)).rejects.toThrow('Invalid provider');
});
test('should handle conversation errors from LLM service', async () => {
await chatSession.init();
mockLLMService.stream.mockRejectedValue(new Error('LLM service error'));
await expect(chatSession.stream('test message')).rejects.toThrow('LLM service error');
});
});
describe('Service Integration Points', () => {
beforeEach(async () => {
await chatSession.init();
});
test('should delegate conversation operations to LLM service', async () => {
const userMessage = 'Hello, world!';
const expectedResponse = 'Hello! How can I help you?';
mockLLMService.stream.mockResolvedValue({ text: expectedResponse });
const response = await chatSession.stream(userMessage);
expect(response).toEqual({ text: expectedResponse });
expect(mockLLMService.stream).toHaveBeenCalledWith(
[{ type: 'text', text: userMessage }],
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
test('should delegate history operations to history provider', async () => {
const mockHistory = [
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
];
mockHistoryProvider.getHistory = vi.fn().mockResolvedValue(mockHistory);
await chatSession.init();
const history = await chatSession.getHistory();
expect(history).toEqual(mockHistory);
expect(mockHistoryProvider.getHistory).toHaveBeenCalled();
});
});
describe('Session Isolation', () => {
test('should create session-specific services with proper isolation', async () => {
await chatSession.init();
// Verify session-specific LLM service creation with new signature
expect(mockCreateLLMService).toHaveBeenCalledWith(
mockLLMConfig,
mockServices.toolManager,
mockServices.systemPromptManager,
mockHistoryProvider,
chatSession.eventBus, // Session-specific event bus
sessionId,
mockServices.resourceManager, // ResourceManager parameter
mockLogger, // Logger parameter
null, // compaction strategy
undefined // compaction config
);
// Verify session-specific history provider creation
expect(mockCreateDatabaseHistoryProvider).toHaveBeenCalledWith(
mockDatabase,
sessionId,
expect.any(Object) // Logger object
);
});
});
});

View File

@@ -0,0 +1,793 @@
import { createDatabaseHistoryProvider } from './history/factory.js';
import { createLLMService, createVercelModel } from '../llm/services/factory.js';
import { createCompactionStrategy } from '../context/compaction/index.js';
import type { ContextManager } from '@core/context/index.js';
import type { IConversationHistoryProvider } from './history/types.js';
import type { VercelLLMService } from '../llm/services/vercel.js';
import type { SystemPromptManager } from '../systemPrompt/manager.js';
import type { ToolManager } from '../tools/tool-manager.js';
import type { ValidatedLLMConfig } from '@core/llm/schemas.js';
import type { AgentStateManager } from '../agent/state-manager.js';
import type { StorageManager } from '../storage/index.js';
import type { PluginManager } from '../plugins/manager.js';
import type { MCPManager } from '../mcp/manager.js';
import type { BeforeLLMRequestPayload, BeforeResponsePayload } from '../plugins/types.js';
import {
SessionEventBus,
AgentEventBus,
SessionEventNames,
SessionEventName,
SessionEventMap,
} from '../events/index.js';
import type { IDextoLogger } from '../logger/v2/types.js';
import { DextoLogComponent } from '../logger/v2/types.js';
import { DextoRuntimeError, ErrorScope, ErrorType } from '../errors/index.js';
import { PluginErrorCode } from '../plugins/error-codes.js';
import type { InternalMessage, ContentPart } from '../context/types.js';
import type { UserMessageInput } from './message-queue.js';
import type { ContentInput } from '../agent/types.js';
import { getModelPricing, calculateCost } from '../llm/registry.js';
/**
* Represents an isolated conversation session within a Dexto agent.
*
* ChatSession provides session-level isolation for conversations, allowing multiple
* independent chat contexts to exist within a single DextoAgent instance. Each session
* maintains its own conversation history, message management, and event handling.
*
* ## Architecture
*
* The ChatSession acts as a lightweight wrapper around core Dexto services, providing
* session-specific instances of:
* - **ContextManager**: Handles conversation history and message formatting
* - **LLMService**: Manages AI model interactions and tool execution
* - **TypedEventEmitter**: Provides session-scoped event handling
*
* ## Event Handling
*
* Each session has its own event bus that emits standard Dexto events:
* - `llm:*` events (thinking, toolCall, response, etc.)
*
* Session events are forwarded to the global agent event bus with session prefixes.
*
* ## Usage Example
*
* ```typescript
* // Create a new session
* const session = agent.createSession('user-123');
*
* // Listen for session events
* session.eventBus.on('llm:response', (payload) => {
* console.log('Session response:', payload.content);
* });
*
* // Run a conversation turn
* const response = await session.run('Hello, how are you?');
*
* // Reset session history
* await session.reset();
* ```
*
* @see {@link SessionManager} for session lifecycle management
* @see {@link ContextManager} for conversation history management
* @see {@link VercelLLMService} for AI model interaction
*/
export class ChatSession {
/**
* Session-scoped event emitter for handling conversation events.
*
* This is a session-local SessionEventBus instance that forwards events
* to the global agent event bus.
*
* Events emitted include:
* - `llm:thinking` - AI model is processing
* - `llm:tool-call` - Tool execution requested
* - `llm:response` - Final response generated
*/
public readonly eventBus: SessionEventBus;
/**
* History provider that persists conversation messages.
* Shared across LLM switches to maintain conversation continuity.
*/
private historyProvider!: IConversationHistoryProvider;
/**
* Handles AI model interactions, tool execution, and response generation for this session.
*
* Each session has its own LLMService instance that uses the session's
* ContextManager and event bus.
*/
private llmService!: VercelLLMService;
/**
* Map of event forwarder functions for cleanup.
* Stores the bound functions so they can be removed from the event bus.
*/
private forwarders: Map<SessionEventName, (payload?: any) => void> = new Map();
/**
* Token accumulator listener for cleanup.
*/
private tokenAccumulatorListener: ((payload: SessionEventMap['llm:response']) => void) | null =
null;
/**
* AbortController for the currently running turn, if any.
* Calling cancel() aborts the in-flight LLM request and tool execution checks.
*/
private currentRunController: AbortController | null = null;
private logger: IDextoLogger;
/**
* Creates a new ChatSession instance.
*
* Each session creates its own isolated services:
* - ConversationHistoryProvider (with session-specific storage, shared across LLM switches)
* - LLMService (creates its own properly-typed ContextManager internally)
* - SessionEventBus (session-local event handling with forwarding)
*
* @param services - The shared services from the agent (state manager, prompt, client managers, etc.)
* @param id - Unique identifier for this session
* @param logger - Logger instance for dependency injection
*/
constructor(
private services: {
stateManager: AgentStateManager;
systemPromptManager: SystemPromptManager;
toolManager: ToolManager;
agentEventBus: AgentEventBus;
storageManager: StorageManager;
resourceManager: import('../resources/index.js').ResourceManager;
pluginManager: PluginManager;
mcpManager: MCPManager;
sessionManager: import('./session-manager.js').SessionManager;
},
public readonly id: string,
logger: IDextoLogger
) {
this.logger = logger.createChild(DextoLogComponent.SESSION);
// Create session-specific event bus
this.eventBus = new SessionEventBus();
// Set up event forwarding to agent's global bus
this.setupEventForwarding();
// Services will be initialized in init() method due to async requirements
this.logger.debug(`ChatSession ${this.id}: Created, awaiting initialization`);
}
/**
* Initialize the session services asynchronously.
* This must be called after construction to set up the storage-backed services.
*/
public async init(): Promise<void> {
await this.initializeServices();
}
/**
* Sets up event forwarding from session bus to global agent bus.
*
* All session events are automatically forwarded to the global bus with the same
* event names, but with session context added to the payload. This allows the app
* layer to continue listening to standard events while having access to session
* information when needed.
*/
private setupEventForwarding(): void {
// Forward each session event type to the agent bus with session context
SessionEventNames.forEach((eventName) => {
const forwarder = (payload?: any) => {
// Create payload with sessionId - handle both void and object payloads
const payloadWithSession =
payload && typeof payload === 'object'
? { ...payload, sessionId: this.id }
: { sessionId: this.id };
// Forward to agent bus with session context
this.services.agentEventBus.emit(eventName as any, payloadWithSession);
};
// Store the forwarder function for later cleanup
this.forwarders.set(eventName, forwarder);
// Attach the forwarder to the session event bus
this.eventBus.on(eventName, forwarder);
});
// Set up token usage accumulation on llm:response
this.setupTokenAccumulation();
this.logger.debug(
`[setupEventForwarding] Event forwarding setup complete for session=${this.id}`
);
}
/**
* Sets up token usage accumulation by listening to llm:response events.
* Accumulates token usage and cost to session metadata for /stats tracking.
*/
private setupTokenAccumulation(): void {
this.tokenAccumulatorListener = (payload: SessionEventMap['llm:response']) => {
if (payload.tokenUsage) {
// Calculate cost if pricing is available
let cost: number | undefined;
const llmConfig = this.services.stateManager.getLLMConfig(this.id);
const pricing = getModelPricing(llmConfig.provider, llmConfig.model);
if (pricing) {
cost = calculateCost(payload.tokenUsage, pricing);
}
// Fire and forget - don't block the event flow
this.services.sessionManager
.accumulateTokenUsage(this.id, payload.tokenUsage, cost)
.catch((err) => {
this.logger.warn(
`Failed to accumulate token usage: ${err instanceof Error ? err.message : String(err)}`
);
});
}
};
this.eventBus.on('llm:response', this.tokenAccumulatorListener);
}
/**
* Initializes session-specific services.
*/
private async initializeServices(): Promise<void> {
// Get current effective configuration for this session from state manager
const runtimeConfig = this.services.stateManager.getRuntimeConfig(this.id);
const llmConfig = runtimeConfig.llm;
// Create session-specific history provider directly with database backend
// This persists across LLM switches to maintain conversation history
this.historyProvider = createDatabaseHistoryProvider(
this.services.storageManager.getDatabase(),
this.id,
this.logger
);
// Create model and compaction strategy from config
const model = createVercelModel(llmConfig);
const compactionStrategy = await createCompactionStrategy(runtimeConfig.compaction, {
logger: this.logger,
model,
});
// Create session-specific LLM service
// The service will create its own properly-typed ContextManager internally
this.llmService = createLLMService(
llmConfig,
this.services.toolManager,
this.services.systemPromptManager,
this.historyProvider, // Pass history provider for service to use
this.eventBus, // Use session event bus
this.id,
this.services.resourceManager, // Pass ResourceManager for blob storage
this.logger, // Pass logger for dependency injection
compactionStrategy, // Pass compaction strategy
runtimeConfig.compaction // Pass compaction config for threshold settings
);
this.logger.debug(`ChatSession ${this.id}: Services initialized with storage`);
}
/**
* Saves a blocked interaction to history when a plugin blocks execution.
* This ensures that even when a plugin blocks execution (e.g., due to abusive language),
* the user's message and the error response are preserved in the conversation history.
*
* @param userInput - The user's text input that was blocked
* @param errorMessage - The error message explaining why execution was blocked
* @param imageData - Optional image data that was part of the blocked message
* @param fileData - Optional file data that was part of the blocked message
* @private
*/
private async saveBlockedInteraction(
userInput: string,
errorMessage: string,
_imageData?: { image: string; mimeType: string },
_fileData?: { data: string; mimeType: string; filename?: string }
): Promise<void> {
// Create redacted user message (do not persist sensitive content or attachments)
// When content is blocked by policy (abusive language, inappropriate content, etc.),
// we shouldn't store the original content to comply with data minimization principles
const userMessage: InternalMessage = {
role: 'user',
content: [{ type: 'text', text: '[Blocked by content policy: input redacted]' }],
};
// Create assistant error message
const errorContent = `Error: ${errorMessage}`;
const assistantMessage: InternalMessage = {
role: 'assistant',
content: [{ type: 'text', text: errorContent }],
};
// Add both messages to history
await this.historyProvider.saveMessage(userMessage);
await this.historyProvider.saveMessage(assistantMessage);
// Emit response event so UI updates immediately on blocked interactions
// This ensures listeners relying on llm:response know a response was added
// Note: sessionId is automatically added by event forwarding layer
const llmConfig = this.services.stateManager.getLLMConfig(this.id);
this.eventBus.emit('llm:response', {
content: errorContent,
provider: llmConfig.provider,
model: llmConfig.model,
});
}
/**
* Stream a response for the given content.
* Primary method for running conversations with multi-image support.
*
* @param content - String or ContentPart[] (text, images, files)
* @param options - { signal?: AbortSignal }
* @returns Promise that resolves to object with text response
*
* @example
* ```typescript
* // Text only
* const { text } = await session.stream('What is the weather?');
*
* // Multiple images
* const { text } = await session.stream([
* { type: 'text', text: 'Compare these images' },
* { type: 'image', image: base64Data1, mimeType: 'image/png' },
* { type: 'image', image: base64Data2, mimeType: 'image/png' }
* ]);
* ```
*/
public async stream(
content: ContentInput,
options?: { signal?: AbortSignal }
): Promise<{ text: string }> {
// Normalize content to ContentPart[]
const parts: ContentPart[] =
typeof content === 'string' ? [{ type: 'text', text: content }] : content;
// Extract text for logging (no sensitive content)
const textParts = parts.filter(
(p): p is { type: 'text'; text: string } => p.type === 'text'
);
const imageParts = parts.filter((p) => p.type === 'image');
const fileParts = parts.filter((p) => p.type === 'file');
this.logger.debug(
`Streaming session ${this.id} | textParts=${textParts.length} | images=${imageParts.length} | files=${fileParts.length}`
);
// Create an AbortController for this run and expose for cancellation
this.currentRunController = new AbortController();
const signal = options?.signal
? this.combineSignals(options.signal, this.currentRunController.signal)
: this.currentRunController.signal;
try {
// Execute beforeLLMRequest plugins
// For backward compatibility, extract first image/file for plugin payload
const textContent = textParts.map((p) => p.text).join('\n');
const firstImage = imageParts[0] as
| { type: 'image'; image: string; mimeType?: string }
| undefined;
const firstFile = fileParts[0] as
| { type: 'file'; data: string; mimeType: string; filename?: string }
| undefined;
const beforeLLMPayload: BeforeLLMRequestPayload = {
text: textContent,
...(firstImage && {
imageData: {
image: typeof firstImage.image === 'string' ? firstImage.image : '[binary]',
mimeType: firstImage.mimeType || 'image/jpeg',
},
}),
...(firstFile && {
fileData: {
data: typeof firstFile.data === 'string' ? firstFile.data : '[binary]',
mimeType: firstFile.mimeType,
...(firstFile.filename && { filename: firstFile.filename }),
},
}),
sessionId: this.id,
};
const modifiedBeforePayload = await this.services.pluginManager.executePlugins(
'beforeLLMRequest',
beforeLLMPayload,
{
sessionManager: this.services.sessionManager,
mcpManager: this.services.mcpManager,
toolManager: this.services.toolManager,
stateManager: this.services.stateManager,
sessionId: this.id,
abortSignal: signal,
}
);
// Apply plugin text modifications to the first text part
let modifiedParts = [...parts];
if (modifiedBeforePayload.text !== textContent && textParts.length > 0) {
// Replace text parts with modified text
modifiedParts = modifiedParts.filter((p) => p.type !== 'text');
modifiedParts.unshift({ type: 'text', text: modifiedBeforePayload.text });
}
// Call LLM service stream
const streamResult = await this.llmService.stream(modifiedParts, { signal });
// Execute beforeResponse plugins
const llmConfig = this.services.stateManager.getLLMConfig(this.id);
const beforeResponsePayload: BeforeResponsePayload = {
content: streamResult.text,
provider: llmConfig.provider,
model: llmConfig.model,
sessionId: this.id,
};
const modifiedResponsePayload = await this.services.pluginManager.executePlugins(
'beforeResponse',
beforeResponsePayload,
{
sessionManager: this.services.sessionManager,
mcpManager: this.services.mcpManager,
toolManager: this.services.toolManager,
stateManager: this.services.stateManager,
sessionId: this.id,
abortSignal: signal,
}
);
return {
text: modifiedResponsePayload.content,
};
} catch (error) {
// If this was an intentional cancellation, return partial response from history
const aborted =
(error instanceof Error && error.name === 'AbortError') ||
(typeof error === 'object' && error !== null && (error as any).aborted === true);
if (aborted) {
this.eventBus.emit('llm:error', {
error: new Error('Run cancelled'),
context: 'user_cancelled',
recoverable: true,
});
// Return partial content that was persisted during streaming
try {
const history = await this.getHistory();
const lastAssistant = history.filter((m) => m.role === 'assistant').pop();
if (lastAssistant) {
if (typeof lastAssistant.content === 'string') {
return { text: lastAssistant.content };
}
// Handle multimodal content (ContentPart[]) - extract text parts
if (Array.isArray(lastAssistant.content)) {
const text = lastAssistant.content
.filter(
(part): part is { type: 'text'; text: string } =>
part.type === 'text'
)
.map((part) => part.text)
.join('');
if (text) {
return { text };
}
}
}
} catch {
this.logger.debug('Failed to retrieve partial response from history on cancel');
}
return { text: '' };
}
// Check if this is a plugin blocking error
if (
error instanceof DextoRuntimeError &&
error.code === PluginErrorCode.PLUGIN_BLOCKED_EXECUTION &&
error.scope === ErrorScope.PLUGIN &&
error.type === ErrorType.FORBIDDEN
) {
// Save the blocked interaction to history
const textContent = parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('\n');
try {
await this.saveBlockedInteraction(textContent, error.message);
this.logger.debug(
`ChatSession ${this.id}: Saved blocked interaction to history`
);
} catch (saveError) {
this.logger.warn(
`Failed to save blocked interaction to history: ${
saveError instanceof Error ? saveError.message : String(saveError)
}`
);
}
return { text: error.message };
}
this.logger.error(
`Error in ChatSession.stream: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
} finally {
this.currentRunController = null;
}
}
/**
* Combine multiple abort signals into one.
*/
private combineSignals(signal1: AbortSignal, signal2: AbortSignal): AbortSignal {
const controller = new AbortController();
const onAbort = () => controller.abort();
signal1.addEventListener('abort', onAbort);
signal2.addEventListener('abort', onAbort);
if (signal1.aborted || signal2.aborted) {
controller.abort();
}
return controller.signal;
}
/**
* Retrieves the complete conversation history for this session.
*
* Returns a read-only copy of all messages in the conversation, including:
* - User messages
* - Assistant responses
* - Tool call results
* - System messages
*
* The history is formatted as internal messages and may include multimodal
* content (text and images).
*
* @returns Promise that resolves to a read-only array of conversation messages in chronological order
*
* @example
* ```typescript
* const history = await session.getHistory();
* console.log(`Conversation has ${history.length} messages`);
* history.forEach(msg => console.log(`${msg.role}: ${msg.content}`));
* ```
*/
public async getHistory() {
return await this.historyProvider.getHistory();
}
/**
* Reset the conversation history for this session.
*
* This method:
* 1. Clears all messages from the session's conversation history
* 2. Removes persisted history from the storage provider
* 3. Emits a `session:reset` event with session context
*
* The system prompt and session configuration remain unchanged.
* Only the conversation messages are cleared.
*
* @returns Promise that resolves when the reset is complete
*
* @example
* ```typescript
* await session.reset();
* console.log('Conversation history cleared');
* ```
*
* @see {@link ContextManager.resetConversation} for the underlying implementation
*/
public async reset(): Promise<void> {
await this.llmService.getContextManager().resetConversation();
// Emit agent-level event with session context
this.services.agentEventBus.emit('session:reset', {
sessionId: this.id,
});
}
/**
* Gets the session's ContextManager instance.
*
* @returns The ContextManager for this session
*/
public getContextManager(): ContextManager<unknown> {
return this.llmService.getContextManager();
}
/**
* Gets the session's LLMService instance.
*
* @returns The LLMService for this session
*/
public getLLMService(): VercelLLMService {
return this.llmService;
}
/**
* Switches the LLM service for this session while preserving conversation history.
*
* This method creates a new LLM service with the specified configuration,
* while maintaining the existing ContextManager and conversation history. This allows
* users to change AI models mid-conversation without losing context.
*
* @param newLLMConfig The new LLM configuration to use
*
* @example
* ```typescript
* // Switch from Claude to GPT-5 while keeping conversation history
* session.switchLLM({
* provider: 'openai',
* model: 'gpt-5',
* apiKey: process.env.OPENAI_API_KEY,
* });
* ```
*/
public async switchLLM(newLLMConfig: ValidatedLLMConfig): Promise<void> {
try {
// Get compression config for this session
const runtimeConfig = this.services.stateManager.getRuntimeConfig(this.id);
// Create model and compaction strategy from config
const model = createVercelModel(newLLMConfig);
const compactionStrategy = await createCompactionStrategy(runtimeConfig.compaction, {
logger: this.logger,
model,
});
// Create new LLM service with new config but SAME history provider
// The service will create its own new ContextManager internally
const newLLMService = createLLMService(
newLLMConfig,
this.services.toolManager,
this.services.systemPromptManager,
this.historyProvider, // Pass the SAME history provider - preserves conversation!
this.eventBus, // Use session event bus
this.id,
this.services.resourceManager,
this.logger,
compactionStrategy, // Pass compaction strategy
runtimeConfig.compaction // Pass compaction config for threshold settings
);
// Replace the LLM service
this.llmService = newLLMService;
this.logger.info(
`ChatSession ${this.id}: LLM switched to ${newLLMConfig.provider}/${newLLMConfig.model}`
);
// Emit session-level event
this.eventBus.emit('llm:switched', {
newConfig: newLLMConfig,
historyRetained: true,
});
} catch (error) {
this.logger.error(
`Error during ChatSession.switchLLM for session ${this.id}: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
/**
* Cleanup the session and its in-memory resources.
* This method should be called when the session is being removed from memory.
* Chat history is preserved in storage and can be restored later.
*/
public async cleanup(): Promise<void> {
try {
// Only dispose of event listeners and in-memory resources
// Do NOT reset conversation - that would delete chat history!
this.dispose();
this.logger.debug(
`ChatSession ${this.id}: Memory cleanup completed (chat history preserved)`
);
} catch (error) {
this.logger.error(
`Error during ChatSession cleanup for session ${this.id}: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
/**
* Cleans up listeners and other resources to prevent memory leaks.
*
* This method should be called when the session is being discarded to ensure
* that event listeners are properly removed from the global event bus.
* Without this cleanup, sessions would remain in memory due to listener references.
*/
public dispose(): void {
this.logger.debug(`Disposing session ${this.id} - cleaning up event listeners`);
// Remove all event forwarders from the session event bus
this.forwarders.forEach((forwarder, eventName) => {
this.eventBus.off(eventName, forwarder);
});
// Clear the forwarders map
this.forwarders.clear();
// Remove token accumulator listener
if (this.tokenAccumulatorListener) {
this.eventBus.off('llm:response', this.tokenAccumulatorListener);
this.tokenAccumulatorListener = null;
}
this.logger.debug(`Session ${this.id} disposed successfully`);
}
/**
* Check if this session is currently processing a message.
* Returns true if a run is in progress and has not been aborted.
*/
public isBusy(): boolean {
return this.currentRunController !== null && !this.currentRunController.signal.aborted;
}
/**
* Queue a message for processing when the session is busy.
* The message will be injected into the conversation when the current turn completes.
*
* @param message The user message to queue
* @returns Queue position and message ID
*/
public queueMessage(message: UserMessageInput): { queued: true; position: number; id: string } {
return this.llmService.getMessageQueue().enqueue(message);
}
/**
* Get all messages currently in the queue.
* @returns Array of queued messages
*/
public getQueuedMessages(): import('./types.js').QueuedMessage[] {
return this.llmService.getMessageQueue().getAll();
}
/**
* Remove a queued message.
* @param id Message ID to remove
* @returns true if message was found and removed; false otherwise
*/
public removeQueuedMessage(id: string): boolean {
return this.llmService.getMessageQueue().remove(id);
}
/**
* Clear all queued messages.
* @returns Number of messages that were cleared
*/
public clearMessageQueue(): number {
const queue = this.llmService.getMessageQueue();
const count = queue.pendingCount();
queue.clear();
return count;
}
/**
* Cancel the currently running turn for this session, if any.
* Returns true if a run was in progress and was signaled to abort.
*/
public cancel(): boolean {
const controller = this.currentRunController;
if (!controller || controller.signal.aborted) {
return false;
}
try {
controller.abort();
return true;
} catch {
// Already aborted or abort failed
return false;
}
}
}

View File

@@ -0,0 +1,16 @@
/**
* Session-specific error codes
* Includes session lifecycle, management, and state errors
*/
export enum SessionErrorCode {
// Session lifecycle
SESSION_NOT_FOUND = 'session_not_found',
SESSION_INITIALIZATION_FAILED = 'session_initialization_failed',
SESSION_MAX_SESSIONS_EXCEEDED = 'session_max_sessions_exceeded',
// Session storage
SESSION_STORAGE_FAILED = 'session_storage_failed',
// Session operations
SESSION_RESET_FAILED = 'session_reset_failed',
}

View File

@@ -0,0 +1,75 @@
import { DextoRuntimeError } from '@core/errors/DextoRuntimeError.js';
import { ErrorScope, ErrorType } from '@core/errors/types.js';
import { SessionErrorCode } from './error-codes.js';
/**
* Session error factory with typed methods for creating session-specific errors
* Each method creates a properly typed DextoError with SESSION scope
*/
export class SessionError {
/**
* Session not found
*/
static notFound(sessionId: string) {
return new DextoRuntimeError(
SessionErrorCode.SESSION_NOT_FOUND,
ErrorScope.SESSION,
ErrorType.NOT_FOUND,
`Session ${sessionId} not found`,
{ sessionId }
);
}
/**
* Session initialization failed
*/
static initializationFailed(sessionId: string, reason: string) {
return new DextoRuntimeError(
SessionErrorCode.SESSION_INITIALIZATION_FAILED,
ErrorScope.SESSION,
ErrorType.SYSTEM,
`Failed to initialize session '${sessionId}': ${reason}`,
{ sessionId, reason }
);
}
/**
* Maximum number of sessions exceeded
*/
static maxSessionsExceeded(currentCount: number, maxSessions: number) {
return new DextoRuntimeError(
SessionErrorCode.SESSION_MAX_SESSIONS_EXCEEDED,
ErrorScope.SESSION,
ErrorType.USER,
`Maximum sessions (${maxSessions}) reached`,
{ currentCount, maxSessions },
'Delete unused sessions or increase maxSessions limit in configuration'
);
}
/**
* Session storage failed
*/
static storageFailed(sessionId: string, operation: string, reason: string) {
return new DextoRuntimeError(
SessionErrorCode.SESSION_STORAGE_FAILED,
ErrorScope.SESSION,
ErrorType.SYSTEM,
`Failed to ${operation} session '${sessionId}': ${reason}`,
{ sessionId, operation, reason }
);
}
/**
* Session reset failed
*/
static resetFailed(sessionId: string, reason: string) {
return new DextoRuntimeError(
SessionErrorCode.SESSION_RESET_FAILED,
ErrorScope.SESSION,
ErrorType.SYSTEM,
`Failed to reset session '${sessionId}': ${reason}`,
{ sessionId, reason }
);
}
}

View File

@@ -0,0 +1,64 @@
import { describe, test, expect, vi, beforeEach, type Mocked } from 'vitest';
import { DatabaseHistoryProvider } from './database.js';
import type { Database } from '@core/storage/types.js';
import { SessionErrorCode } from '../error-codes.js';
import { ErrorScope, ErrorType } from '@core/errors/types.js';
import { createMockLogger } from '@core/logger/v2/test-utils.js';
describe('DatabaseHistoryProvider error mapping', () => {
let db: Mocked<Database>;
let provider: DatabaseHistoryProvider;
const sessionId = 's-1';
const mockLogger = createMockLogger();
beforeEach(() => {
db = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
list: vi.fn(),
clear: vi.fn(),
append: vi.fn(),
getRange: vi.fn(),
getLength: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
getStoreType: vi.fn().mockReturnValue('memory'),
} as any;
provider = new DatabaseHistoryProvider(sessionId, db, mockLogger);
});
test('saveMessage maps backend error to SessionError.storageFailed', async () => {
db.append.mockRejectedValue(new Error('append failed'));
await expect(
provider.saveMessage({ role: 'user', content: 'hi' } as any)
).rejects.toMatchObject({
code: SessionErrorCode.SESSION_STORAGE_FAILED,
scope: ErrorScope.SESSION,
type: ErrorType.SYSTEM,
context: expect.objectContaining({ sessionId }),
});
});
test('clearHistory maps backend error to SessionError.resetFailed', async () => {
db.delete.mockRejectedValue(new Error('delete failed'));
await expect(provider.clearHistory()).rejects.toMatchObject({
code: SessionErrorCode.SESSION_RESET_FAILED,
scope: ErrorScope.SESSION,
type: ErrorType.SYSTEM,
context: expect.objectContaining({ sessionId }),
});
});
test('getHistory maps backend error to SessionError.storageFailed', async () => {
db.getRange.mockRejectedValue(new Error('getRange failed'));
await expect(provider.getHistory()).rejects.toMatchObject({
code: SessionErrorCode.SESSION_STORAGE_FAILED,
scope: ErrorScope.SESSION,
type: ErrorType.SYSTEM,
context: expect.objectContaining({ sessionId }),
});
});
});

View File

@@ -0,0 +1,313 @@
import type { IDextoLogger } from '@core/logger/v2/types.js';
import { DextoLogComponent } from '@core/logger/v2/types.js';
import type { Database } from '@core/storage/types.js';
import { SessionError } from '../errors.js';
import type { InternalMessage } from '@core/context/types.js';
import type { IConversationHistoryProvider } from './types.js';
/**
* History provider that works directly with DatabaseBackend.
* Uses write-through caching for read performance while maintaining durability.
*
* Caching strategy:
* - getHistory(): Returns cached messages after first load (eliminates repeated DB reads)
* - saveMessage(): Updates cache AND writes to DB immediately (new messages are critical)
* - updateMessage(): Updates cache immediately, debounces DB writes (batches rapid updates)
* - flush(): Forces all pending updates to DB (called at turn boundaries)
* - clearHistory(): Clears cache and DB immediately
*
* Durability guarantees:
* - New messages (saveMessage) are always immediately durable
* - Updates (updateMessage) are durable within flush interval or on explicit flush()
* - Worst case on crash: lose updates from last flush interval (typically <100ms)
*/
export class DatabaseHistoryProvider implements IConversationHistoryProvider {
private logger: IDextoLogger;
// Cache state
private cache: InternalMessage[] | null = null;
private dirty = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private flushPromise: Promise<void> | null = null;
// Flush configuration
private static readonly FLUSH_DELAY_MS = 100; // Debounce window for batching updates
constructor(
private sessionId: string,
private database: Database,
logger: IDextoLogger
) {
this.logger = logger.createChild(DextoLogComponent.SESSION);
}
async getHistory(): Promise<InternalMessage[]> {
// Load from DB on first access
if (this.cache === null) {
const key = this.getMessagesKey();
try {
const limit = 10000;
const rawMessages = await this.database.getRange<InternalMessage>(key, 0, limit);
if (rawMessages.length === limit) {
this.logger.warn(
`DatabaseHistoryProvider: Session ${this.sessionId} hit message limit (${limit}), history may be truncated`
);
}
// Deduplicate messages by ID (keep first occurrence to preserve order)
const seen = new Set<string>();
this.cache = [];
let duplicateCount = 0;
for (const msg of rawMessages) {
if (msg.id && seen.has(msg.id)) {
duplicateCount++;
continue; // Skip duplicate
}
if (msg.id) {
seen.add(msg.id);
}
this.cache.push(msg);
}
// Log and self-heal if duplicates found (indicates prior data corruption)
if (duplicateCount > 0) {
this.logger.warn(
`DatabaseHistoryProvider: Found ${duplicateCount} duplicate messages for session ${this.sessionId}, deduped to ${this.cache.length}`
);
// Mark dirty to rewrite clean data on next flush
this.dirty = true;
this.scheduleFlush();
} else {
this.logger.debug(
`DatabaseHistoryProvider: Loaded ${this.cache.length} messages for session ${this.sessionId}`
);
}
} catch (error) {
this.logger.error(
`DatabaseHistoryProvider: Error loading messages for session ${this.sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
throw SessionError.storageFailed(
this.sessionId,
'load history',
error instanceof Error ? error.message : String(error)
);
}
}
// Return a copy to prevent external mutation
return [...this.cache];
}
async saveMessage(message: InternalMessage): Promise<void> {
const key = this.getMessagesKey();
// Ensure cache is initialized
if (this.cache === null) {
await this.getHistory();
}
// Check if message already exists in cache (prevent duplicates)
if (message.id && this.cache!.some((m) => m.id === message.id)) {
this.logger.debug(
`DatabaseHistoryProvider: Message ${message.id} already exists, skipping`
);
return;
}
// Update cache
this.cache!.push(message);
// Write to DB immediately - new messages must be durable
try {
await this.database.append(key, message);
this.logger.debug(
`DatabaseHistoryProvider: Saved message ${message.id} (${message.role}) for session ${this.sessionId}`
);
} catch (error) {
// Remove from cache on failure to keep in sync
this.cache!.pop();
this.logger.error(
`DatabaseHistoryProvider: Error saving message for session ${this.sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
throw SessionError.storageFailed(
this.sessionId,
'save message',
error instanceof Error ? error.message : String(error)
);
}
}
async updateMessage(message: InternalMessage): Promise<void> {
// Guard against undefined id
if (!message.id) {
this.logger.warn(
`DatabaseHistoryProvider: Ignoring update for message without id in session ${this.sessionId}`
);
return;
}
// Ensure cache is initialized
if (this.cache === null) {
await this.getHistory();
}
// Update cache immediately (fast, in-memory)
const index = this.cache!.findIndex((m) => m.id === message.id);
if (index !== -1) {
this.cache![index] = message;
this.dirty = true;
// Schedule debounced flush
this.scheduleFlush();
this.logger.debug(
`DatabaseHistoryProvider: Updated message ${message.id} in cache for session ${this.sessionId}`
);
} else {
this.logger.warn(
`DatabaseHistoryProvider: Message ${message.id} not found for update in session ${this.sessionId}`
);
}
}
async clearHistory(): Promise<void> {
// Cancel any pending flush
this.cancelPendingFlush();
// Clear cache
this.cache = [];
this.dirty = false;
// Clear DB
const key = this.getMessagesKey();
try {
await this.database.delete(key);
this.logger.debug(
`DatabaseHistoryProvider: Cleared history for session ${this.sessionId}`
);
} catch (error) {
this.logger.error(
`DatabaseHistoryProvider: Error clearing session ${this.sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
throw SessionError.resetFailed(
this.sessionId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Flush any pending updates to the database.
* Should be called at turn boundaries to ensure durability.
*/
async flush(): Promise<void> {
// If a flush is already in progress, wait for it
if (this.flushPromise) {
await this.flushPromise;
return;
}
// Cancel any scheduled flush since we're flushing now
this.cancelPendingFlush();
// Nothing to flush
if (!this.dirty || !this.cache) {
return;
}
// Perform the flush
this.flushPromise = this.doFlush();
try {
await this.flushPromise;
} finally {
this.flushPromise = null;
}
}
/**
* Internal flush implementation.
* Writes entire cache to DB (delete + re-append all).
*/
private async doFlush(): Promise<void> {
if (!this.dirty || !this.cache) {
return;
}
const key = this.getMessagesKey();
// Take a snapshot of cache to avoid race conditions with concurrent saveMessage() calls.
// If saveMessage() is called during flush, it will append to the live cache AND write to DB.
// By iterating over a snapshot, we avoid re-appending messages that were already written.
const snapshot = [...this.cache];
const messageCount = snapshot.length;
this.logger.debug(
`DatabaseHistoryProvider: FLUSH START key=${key} snapshotSize=${messageCount} ids=[${snapshot.map((m) => m.id).join(',')}]`
);
try {
// Atomic replace: delete all + re-append from snapshot
await this.database.delete(key);
this.logger.debug(`DatabaseHistoryProvider: FLUSH DELETED key=${key}`);
for (const msg of snapshot) {
await this.database.append(key, msg);
}
this.logger.debug(
`DatabaseHistoryProvider: FLUSH REAPPENDED key=${key} count=${messageCount}`
);
// Only clear dirty if no new updates were scheduled during flush.
// If flushTimer exists, updateMessage() was called during the flush,
// so keep dirty=true to ensure the scheduled flush persists those updates.
if (!this.flushTimer) {
this.dirty = false;
}
} catch (error) {
this.logger.error(
`DatabaseHistoryProvider: Error flushing messages for session ${this.sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
throw SessionError.storageFailed(
this.sessionId,
'flush messages',
error instanceof Error ? error.message : String(error)
);
}
}
/**
* Schedule a debounced flush.
* Batches rapid updateMessage() calls into a single DB write.
*/
private scheduleFlush(): void {
// Already scheduled
if (this.flushTimer) {
return;
}
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
// Use flush() instead of doFlush() to respect flushPromise concurrency guard
this.flush().catch(() => {
// Error already logged in doFlush
});
}, DatabaseHistoryProvider.FLUSH_DELAY_MS);
}
/**
* Cancel any pending scheduled flush.
*/
private cancelPendingFlush(): void {
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
}
private getMessagesKey(): string {
return `messages:${this.sessionId}`;
}
}

View File

@@ -0,0 +1,18 @@
import type { IConversationHistoryProvider } from './types.js';
import type { Database } from '@core/storage/types.js';
import type { IDextoLogger } from '@core/logger/v2/types.js';
import { DatabaseHistoryProvider } from './database.js';
/**
* Create a history provider directly with database backend
* @param database Database instance
* @param sessionId Session ID
* @param logger Logger instance for logging
*/
export function createDatabaseHistoryProvider(
database: Database,
sessionId: string,
logger: IDextoLogger
): IConversationHistoryProvider {
return new DatabaseHistoryProvider(sessionId, database, logger);
}

View File

@@ -0,0 +1,46 @@
import type { InternalMessage } from '@core/context/types.js';
import type { IConversationHistoryProvider } from './types.js';
import type { IDextoLogger } from '../../logger/v2/types.js';
/**
* Lightweight in-memory history provider for ephemeral, isolated LLM calls.
* Used to run background tasks (e.g., title generation) without touching
* the real session history or emitting history-related side effects.
*/
export class MemoryHistoryProvider implements IConversationHistoryProvider {
private messages: InternalMessage[] = [];
constructor(private logger: IDextoLogger) {}
async getHistory(): Promise<InternalMessage[]> {
// Return a shallow copy to prevent external mutation
return [...this.messages];
}
async saveMessage(message: InternalMessage): Promise<void> {
this.messages.push(message);
}
async updateMessage(message: InternalMessage): Promise<void> {
// Guard against undefined id - could match another message with undefined id
if (!message.id) {
this.logger.warn('MemoryHistoryProvider: Ignoring update for message without id');
return;
}
const index = this.messages.findIndex((m) => m.id === message.id);
if (index !== -1) {
this.messages[index] = message;
}
}
async clearHistory(): Promise<void> {
this.messages = [];
}
/**
* No-op for in-memory provider - all operations are already "flushed".
*/
async flush(): Promise<void> {
// Nothing to flush - memory provider is always in sync
}
}

View File

@@ -0,0 +1,30 @@
import type { InternalMessage } from '@core/context/types.js';
/**
* Session-scoped conversation history provider.
* Each instance is tied to a specific session and manages only that session's messages.
*
* Implementations may use caching to optimize read performance. If caching is used,
* the flush() method should be called at turn boundaries to ensure all updates
* are persisted to durable storage.
*/
export interface IConversationHistoryProvider {
/** Load the full message history for this session */
getHistory(): Promise<InternalMessage[]>;
/** Append a message to this session's history (must be durable immediately) */
saveMessage(message: InternalMessage): Promise<void>;
/** Update an existing message in this session's history (may be cached/batched) */
updateMessage(message: InternalMessage): Promise<void>;
/** Clear all messages for this session */
clearHistory(): Promise<void>;
/**
* Flush any pending updates to durable storage.
* Called at turn boundaries to ensure data durability.
* Implementations without caching can make this a no-op.
*/
flush(): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
export { ChatSession } from './chat-session.js';
export { SessionManager } from './session-manager.js';
export type { SessionMetadata } from './session-manager.js';
export { SessionErrorCode } from './error-codes.js';
export { SessionError } from './errors.js';
export { MessageQueueService } from './message-queue.js';
export type { UserMessageInput } from './message-queue.js';
export type { QueuedMessage, CoalescedMessage } from './types.js';

View File

@@ -0,0 +1,378 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageQueueService } from './message-queue.js';
import type { SessionEventBus } from '../events/index.js';
import type { ContentPart } from '../context/types.js';
import { createMockLogger } from '../logger/v2/test-utils.js';
import type { IDextoLogger } from '../logger/v2/types.js';
// Create a mock SessionEventBus
function createMockEventBus(): SessionEventBus {
return {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
} as unknown as SessionEventBus;
}
describe('MessageQueueService', () => {
let eventBus: SessionEventBus;
let logger: IDextoLogger;
let queue: MessageQueueService;
beforeEach(() => {
eventBus = createMockEventBus();
logger = createMockLogger();
queue = new MessageQueueService(eventBus, logger);
});
describe('enqueue()', () => {
it('should add a message to the queue and return position and id', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
const result = queue.enqueue({ content });
expect(result.queued).toBe(true);
expect(result.position).toBe(1);
expect(result.id).toMatch(/^msg_\d+_[a-z0-9]+$/);
});
it('should increment position for multiple enqueued messages', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
const result1 = queue.enqueue({ content });
const result2 = queue.enqueue({ content });
const result3 = queue.enqueue({ content });
expect(result1.position).toBe(1);
expect(result2.position).toBe(2);
expect(result3.position).toBe(3);
});
it('should emit message:queued event with correct data', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
const result = queue.enqueue({ content });
expect(eventBus.emit).toHaveBeenCalledWith('message:queued', {
position: 1,
id: result.id,
});
});
it('should include metadata when provided', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
const metadata = { source: 'api', priority: 'high' };
queue.enqueue({ content, metadata });
const coalesced = queue.dequeueAll();
expect(coalesced).not.toBeNull();
const firstMessage = coalesced?.messages[0];
expect(firstMessage?.metadata).toEqual(metadata);
});
it('should not include metadata field when not provided', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
queue.enqueue({ content });
const coalesced = queue.dequeueAll();
expect(coalesced).not.toBeNull();
const firstMessage = coalesced?.messages[0];
expect(firstMessage?.metadata).toBeUndefined();
});
});
describe('dequeueAll()', () => {
it('should return null when queue is empty', () => {
const result = queue.dequeueAll();
expect(result).toBeNull();
});
it('should return CoalescedMessage with single message', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
queue.enqueue({ content });
const result = queue.dequeueAll();
expect(result).not.toBeNull();
expect(result?.messages).toHaveLength(1);
expect(result?.combinedContent).toEqual(content);
});
it('should clear the queue after dequeue', () => {
queue.enqueue({ content: [{ type: 'text', text: 'hello' }] });
queue.dequeueAll();
expect(queue.hasPending()).toBe(false);
expect(queue.pendingCount()).toBe(0);
});
it('should emit message:dequeued event with correct data', () => {
queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
queue.enqueue({ content: [{ type: 'text', text: 'msg2' }] });
queue.dequeueAll();
expect(eventBus.emit).toHaveBeenCalledWith('message:dequeued', {
count: 2,
ids: expect.arrayContaining([expect.stringMatching(/^msg_/)]),
coalesced: true,
content: expect.any(Array),
});
});
it('should set coalesced to false for single message', () => {
queue.enqueue({ content: [{ type: 'text', text: 'solo' }] });
queue.dequeueAll();
expect(eventBus.emit).toHaveBeenCalledWith('message:dequeued', {
count: 1,
ids: expect.any(Array),
coalesced: false,
content: [{ type: 'text', text: 'solo' }],
});
});
});
describe('coalescing', () => {
it('should return single message content as-is', () => {
const content: ContentPart[] = [
{ type: 'text', text: 'hello world' },
{ type: 'image', image: 'base64data', mimeType: 'image/png' },
];
queue.enqueue({ content });
const result = queue.dequeueAll();
expect(result?.combinedContent).toEqual(content);
});
it('should prefix two messages with First and Also', () => {
queue.enqueue({ content: [{ type: 'text', text: 'stop' }] });
queue.enqueue({ content: [{ type: 'text', text: 'try another way' }] });
const result = queue.dequeueAll();
expect(result?.combinedContent).toHaveLength(3); // First + separator + Also
expect(result?.combinedContent[0]).toEqual({ type: 'text', text: 'First: stop' });
expect(result?.combinedContent[1]).toEqual({ type: 'text', text: '\n\n' });
expect(result?.combinedContent[2]).toEqual({
type: 'text',
text: 'Also: try another way',
});
});
it('should number three or more messages', () => {
queue.enqueue({ content: [{ type: 'text', text: 'one' }] });
queue.enqueue({ content: [{ type: 'text', text: 'two' }] });
queue.enqueue({ content: [{ type: 'text', text: 'three' }] });
const result = queue.dequeueAll();
expect(result?.combinedContent).toHaveLength(5); // 3 messages + 2 separators
expect(result?.combinedContent[0]).toEqual({ type: 'text', text: '[1]: one' });
expect(result?.combinedContent[2]).toEqual({ type: 'text', text: '[2]: two' });
expect(result?.combinedContent[4]).toEqual({ type: 'text', text: '[3]: three' });
});
it('should preserve multimodal content (text + images)', () => {
queue.enqueue({
content: [
{ type: 'text', text: 'look at this' },
{ type: 'image', image: 'base64img1', mimeType: 'image/png' },
],
});
queue.enqueue({
content: [{ type: 'image', image: 'base64img2', mimeType: 'image/jpeg' }],
});
const result = queue.dequeueAll();
// Should have: "First: look at this", image1, separator, "Also: ", image2
expect(result?.combinedContent).toHaveLength(5);
expect(result?.combinedContent[0]).toEqual({
type: 'text',
text: 'First: look at this',
});
expect(result?.combinedContent[1]).toEqual({
type: 'image',
image: 'base64img1',
mimeType: 'image/png',
});
expect(result?.combinedContent[3]).toEqual({ type: 'text', text: 'Also: ' });
expect(result?.combinedContent[4]).toEqual({
type: 'image',
image: 'base64img2',
mimeType: 'image/jpeg',
});
});
it('should handle empty message content with placeholder', () => {
queue.enqueue({ content: [{ type: 'text', text: 'first' }] });
queue.enqueue({ content: [] });
const result = queue.dequeueAll();
expect(result?.combinedContent).toContainEqual({
type: 'text',
text: 'Also: [empty message]',
});
});
it('should set correct firstQueuedAt and lastQueuedAt timestamps', async () => {
queue.enqueue({ content: [{ type: 'text', text: 'first' }] });
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
queue.enqueue({ content: [{ type: 'text', text: 'second' }] });
const result = queue.dequeueAll();
expect(result?.firstQueuedAt).toBeLessThan(result?.lastQueuedAt ?? 0);
});
});
describe('hasPending() and pendingCount()', () => {
it('should return false and 0 for empty queue', () => {
expect(queue.hasPending()).toBe(false);
expect(queue.pendingCount()).toBe(0);
});
it('should return true and correct count for non-empty queue', () => {
queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
queue.enqueue({ content: [{ type: 'text', text: 'msg2' }] });
expect(queue.hasPending()).toBe(true);
expect(queue.pendingCount()).toBe(2);
});
it('should update after dequeue', () => {
queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
expect(queue.pendingCount()).toBe(1);
queue.dequeueAll();
expect(queue.hasPending()).toBe(false);
expect(queue.pendingCount()).toBe(0);
});
});
describe('clear()', () => {
it('should empty the queue', () => {
queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
queue.enqueue({ content: [{ type: 'text', text: 'msg2' }] });
queue.clear();
expect(queue.hasPending()).toBe(false);
expect(queue.pendingCount()).toBe(0);
expect(queue.dequeueAll()).toBeNull();
});
});
describe('getAll()', () => {
it('should return empty array when queue is empty', () => {
expect(queue.getAll()).toEqual([]);
});
it('should return shallow copy of queued messages', () => {
const result1 = queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
const result2 = queue.enqueue({ content: [{ type: 'text', text: 'msg2' }] });
const all = queue.getAll();
expect(all).toHaveLength(2);
expect(all[0]?.id).toBe(result1.id);
expect(all[1]?.id).toBe(result2.id);
});
it('should not allow external mutation of queue', () => {
queue.enqueue({ content: [{ type: 'text', text: 'msg1' }] });
const all = queue.getAll();
all.push({
id: 'fake',
content: [{ type: 'text', text: 'fake' }],
queuedAt: Date.now(),
});
expect(queue.getAll()).toHaveLength(1);
});
});
describe('get()', () => {
it('should return undefined for non-existent id', () => {
expect(queue.get('non-existent')).toBeUndefined();
});
it('should return message by id', () => {
const content: ContentPart[] = [{ type: 'text', text: 'hello' }];
const result = queue.enqueue({ content });
const msg = queue.get(result.id);
expect(msg).toBeDefined();
expect(msg?.id).toBe(result.id);
expect(msg?.content).toEqual(content);
});
});
describe('remove()', () => {
it('should return false for non-existent id', () => {
const result = queue.remove('non-existent');
expect(result).toBe(false);
expect(logger.debug).toHaveBeenCalledWith(
'Remove failed: message non-existent not found in queue'
);
});
it('should remove message and return true', () => {
const result = queue.enqueue({ content: [{ type: 'text', text: 'to remove' }] });
const removed = queue.remove(result.id);
expect(removed).toBe(true);
expect(queue.get(result.id)).toBeUndefined();
expect(queue.pendingCount()).toBe(0);
});
it('should emit message:removed event', () => {
const result = queue.enqueue({ content: [{ type: 'text', text: 'to remove' }] });
queue.remove(result.id);
expect(eventBus.emit).toHaveBeenCalledWith('message:removed', {
id: result.id,
});
});
it('should log debug message on successful removal', () => {
const result = queue.enqueue({ content: [{ type: 'text', text: 'to remove' }] });
queue.remove(result.id);
expect(logger.debug).toHaveBeenCalledWith(
`Message removed: ${result.id}, remaining: 0`
);
});
it('should maintain order of remaining messages', () => {
const r1 = queue.enqueue({ content: [{ type: 'text', text: 'first' }] });
const r2 = queue.enqueue({ content: [{ type: 'text', text: 'second' }] });
const r3 = queue.enqueue({ content: [{ type: 'text', text: 'third' }] });
queue.remove(r2.id);
const all = queue.getAll();
expect(all).toHaveLength(2);
expect(all[0]?.id).toBe(r1.id);
expect(all[1]?.id).toBe(r3.id);
});
});
});

View File

@@ -0,0 +1,264 @@
import type { SessionEventBus } from '../events/index.js';
import type { QueuedMessage, CoalescedMessage } from './types.js';
import type { ContentPart } from '../context/types.js';
import type { IDextoLogger } from '../logger/v2/types.js';
/**
* Generates a unique ID for queued messages.
*/
function generateId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
/**
* Input for enqueuing a user message to the queue.
* (Not to be confused with UserMessage from context/types.ts which represents
* a message in conversation history)
*/
export interface UserMessageInput {
/** Multimodal content array (text, images, files, etc.) */
content: ContentPart[];
/** Optional metadata to attach to the message */
metadata?: Record<string, unknown>;
}
/**
* MessageQueueService handles queuing of user messages during agent execution.
*
* Key features:
* - Accepts messages when the agent is busy executing tools
* - Coalesces multiple queued messages into a single injection
* - Supports multimodal content (text, images, files)
*
* This enables user guidance where users can send
* mid-task instructions like "stop" or "try a different approach".
*
* @example
* ```typescript
* // In API handler - queue message if agent is busy
* if (agent.isBusy()) {
* return messageQueue.enqueue({ content: [{ type: 'text', text: 'stop' }] });
* }
*
* // In TurnExecutor - check for queued messages
* const coalesced = messageQueue.dequeueAll();
* if (coalesced) {
* await contextManager.addMessage({
* role: 'user',
* content: coalesced.combinedContent,
* });
* }
* ```
*/
export class MessageQueueService {
private queue: QueuedMessage[] = [];
constructor(
private eventBus: SessionEventBus,
private logger: IDextoLogger
) {}
/**
* Add a message to the queue.
* Called by API endpoint - returns immediately with queue position.
*
* @param message The user message to queue
* @returns Queue position and message ID
*/
enqueue(message: UserMessageInput): { queued: true; position: number; id: string } {
const queuedMsg: QueuedMessage = {
id: generateId(),
content: message.content,
queuedAt: Date.now(),
...(message.metadata !== undefined && { metadata: message.metadata }),
};
this.queue.push(queuedMsg);
this.logger.debug(`Message queued: ${queuedMsg.id}, position: ${this.queue.length}`);
this.eventBus.emit('message:queued', {
position: this.queue.length,
id: queuedMsg.id,
});
return {
queued: true,
position: this.queue.length,
id: queuedMsg.id,
};
}
/**
* Dequeue ALL pending messages and coalesce into single injection.
* Called by executor between steps.
*
* Multiple queued messages become ONE combined message to the LLM.
*
* @example
* If 3 messages are queued: "stop", "try X instead", "also check Y"
* They become:
* ```
* [1]: stop
*
* [2]: try X instead
*
* [3]: also check Y
* ```
*
* @returns Coalesced message or null if queue is empty
*/
dequeueAll(): CoalescedMessage | null {
if (this.queue.length === 0) return null;
const messages = [...this.queue];
this.queue = [];
const combined = this.coalesce(messages);
this.logger.debug(
`Dequeued ${messages.length} message(s): ${messages.map((m) => m.id).join(', ')}`
);
this.eventBus.emit('message:dequeued', {
count: messages.length,
ids: messages.map((m) => m.id),
coalesced: messages.length > 1,
content: combined.combinedContent,
});
return combined;
}
/**
* Coalesce multiple messages into one (multimodal-aware).
* Strategy: Combine with numbered separators, preserve all media.
*/
private coalesce(messages: QueuedMessage[]): CoalescedMessage {
// Single message - return as-is
if (messages.length === 1) {
const firstMsg = messages[0];
if (!firstMsg) {
// This should never happen since we check length === 1, but satisfies TypeScript
throw new Error('Unexpected empty messages array');
}
return {
messages,
combinedContent: firstMsg.content,
firstQueuedAt: firstMsg.queuedAt,
lastQueuedAt: firstMsg.queuedAt,
};
}
// Multiple messages - combine with numbered prefixes
const combinedContent: ContentPart[] = [];
for (const [i, msg] of messages.entries()) {
// Add prefix based on message count
const prefix = messages.length === 2 ? (i === 0 ? 'First' : 'Also') : `[${i + 1}]`;
// Start with prefix text
let prefixText = `${prefix}: `;
// Process content parts
for (const part of msg.content) {
if (part.type === 'text') {
// Combine prefix with first text part for cleaner output
if (prefixText) {
combinedContent.push({ type: 'text', text: prefixText + part.text });
prefixText = '';
} else {
combinedContent.push(part);
}
} else {
// If we haven't added prefix yet (message started with media), add it first
if (prefixText) {
combinedContent.push({ type: 'text', text: prefixText });
prefixText = '';
}
// Images, files, and other media are added as-is
combinedContent.push(part);
}
}
// If the message only had media (no text), prefix was already added
// If message was empty, add just the prefix
if (prefixText && msg.content.length === 0) {
combinedContent.push({ type: 'text', text: prefixText + '[empty message]' });
}
// Add separator between messages (not after last one)
if (i < messages.length - 1) {
combinedContent.push({ type: 'text', text: '\n\n' });
}
}
// Get first and last messages - safe because we checked length > 1 above
const firstMessage = messages[0];
const lastMessage = messages[messages.length - 1];
if (!firstMessage || !lastMessage) {
throw new Error('Unexpected undefined message in non-empty array');
}
return {
messages,
combinedContent,
firstQueuedAt: firstMessage.queuedAt,
lastQueuedAt: lastMessage.queuedAt,
};
}
/**
* Check if there are pending messages in the queue.
*/
hasPending(): boolean {
return this.queue.length > 0;
}
/**
* Get the number of pending messages.
*/
pendingCount(): number {
return this.queue.length;
}
/**
* Clear all pending messages without processing.
* Used during cleanup/abort.
*/
clear(): void {
this.queue = [];
}
/**
* Get all queued messages (for UI display).
* Returns a shallow copy to prevent external mutation.
*/
getAll(): QueuedMessage[] {
return [...this.queue];
}
/**
* Get a single queued message by ID.
*/
get(id: string): QueuedMessage | undefined {
return this.queue.find((m) => m.id === id);
}
/**
* Remove a single queued message by ID.
* @returns true if message was found and removed; false otherwise
*/
remove(id: string): boolean {
const index = this.queue.findIndex((m) => m.id === id);
if (index === -1) {
this.logger.debug(`Remove failed: message ${id} not found in queue`);
return false;
}
this.queue.splice(index, 1);
this.logger.debug(`Message removed: ${id}, remaining: ${this.queue.length}`);
this.eventBus.emit('message:removed', { id });
return true;
}
}

View File

@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { SessionConfigSchema, type SessionConfig, type ValidatedSessionConfig } from './schemas.js';
describe('SessionConfigSchema', () => {
describe('Field Validation', () => {
it('should validate maxSessions as positive integer', () => {
// Negative should fail
let result = SessionConfigSchema.safeParse({ maxSessions: -1 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.too_small);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
// Zero should fail
result = SessionConfigSchema.safeParse({ maxSessions: 0 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.too_small);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
// Float should fail
result = SessionConfigSchema.safeParse({ maxSessions: 1.5 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
// Valid values should pass
const valid1 = SessionConfigSchema.parse({ maxSessions: 1 });
expect(valid1.maxSessions).toBe(1);
const valid2 = SessionConfigSchema.parse({ maxSessions: 100 });
expect(valid2.maxSessions).toBe(100);
});
it('should validate sessionTTL as positive integer', () => {
// Negative should fail
let result = SessionConfigSchema.safeParse({ sessionTTL: -1 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.too_small);
expect(result.error?.issues[0]?.path).toEqual(['sessionTTL']);
// Zero should fail
result = SessionConfigSchema.safeParse({ sessionTTL: 0 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.too_small);
expect(result.error?.issues[0]?.path).toEqual(['sessionTTL']);
// Float should fail
result = SessionConfigSchema.safeParse({ sessionTTL: 1.5 });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['sessionTTL']);
// Valid values should pass
const valid1 = SessionConfigSchema.parse({ sessionTTL: 1000 });
expect(valid1.sessionTTL).toBe(1000);
const valid2 = SessionConfigSchema.parse({ sessionTTL: 3600000 });
expect(valid2.sessionTTL).toBe(3600000);
});
it('should reject string inputs without coercion', () => {
const result = SessionConfigSchema.safeParse({
maxSessions: '50',
sessionTTL: '1800000',
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
});
});
describe('Default Values', () => {
it('should apply field defaults for empty object', () => {
const result = SessionConfigSchema.parse({});
expect(result).toEqual({
maxSessions: 100,
sessionTTL: 3600000,
});
});
it('should apply field defaults for partial config', () => {
const result1 = SessionConfigSchema.parse({ maxSessions: 50 });
expect(result1).toEqual({
maxSessions: 50,
sessionTTL: 3600000,
});
const result2 = SessionConfigSchema.parse({ sessionTTL: 1800000 });
expect(result2).toEqual({
maxSessions: 100,
sessionTTL: 1800000,
});
});
it('should override defaults when values provided', () => {
const config = {
maxSessions: 200,
sessionTTL: 7200000,
};
const result = SessionConfigSchema.parse(config);
expect(result).toEqual(config);
});
});
describe('Edge Cases', () => {
it('should handle boundary values', () => {
// Very small valid values
const small = SessionConfigSchema.parse({
maxSessions: 1,
sessionTTL: 1,
});
expect(small.maxSessions).toBe(1);
expect(small.sessionTTL).toBe(1);
// Large values
const large = SessionConfigSchema.parse({
maxSessions: 10000,
sessionTTL: 86400000, // 24 hours
});
expect(large.maxSessions).toBe(10000);
expect(large.sessionTTL).toBe(86400000);
});
it('should reject non-numeric types', () => {
// String should fail
let result = SessionConfigSchema.safeParse({ maxSessions: 'abc' });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
// Null should fail
result = SessionConfigSchema.safeParse({ sessionTTL: null });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['sessionTTL']);
// Object should fail
result = SessionConfigSchema.safeParse({ maxSessions: {} });
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.invalid_type);
expect(result.error?.issues[0]?.path).toEqual(['maxSessions']);
});
it('should reject extra fields with strict validation', () => {
const configWithExtra = {
maxSessions: 100,
sessionTTL: 3600000,
unknownField: 'should fail',
};
const result = SessionConfigSchema.safeParse(configWithExtra);
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.code).toBe(z.ZodIssueCode.unrecognized_keys);
});
});
describe('Type Safety', () => {
it('should have correct input and output types', () => {
// Input type allows optional fields (due to defaults)
const input: SessionConfig = {};
const inputPartial: SessionConfig = { maxSessions: 50 };
const inputFull: SessionConfig = { maxSessions: 100, sessionTTL: 3600000 };
// All should be valid inputs
expect(() => SessionConfigSchema.parse(input)).not.toThrow();
expect(() => SessionConfigSchema.parse(inputPartial)).not.toThrow();
expect(() => SessionConfigSchema.parse(inputFull)).not.toThrow();
});
it('should produce validated output type', () => {
const result: ValidatedSessionConfig = SessionConfigSchema.parse({});
// Output type guarantees all fields are present
expect(typeof result.maxSessions).toBe('number');
expect(typeof result.sessionTTL).toBe('number');
expect(result.maxSessions).toBeGreaterThan(0);
expect(result.sessionTTL).toBeGreaterThan(0);
});
});
describe('Real-world Scenarios', () => {
it('should handle typical production config', () => {
const prodConfig = {
maxSessions: 1000,
sessionTTL: 7200000, // 2 hours
};
const result = SessionConfigSchema.parse(prodConfig);
expect(result).toEqual(prodConfig);
});
it('should handle development config with shorter TTL', () => {
const devConfig = {
maxSessions: 10,
sessionTTL: 300000, // 5 minutes
};
const result = SessionConfigSchema.parse(devConfig);
expect(result).toEqual(devConfig);
});
});
});

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const SessionConfigSchema = z
.object({
maxSessions: z
.number()
.int()
.positive()
.default(100)
.describe('Maximum number of concurrent sessions allowed, defaults to 100'),
sessionTTL: z
.number()
.int()
.positive()
.default(3600000)
.describe('Session time-to-live in milliseconds, defaults to 3600000ms (1 hour)'),
})
.strict()
.describe('Session management configuration');
export type SessionConfig = z.input<typeof SessionConfigSchema>;
export type ValidatedSessionConfig = z.output<typeof SessionConfigSchema>;

View File

@@ -0,0 +1,219 @@
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
import { DextoAgent } from '../agent/DextoAgent.js';
import type { AgentConfig } from '@core/agent/schemas.js';
import type { SessionData } from './session-manager.js';
/**
* Full end-to-end integration tests for chat history preservation.
* Tests the complete flow from DextoAgent -> SessionManager -> ChatSession -> Storage
*/
describe('Session Integration: Chat History Preservation', () => {
let agent: DextoAgent;
const testConfig: AgentConfig = {
systemPrompt: 'You are a helpful assistant.',
llm: {
provider: 'openai',
model: 'gpt-5-mini',
apiKey: 'test-key-123',
},
mcpServers: {},
sessions: {
maxSessions: 10,
sessionTTL: 100, // 100ms for fast testing
},
toolConfirmation: {
mode: 'auto-approve',
timeout: 120000,
},
elicitation: {
enabled: false,
timeout: 120000,
},
};
beforeEach(async () => {
agent = new DextoAgent(testConfig);
await agent.start();
});
afterEach(async () => {
if (agent.isStarted()) {
await agent.stop();
}
});
test('full integration: chat history survives session expiry through DextoAgent', async () => {
const sessionId = 'integration-test-session';
// Step 1: Create session through DextoAgent
const session = await agent.createSession(sessionId);
expect(session.id).toBe(sessionId);
// Step 2: Simulate adding messages to the session
// In a real scenario, this would happen through agent.run() calls
// For testing, we'll access the underlying storage directly
const storage = agent.services.storageManager;
const messagesKey = `messages:${sessionId}`;
const chatHistory = [
{ role: 'user', content: 'What is 2+2?' },
{ role: 'assistant', content: '2+2 equals 4.' },
{ role: 'user', content: 'Thank you!' },
{
role: 'assistant',
content: "You're welcome! Is there anything else I can help you with?",
},
];
await storage.getDatabase().set(messagesKey, chatHistory);
// Step 3: Verify session exists and has history
const activeSession = await agent.getSession(sessionId);
expect(activeSession).toBeDefined();
expect(activeSession!.id).toBe(sessionId);
const storedHistory = await storage.getDatabase().get(messagesKey);
expect(storedHistory).toEqual(chatHistory);
// Step 4: Force session expiry by manipulating lastActivity timestamp
await new Promise((resolve) => setTimeout(resolve, 150)); // Wait > TTL
const sessionKey = `session:${sessionId}`;
const sessionData = await storage.getDatabase().get<SessionData>(sessionKey);
if (sessionData) {
sessionData.lastActivity = Date.now() - 200; // Mark as expired
await storage.getDatabase().set(sessionKey, sessionData);
}
// Access private method to manually trigger cleanup for testing session expiry behavior
const sessionManager = agent.sessionManager;
await (sessionManager as any).cleanupExpiredSessions();
// Step 5: Verify session is removed from memory but preserved in storage
const sessionsMap = (sessionManager as any).sessions;
expect(sessionsMap.has(sessionId)).toBe(false);
// But storage should still have both session metadata and chat history
expect(await storage.getDatabase().get(sessionKey)).toBeDefined();
expect(await storage.getDatabase().get(messagesKey)).toEqual(chatHistory);
// Step 6: Access session again through DextoAgent - should restore seamlessly
const restoredSession = await agent.getSession(sessionId);
expect(restoredSession).toBeDefined();
expect(restoredSession!.id).toBe(sessionId);
// Session should be back in memory
expect(sessionsMap.has(sessionId)).toBe(true);
// Chat history should still be intact
const restoredHistory = await storage.getDatabase().get(messagesKey);
expect(restoredHistory).toEqual(chatHistory);
// Step 7: Verify we can continue the conversation
const newMessage = { role: 'user', content: 'One more question: what is 3+3?' };
await storage.getDatabase().set(messagesKey, [...chatHistory, newMessage]);
const finalHistory = await storage.getDatabase().get<any[]>(messagesKey);
expect(finalHistory).toBeDefined();
expect(finalHistory!).toHaveLength(5);
expect(finalHistory![4]).toEqual(newMessage);
});
test('full integration: explicit session deletion removes everything', async () => {
const sessionId = 'deletion-test-session';
// Create session and add history
await agent.createSession(sessionId);
const storage = agent.services.storageManager;
const messagesKey = `messages:${sessionId}`;
const sessionKey = `session:${sessionId}`;
const history = [{ role: 'user', content: 'Hello!' }];
await storage.getDatabase().set(messagesKey, history);
// Verify everything exists
expect(await agent.getSession(sessionId)).toBeDefined();
expect(await storage.getDatabase().get(sessionKey)).toBeDefined();
expect(await storage.getDatabase().get(messagesKey)).toEqual(history);
// Delete session through DextoAgent
await agent.deleteSession(sessionId);
// Everything should be gone including chat history
const deletedSession = await agent.getSession(sessionId);
expect(deletedSession).toBeUndefined();
expect(await storage.getDatabase().get(sessionKey)).toBeUndefined();
expect(await storage.getDatabase().get(messagesKey)).toBeUndefined();
});
test('full integration: multiple concurrent sessions with independent histories', async () => {
const sessionIds = ['concurrent-1', 'concurrent-2', 'concurrent-3'];
const histories = sessionIds.map((_, index) => [
{ role: 'user', content: `Message from session ${index + 1}` },
{ role: 'assistant', content: `Response to session ${index + 1}` },
]);
// Create multiple sessions with different histories
const storage = agent.services.storageManager;
for (let i = 0; i < sessionIds.length; i++) {
await agent.createSession(sessionIds[i]);
await storage.getDatabase().set(`messages:${sessionIds[i]}`, histories[i]);
}
// Verify all sessions exist and have correct histories
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]!;
const session = await agent.getSession(sessionId);
expect(session).toBeDefined();
expect(session!.id).toBe(sessionId);
const history = await storage.getDatabase().get(`messages:${sessionId}`);
expect(history).toEqual(histories[i]);
}
// Force expiry and cleanup for all sessions
await new Promise((resolve) => setTimeout(resolve, 150));
for (const sessionId of sessionIds) {
const sessionData = await storage
.getDatabase()
.get<SessionData>(`session:${sessionId}`);
if (sessionData) {
sessionData.lastActivity = Date.now() - 200;
await storage.getDatabase().set(`session:${sessionId}`, sessionData);
}
}
const sessionManager = agent.sessionManager;
await (sessionManager as any).cleanupExpiredSessions();
// All should be removed from memory
const sessionsMap = (sessionManager as any).sessions;
sessionIds.forEach((id) => {
expect(sessionsMap.has(id)).toBe(false);
});
// But histories should be preserved in storage
for (let i = 0; i < sessionIds.length; i++) {
const history = await storage.getDatabase().get(`messages:${sessionIds[i]}`);
expect(history).toEqual(histories[i]);
}
// Restore sessions one by one and verify independent operation
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]!;
const restoredSession = await agent.getSession(sessionId);
expect(restoredSession).toBeDefined();
expect(restoredSession!.id).toBe(sessionId);
// Verify the session is back in memory
expect(sessionsMap.has(sessionId)).toBe(true);
// Verify history is still intact and independent
const history = await storage.getDatabase().get(`messages:${sessionId}`);
expect(history).toEqual(histories[i]);
}
});
// Note: Activity-based expiry prevention test removed due to timing complexities
// The core functionality (chat history preservation) is thoroughly tested above
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,813 @@
import { randomUUID } from 'crypto';
import { ChatSession } from './chat-session.js';
import { SystemPromptManager } from '../systemPrompt/manager.js';
import { ToolManager } from '../tools/tool-manager.js';
import { AgentEventBus } from '../events/index.js';
import type { IDextoLogger } from '../logger/v2/types.js';
import { DextoLogComponent } from '../logger/v2/types.js';
import type { AgentStateManager } from '../agent/state-manager.js';
import type { ValidatedLLMConfig } from '@core/llm/schemas.js';
import type { StorageManager } from '../storage/index.js';
import type { PluginManager } from '../plugins/manager.js';
import { SessionError } from './errors.js';
import type { TokenUsage } from '../llm/types.js';
/**
* Session-level token usage totals (accumulated across all messages).
* All fields required since we track cumulative totals (defaulting to 0).
*/
export type SessionTokenUsage = Required<TokenUsage>;
export interface SessionMetadata {
createdAt: number;
lastActivity: number;
messageCount: number;
title?: string;
tokenUsage?: SessionTokenUsage;
estimatedCost?: number;
}
export interface SessionManagerConfig {
maxSessions?: number;
sessionTTL?: number;
}
export interface SessionData {
id: string;
userId?: string;
createdAt: number;
lastActivity: number;
messageCount: number;
metadata?: Record<string, any>;
tokenUsage?: SessionTokenUsage;
estimatedCost?: number;
}
/**
* Manages multiple chat sessions within a Dexto agent.
*
* The SessionManager is responsible for:
* - Creating and managing multiple isolated chat sessions
* - Enforcing session limits and TTL policies
* - Cleaning up expired sessions
* - Providing session lifecycle management
* - Persisting session data using the simplified storage backends
*
* TODO (Telemetry): Add OpenTelemetry metrics collection later if needed
* - Active session gauges (current count)
* - Session creation/deletion counters
* - Session duration histograms
* - Messages per session histograms
*/
export class SessionManager {
private sessions: Map<string, ChatSession> = new Map();
private readonly maxSessions: number;
private readonly sessionTTL: number;
private initialized = false;
private cleanupInterval?: NodeJS.Timeout;
private initializationPromise!: Promise<void>;
// Add a Map to track ongoing session creation operations to prevent race conditions
private readonly pendingCreations = new Map<string, Promise<ChatSession>>();
// Per-session mutex for token usage updates to prevent lost updates from concurrent calls
private readonly tokenUsageLocks = new Map<string, Promise<void>>();
private logger: IDextoLogger;
constructor(
private services: {
stateManager: AgentStateManager;
systemPromptManager: SystemPromptManager;
toolManager: ToolManager;
agentEventBus: AgentEventBus;
storageManager: StorageManager;
resourceManager: import('../resources/index.js').ResourceManager;
pluginManager: PluginManager;
mcpManager: import('../mcp/manager.js').MCPManager;
},
config: SessionManagerConfig = {},
logger: IDextoLogger
) {
this.maxSessions = config.maxSessions ?? 100;
this.sessionTTL = config.sessionTTL ?? 3600000; // 1 hour
this.logger = logger.createChild(DextoLogComponent.SESSION);
}
/**
* Initialize the SessionManager with persistent storage.
* This must be called before using any session operations.
*/
public async init(): Promise<void> {
if (this.initialized) {
return;
}
// Restore any existing sessions from storage
await this.restoreSessionsFromStorage();
// Start periodic cleanup to prevent memory leaks from long-lived sessions
// Clean up every 15 minutes or 1/4 of session TTL, whichever is smaller
const cleanupIntervalMs = Math.min(this.sessionTTL / 4, 15 * 60 * 1000);
this.cleanupInterval = setInterval(
() =>
this.cleanupExpiredSessions().catch((err) =>
this.logger.error(`Periodic session cleanup failed: ${err}`)
),
cleanupIntervalMs
);
this.initialized = true;
this.logger.debug(
`SessionManager initialized with periodic cleanup every ${Math.round(cleanupIntervalMs / 1000 / 60)} minutes`
);
}
/**
* Restore sessions from persistent storage on startup.
* This allows sessions to survive application restarts.
*/
private async restoreSessionsFromStorage(): Promise<void> {
try {
// Use the database backend to list sessions with the 'session:' prefix
const sessionKeys = await this.services.storageManager.getDatabase().list('session:');
this.logger.debug(`Found ${sessionKeys.length} persisted sessions to restore`);
for (const sessionKey of sessionKeys) {
const sessionId = sessionKey.replace('session:', '');
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData) {
// Check if session is still valid (not expired)
const now = Date.now();
const lastActivity = sessionData.lastActivity;
if (now - lastActivity <= this.sessionTTL) {
// Session is still valid, but don't create ChatSession until requested
this.logger.debug(`Session ${sessionId} restored from storage`);
} else {
// Session expired, clean it up
await this.services.storageManager.getDatabase().delete(sessionKey);
this.logger.debug(`Expired session ${sessionId} cleaned up during restore`);
}
}
}
} catch (error) {
this.logger.error(
`Failed to restore sessions from storage: ${error instanceof Error ? error.message : String(error)}`
);
// Continue without restored sessions
}
}
/**
* Ensures the SessionManager is initialized before operations.
*/
private async ensureInitialized(): Promise<void> {
if (!this.initialized) {
if (!this.initializationPromise) {
this.initializationPromise = this.init();
}
await this.initializationPromise;
}
}
/**
* Creates a new chat session or returns an existing one.
*
* @param sessionId Optional session ID. If not provided, a UUID will be generated.
* @returns The created or existing ChatSession
* @throws Error if maximum sessions limit is reached
*/
public async createSession(sessionId?: string): Promise<ChatSession> {
await this.ensureInitialized();
const id = sessionId ?? randomUUID();
// Check if there's already a pending creation for this session ID
if (this.pendingCreations.has(id)) {
return await this.pendingCreations.get(id)!;
}
// Check if session already exists in memory
if (this.sessions.has(id)) {
await this.updateSessionActivity(id);
return this.sessions.get(id)!;
}
// Create a promise for the session creation and track it to prevent concurrent operations
const creationPromise = this.createSessionInternal(id);
this.pendingCreations.set(id, creationPromise);
try {
const session = await creationPromise;
return session;
} finally {
// Always clean up the pending creation tracker
this.pendingCreations.delete(id);
}
}
/**
* Internal method that handles the actual session creation logic.
* This method implements atomic session creation to prevent race conditions.
*/
private async createSessionInternal(id: string): Promise<ChatSession> {
// Clean up expired sessions first
await this.cleanupExpiredSessions();
// Check if session exists in storage (could have been created by another process)
const sessionKey = `session:${id}`;
const existingMetadata = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (existingMetadata) {
// Session exists in storage, restore it
await this.updateSessionActivity(id);
const session = new ChatSession(
{ ...this.services, sessionManager: this },
id,
this.logger
);
await session.init();
this.sessions.set(id, session);
this.logger.info(`Restored session from storage: ${id}`);
return session;
}
// Perform atomic session limit check and creation
// This ensures the limit check and session creation happen as close to atomically as possible
const activeSessionKeys = await this.services.storageManager.getDatabase().list('session:');
if (activeSessionKeys.length >= this.maxSessions) {
throw SessionError.maxSessionsExceeded(activeSessionKeys.length, this.maxSessions);
}
// Create new session metadata first to "reserve" the session slot
const sessionData: SessionData = {
id,
createdAt: Date.now(),
lastActivity: Date.now(),
messageCount: 0,
};
// Store session metadata in persistent storage immediately to claim the session
try {
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
} catch (error) {
// If storage fails, another concurrent creation might have succeeded
this.logger.error(`Failed to store session metadata for ${id}:`, {
error: error instanceof Error ? error.message : String(error),
});
// Re-throw the original error to maintain test compatibility
throw error;
}
// Now create the actual session object
let session: ChatSession;
try {
session = new ChatSession({ ...this.services, sessionManager: this }, id, this.logger);
await session.init();
this.sessions.set(id, session);
// Also store in cache with TTL for faster access
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
this.logger.info(`Created new session: ${id}`);
return session;
} catch (error) {
// If session creation fails after we've claimed the slot, clean up the metadata
this.logger.error(
`Failed to initialize session ${id}: ${error instanceof Error ? error.message : String(error)}`
);
await this.services.storageManager.getDatabase().delete(sessionKey);
await this.services.storageManager.getCache().delete(sessionKey);
const reason = error instanceof Error ? error.message : 'unknown error';
throw SessionError.initializationFailed(id, reason);
}
}
/**
* Retrieves an existing session by ID.
*
* @param sessionId The session ID to retrieve
* @param restoreFromStorage Whether to restore from storage if not in memory (default: true)
* @returns The ChatSession if found, undefined otherwise
*/
public async getSession(
sessionId: string,
restoreFromStorage: boolean = true
): Promise<ChatSession | undefined> {
await this.ensureInitialized();
// Check if there's a pending creation for this session ID
if (this.pendingCreations.has(sessionId)) {
return await this.pendingCreations.get(sessionId)!;
}
// Check memory first
if (this.sessions.has(sessionId)) {
return this.sessions.get(sessionId)!;
}
// Conditionally check storage if restoreFromStorage is true
if (restoreFromStorage) {
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData) {
// Restore session to memory
const session = new ChatSession(
{ ...this.services, sessionManager: this },
sessionId,
this.logger
);
await session.init();
this.sessions.set(sessionId, session);
return session;
}
}
return undefined;
}
/**
* Ends a session by removing it from memory without deleting conversation history.
* Used for cleanup, agent shutdown, and session expiry.
*
* @param sessionId The session ID to end
*/
public async endSession(sessionId: string): Promise<void> {
await this.ensureInitialized();
// Remove from memory only - preserve conversation history in storage
const session = this.sessions.get(sessionId);
if (session) {
await session.cleanup(); // Clean up memory resources only
this.sessions.delete(sessionId);
}
// Remove from cache but preserve database storage
const sessionKey = `session:${sessionId}`;
await this.services.storageManager.getCache().delete(sessionKey);
this.logger.debug(
`Ended session (removed from memory, chat history preserved): ${sessionId}`
);
}
/**
* Deletes a session and its conversation history, removing everything from memory and storage.
* Used for user-initiated permanent deletion.
*
* @param sessionId The session ID to delete
*/
public async deleteSession(sessionId: string): Promise<void> {
await this.ensureInitialized();
// Get session (load from storage if not in memory) to clean up memory resources
const session = await this.getSession(sessionId);
if (session) {
await session.cleanup(); // This cleans up memory resources
this.sessions.delete(sessionId);
}
// Remove session metadata from storage
const sessionKey = `session:${sessionId}`;
await this.services.storageManager.getDatabase().delete(sessionKey);
await this.services.storageManager.getCache().delete(sessionKey);
const messagesKey = `messages:${sessionId}`;
await this.services.storageManager.getDatabase().delete(messagesKey);
this.logger.debug(`Deleted session and conversation history: ${sessionId}`);
}
/**
* Resets the conversation history for a session while keeping the session alive.
*
* @param sessionId The session ID to reset
* @throws Error if session doesn't exist
*/
public async resetSession(sessionId: string): Promise<void> {
await this.ensureInitialized();
const session = await this.getSession(sessionId);
if (!session) {
throw SessionError.notFound(sessionId);
}
await session.reset();
// Reset message count in metadata
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData) {
sessionData.messageCount = 0;
sessionData.lastActivity = Date.now();
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
// Update cache as well
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
}
this.logger.debug(`Reset session conversation: ${sessionId}`);
}
/**
* Lists all active session IDs.
*
* @returns Array of active session IDs
*/
public async listSessions(): Promise<string[]> {
await this.ensureInitialized();
const sessionKeys = await this.services.storageManager.getDatabase().list('session:');
return sessionKeys.map((key) => key.replace('session:', ''));
}
/**
* Gets metadata for a specific session.
*
* @param sessionId The session ID
* @returns Session metadata if found, undefined otherwise
*/
public async getSessionMetadata(sessionId: string): Promise<SessionMetadata | undefined> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (!sessionData) return undefined;
return {
createdAt: sessionData.createdAt,
lastActivity: sessionData.lastActivity,
messageCount: sessionData.messageCount,
title: sessionData.metadata?.title,
...(sessionData.tokenUsage && { tokenUsage: sessionData.tokenUsage }),
...(sessionData.estimatedCost !== undefined && {
estimatedCost: sessionData.estimatedCost,
}),
};
}
/**
* Get the global session manager configuration.
*/
public getConfig(): SessionManagerConfig {
return {
maxSessions: this.maxSessions,
sessionTTL: this.sessionTTL,
};
}
/**
* Updates the last activity timestamp for a session.
*/
private async updateSessionActivity(sessionId: string): Promise<void> {
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData) {
sessionData.lastActivity = Date.now();
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
// Update cache as well
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
}
}
/**
* Increments the message count for a session.
*/
public async incrementMessageCount(sessionId: string): Promise<void> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData) {
sessionData.messageCount++;
sessionData.lastActivity = Date.now();
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
// Update cache as well
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
}
}
/**
* Accumulates token usage for a session.
* Called after each LLM response to update session-level totals.
*
* Uses per-session locking to prevent lost updates from concurrent calls.
*/
public async accumulateTokenUsage(
sessionId: string,
usage: TokenUsage,
cost?: number
): Promise<void> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
// Wait for any in-flight update for this session, then chain ours
const previousLock = this.tokenUsageLocks.get(sessionKey) ?? Promise.resolve();
const currentLock = previousLock.then(async () => {
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (!sessionData) return;
// Initialize if needed
if (!sessionData.tokenUsage) {
sessionData.tokenUsage = {
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
};
}
// Accumulate
sessionData.tokenUsage.inputTokens += usage.inputTokens ?? 0;
sessionData.tokenUsage.outputTokens += usage.outputTokens ?? 0;
sessionData.tokenUsage.reasoningTokens += usage.reasoningTokens ?? 0;
sessionData.tokenUsage.cacheReadTokens += usage.cacheReadTokens ?? 0;
sessionData.tokenUsage.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
sessionData.tokenUsage.totalTokens += usage.totalTokens ?? 0;
// Add cost if provided
if (cost !== undefined) {
sessionData.estimatedCost = (sessionData.estimatedCost ?? 0) + cost;
}
sessionData.lastActivity = Date.now();
// Persist
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
});
this.tokenUsageLocks.set(sessionKey, currentLock);
// Wait for our update to complete, but don't let errors propagate to break the chain
try {
await currentLock;
} finally {
// Clean up lock if this was the last operation
if (this.tokenUsageLocks.get(sessionKey) === currentLock) {
this.tokenUsageLocks.delete(sessionKey);
}
}
}
/**
* Sets the human-friendly title for a session.
* Title is stored in session metadata and cached with TTL.
*/
public async setSessionTitle(
sessionId: string,
title: string,
opts: { ifUnsetOnly?: boolean } = {}
): Promise<void> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (!sessionData) {
throw SessionError.notFound(sessionId);
}
const normalized = title.trim().slice(0, 80);
if (opts.ifUnsetOnly && sessionData.metadata?.title) {
return;
}
sessionData.metadata = sessionData.metadata || {};
sessionData.metadata.title = normalized;
sessionData.lastActivity = Date.now();
await this.services.storageManager.getDatabase().set(sessionKey, sessionData);
await this.services.storageManager
.getCache()
.set(sessionKey, sessionData, this.sessionTTL / 1000);
}
/**
* Gets the stored title for a session, if any.
*/
public async getSessionTitle(sessionId: string): Promise<string | undefined> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
return sessionData?.metadata?.title;
}
/**
* Cleans up expired sessions from memory only, preserving chat history in storage.
* This allows inactive sessions to be garbage collected while keeping conversations restorable.
*/
private async cleanupExpiredSessions(): Promise<void> {
const now = Date.now();
const expiredSessions: string[] = [];
// Check in-memory sessions for expiry
for (const [sessionId, _session] of this.sessions.entries()) {
const sessionKey = `session:${sessionId}`;
const sessionData = await this.services.storageManager
.getDatabase()
.get<SessionData>(sessionKey);
if (sessionData && now - sessionData.lastActivity > this.sessionTTL) {
expiredSessions.push(sessionId);
}
}
// Remove expired sessions from memory only (preserve storage)
for (const sessionId of expiredSessions) {
const session = this.sessions.get(sessionId);
if (session) {
// Only dispose memory resources, don't delete chat history
session.dispose();
this.sessions.delete(sessionId);
this.logger.debug(
`Removed expired session from memory: ${sessionId} (chat history preserved)`
);
}
}
if (expiredSessions.length > 0) {
this.logger.debug(
`Memory cleanup: removed ${expiredSessions.length} inactive sessions, chat history preserved`
);
}
}
/**
* Switch LLM for all sessions.
* @param newLLMConfig The new LLM configuration to apply
* @returns Result object with success message and any warnings
*/
public async switchLLMForAllSessions(
newLLMConfig: ValidatedLLMConfig
): Promise<{ message: string; warnings: string[] }> {
await this.ensureInitialized();
const sessionIds = await this.listSessions();
const failedSessions: string[] = [];
for (const sId of sessionIds) {
const session = await this.getSession(sId);
if (session) {
try {
// Update state with validated config (validation already done by DextoAgent)
// Using exceptions here for session-specific runtime failures (corruption, disposal, etc.)
// This is different from input validation which uses Result<T,C> pattern
this.services.stateManager.updateLLM(newLLMConfig, sId);
await session.switchLLM(newLLMConfig);
} catch (error) {
// Session-level failure - continue processing other sessions (isolation)
failedSessions.push(sId);
this.logger.warn(
`Error switching LLM for session ${sId}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
this.services.agentEventBus.emit('llm:switched', {
newConfig: newLLMConfig,
historyRetained: true,
sessionIds: sessionIds.filter((id) => !failedSessions.includes(id)),
});
const message =
failedSessions.length > 0
? `Successfully switched to ${newLLMConfig.provider}/${newLLMConfig.model} (${failedSessions.length} sessions failed)`
: `Successfully switched to ${newLLMConfig.provider}/${newLLMConfig.model} for all sessions`;
const warnings =
failedSessions.length > 0
? [`Failed to switch LLM for sessions: ${failedSessions.join(', ')}`]
: [];
return { message, warnings };
}
/**
* Switch LLM for a specific session.
* @param newLLMConfig The new LLM configuration to apply
* @param sessionId The session ID to switch LLM for
* @returns Result object with success message and any warnings
*/
public async switchLLMForSpecificSession(
newLLMConfig: ValidatedLLMConfig,
sessionId: string
): Promise<{ message: string; warnings: string[] }> {
const session = await this.getSession(sessionId);
if (!session) {
throw SessionError.notFound(sessionId);
}
await session.switchLLM(newLLMConfig);
this.services.agentEventBus.emit('llm:switched', {
newConfig: newLLMConfig,
historyRetained: true,
sessionIds: [sessionId],
});
const message = `Successfully switched to ${newLLMConfig.provider}/${newLLMConfig.model} for session ${sessionId}`;
return { message, warnings: [] };
}
/**
* Get session statistics for monitoring and debugging.
*/
public async getSessionStats(): Promise<{
totalSessions: number;
inMemorySessions: number;
maxSessions: number;
sessionTTL: number;
}> {
await this.ensureInitialized();
const totalSessions = (await this.listSessions()).length;
const inMemorySessions = this.sessions.size;
return {
totalSessions,
inMemorySessions,
maxSessions: this.maxSessions,
sessionTTL: this.sessionTTL,
};
}
/**
* Get the raw session data for a session ID.
*
* @param sessionId The session ID
* @returns Session data if found, undefined otherwise
*/
public async getSessionData(sessionId: string): Promise<SessionData | undefined> {
await this.ensureInitialized();
const sessionKey = `session:${sessionId}`;
return await this.services.storageManager.getDatabase().get<SessionData>(sessionKey);
}
/**
* Cleanup all sessions and resources.
* This should be called when shutting down the application.
*/
public async cleanup(): Promise<void> {
if (!this.initialized) {
return;
}
// Stop periodic cleanup
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
delete this.cleanupInterval;
this.logger.debug('Periodic session cleanup stopped');
}
// End all in-memory sessions (preserve conversation history)
const sessionIds = Array.from(this.sessions.keys());
for (const sessionId of sessionIds) {
try {
await this.endSession(sessionId);
} catch (error) {
this.logger.error(
`Failed to cleanup session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
this.sessions.clear();
this.initialized = false;
this.logger.debug('SessionManager cleanup completed');
}
}

View File

@@ -0,0 +1,151 @@
import type { ValidatedLLMConfig } from '@core/llm/schemas.js';
import type { ToolManager } from '@core/tools/tool-manager.js';
import type { SystemPromptManager } from '@core/systemPrompt/manager.js';
import type { ResourceManager } from '@core/resources/index.js';
import type { IDextoLogger } from '@core/logger/v2/types.js';
import { createLLMService } from '@core/llm/services/factory.js';
import { SessionEventBus } from '@core/events/index.js';
import { MemoryHistoryProvider } from './history/memory.js';
export interface GenerateSessionTitleResult {
title?: string;
error?: string;
timedOut?: boolean;
}
/**
* Generate a concise title for a chat based on the first user message.
* Runs a lightweight, isolated LLM completion that does not touch real history.
*/
export async function generateSessionTitle(
config: ValidatedLLMConfig,
toolManager: ToolManager,
systemPromptManager: SystemPromptManager,
resourceManager: ResourceManager,
userText: string,
logger: IDextoLogger,
opts: { timeoutMs?: number } = {}
): Promise<GenerateSessionTitleResult> {
const timeoutMs = opts.timeoutMs;
const controller = timeoutMs !== undefined ? new AbortController() : undefined;
let timer: NodeJS.Timeout | undefined;
if (controller && timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
timer = setTimeout(() => controller.abort(), timeoutMs);
}
try {
const history = new MemoryHistoryProvider(logger);
const bus = new SessionEventBus();
const tempService = createLLMService(
config,
toolManager,
systemPromptManager,
history,
bus,
`titlegen-${Math.random().toString(36).slice(2)}`,
resourceManager,
logger
);
const instruction = [
'Generate a short conversation title from the following user message.',
'Rules: 38 words; no surrounding punctuation, emojis, or PII; return only the title.',
'',
'Message:',
sanitizeUserText(userText, 512),
].join('\n');
const streamResult = await tempService.stream(
instruction,
controller ? { signal: controller.signal } : undefined
);
const processed = postProcessTitle(streamResult.text);
if (!processed) {
return { error: 'LLM returned empty title' };
}
return { title: processed };
} catch (error) {
if (controller?.signal.aborted) {
return { timedOut: true, error: 'Timed out while waiting for LLM response' };
}
const message = error instanceof Error ? error.message : String(error);
return { error: message };
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
/**
* Heuristic fallback when the LLM-based title fails.
*/
export function deriveHeuristicTitle(userText: string): string | undefined {
const sanitized = sanitizeUserText(userText, 120);
if (!sanitized) return undefined;
const isSlashCommand = sanitized.startsWith('/');
if (isSlashCommand) {
const [commandTokenRaw, ...rest] = sanitized.split(/\s+/);
if (!commandTokenRaw) {
return undefined;
}
const commandToken = commandTokenRaw.trim();
const commandName = commandToken.startsWith('/') ? commandToken.slice(1) : commandToken;
if (!commandName) {
return undefined;
}
const command = commandName.replace(/[-_]+/g, ' ');
const commandTitle = toTitleCase(command);
const remainder = rest.join(' ').trim();
if (remainder) {
return truncateWords(`${commandTitle}${remainder}`, 10, 70);
}
return commandTitle || undefined;
}
const firstLine = sanitized.split(/\r?\n/)[0] ?? sanitized;
const withoutMarkdown = firstLine.replace(/[`*_~>#-]/g, '').trim();
if (!withoutMarkdown) {
return undefined;
}
return truncateWords(toSentenceCase(withoutMarkdown), 10, 70);
}
function sanitizeUserText(text: string, maxLen: number): string {
const cleaned = text.replace(/\p{Cc}+/gu, ' ').trim();
return cleaned.length > maxLen ? cleaned.slice(0, maxLen) : cleaned;
}
function postProcessTitle(raw: string): string | undefined {
if (!raw) return undefined;
let t = raw.trim();
t = t.replace(/^["'`\s]+|["'`\s]+$/g, '');
t = t.replace(/\s+/g, ' ').trim();
t = t.replace(/[\s\-–—,:;.!?]+$/g, '');
if (!t) return undefined;
return truncateWords(toSentenceCase(t), 8, 80);
}
function truncateWords(text: string, maxWords: number, maxChars: number): string {
const words = text.split(' ').filter(Boolean);
let truncated = words.slice(0, maxWords).join(' ');
if (truncated.length > maxChars) {
truncated = truncated.slice(0, maxChars).trimEnd();
}
return truncated;
}
function toSentenceCase(text: string): string {
if (!text) return text;
return text.charAt(0).toUpperCase() + text.slice(1);
}
function toTitleCase(text: string): string {
return text
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}

View File

@@ -0,0 +1,15 @@
import { ContentPart } from '../context/types.js';
export interface QueuedMessage {
id: string;
content: ContentPart[];
queuedAt: number;
metadata?: Record<string, unknown>;
}
export interface CoalescedMessage {
messages: QueuedMessage[];
combinedContent: ContentPart[];
firstQueuedAt: number;
lastQueuedAt: number;
}