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:
458
dexto/packages/core/src/session/chat-session.test.ts
Normal file
458
dexto/packages/core/src/session/chat-session.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
793
dexto/packages/core/src/session/chat-session.ts
Normal file
793
dexto/packages/core/src/session/chat-session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
dexto/packages/core/src/session/error-codes.ts
Normal file
16
dexto/packages/core/src/session/error-codes.ts
Normal 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',
|
||||
}
|
||||
75
dexto/packages/core/src/session/errors.ts
Normal file
75
dexto/packages/core/src/session/errors.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
dexto/packages/core/src/session/history/database.test.ts
Normal file
64
dexto/packages/core/src/session/history/database.test.ts
Normal 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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
313
dexto/packages/core/src/session/history/database.ts
Normal file
313
dexto/packages/core/src/session/history/database.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
18
dexto/packages/core/src/session/history/factory.ts
Normal file
18
dexto/packages/core/src/session/history/factory.ts
Normal 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);
|
||||
}
|
||||
46
dexto/packages/core/src/session/history/memory.ts
Normal file
46
dexto/packages/core/src/session/history/memory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
30
dexto/packages/core/src/session/history/types.ts
Normal file
30
dexto/packages/core/src/session/history/types.ts
Normal 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>;
|
||||
}
|
||||
8
dexto/packages/core/src/session/index.ts
Normal file
8
dexto/packages/core/src/session/index.ts
Normal 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';
|
||||
378
dexto/packages/core/src/session/message-queue.test.ts
Normal file
378
dexto/packages/core/src/session/message-queue.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
264
dexto/packages/core/src/session/message-queue.ts
Normal file
264
dexto/packages/core/src/session/message-queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
204
dexto/packages/core/src/session/schemas.test.ts
Normal file
204
dexto/packages/core/src/session/schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
dexto/packages/core/src/session/schemas.ts
Normal file
22
dexto/packages/core/src/session/schemas.ts
Normal 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>;
|
||||
@@ -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
|
||||
});
|
||||
1201
dexto/packages/core/src/session/session-manager.test.ts
Normal file
1201
dexto/packages/core/src/session/session-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
813
dexto/packages/core/src/session/session-manager.ts
Normal file
813
dexto/packages/core/src/session/session-manager.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
151
dexto/packages/core/src/session/title-generator.ts
Normal file
151
dexto/packages/core/src/session/title-generator.ts
Normal 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: 3–8 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(' ');
|
||||
}
|
||||
15
dexto/packages/core/src/session/types.ts
Normal file
15
dexto/packages/core/src/session/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user