mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-14 05:30:11 +00:00
mnemonic ralphised (#24)
* Make things 'betterer' across the board * fix: reorganize struct fields and config parameters for consistency - [x] Reorder Config struct fields alphabetically and by related functionality - [x] Reorganize Observation model fields with archival fields grouped together - [x] Reorder ObservationStore fields to group related members - [x] Reorder Store struct fields with health check caching grouped - [x] Reorganize HealthInfo and PoolMetrics struct field order - [x] Reorder maintenance Service struct fields logically - [x] Reorganize MCP server handler parameter structs alphabetically - [x] Reorder pattern detector candidate tracking fields - [x] Reorganize search Manager struct fields by functionality - [x] Reorder vector Client struct fields with mutex protections grouped - [x] Reorganize handler request/response struct fields - [x] Update handlers_test.go to expect wrapped response format - [x] Reorder middleware TokenAuth and rate limiter fields - [x] Reorganize Service struct fields with grouped functionality - [x] Fix RateLimiter field ordering for clarity - [x] Reorder CircuitBreaker metrics fields * fix(security): improve JSON output safety and path traversal protection - [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler - [x] Remove escapeJSONString helper function in favor of standard JSON marshaling - [x] Add safeResolvePath function to validate paths and prevent directory traversal - [x] Apply path traversal validation in captureFileMtimes operations - [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation * fix(sdk): improve path traversal protection and allocation safety - [x] Enhance safeResolvePath with stricter validation using filepath.Rel - [x] Reject paths containing ".." after cleaning to prevent traversal - [x] Validate absolute paths are within cwd when cwd is specified - [x] Apply safeResolvePath validation to GetFileContent for consistency - [x] Add comprehensive test coverage for path traversal protection - [x] Fix allocation safety in getRecentSearchQueries by using constant capacity * feat(dashboard): add graph stats and vector metrics endpoints - [x] Add handleGraphStats endpoint for knowledge graph visualization - [x] Add handleVectorMetrics endpoint for vector database dashboard - [x] Improve update check error handling with JSON response - [x] Register new API routes for graph and vector metrics - [x] Migrate Font Awesome to npm package from CDN - [x] Fix observations API response type handling - [x] Update package version to v0.10.5-15-g385d05a * fixup! feat(dashboard): add graph stats and vector metrics endpoints * test: add comprehensive test coverage across multiple packages - [x] Add 298 tests for Python chunker functionality - [x] Add 213 tests for chunking types and constants - [x] Add 398 tests for TypeScript/JavaScript chunker - [x] Add 954 tests for MCP server handlers and validation - [x] Add 563 tests for pattern detector and analysis - [x] Add 1149 tests for vector client cache and operations - [x] Add 663 tests for SDK processor, circuit breaker, and deduplication - [x] Add 731 tests for session manager lifecycle and concurrency - [x] Add 331 tests for similarity clustering and term extraction * fix(pattern): add nil check and fmt import for GetPatternInsight - [x] Add `fmt` import for error formatting - [x] Add nil check for pattern before using it - [x] Remove duplicate comment line
This commit is contained in:
@@ -693,3 +693,734 @@ func TestToolInputResponse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR NewManager AND CLEANUP
|
||||
// =============================================================================
|
||||
|
||||
// TestNewManager tests the NewManager function.
|
||||
func TestNewManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test with nil session store (valid for testing)
|
||||
manager := NewManager(nil)
|
||||
|
||||
assert.NotNil(t, manager)
|
||||
assert.NotNil(t, manager.sessions)
|
||||
assert.NotNil(t, manager.ProcessNotify)
|
||||
assert.NotNil(t, manager.ctx)
|
||||
assert.NotNil(t, manager.cancel)
|
||||
assert.Equal(t, 0, manager.GetActiveSessionCount())
|
||||
|
||||
// Clean up - cancel context to stop cleanup goroutine
|
||||
manager.cancel()
|
||||
}
|
||||
|
||||
// TestNewManager_CleanupGoroutineStops tests that cleanup goroutine stops on cancel.
|
||||
func TestNewManager_CleanupGoroutineStops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := NewManager(nil)
|
||||
|
||||
// Give goroutine time to start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Cancel should stop the cleanup goroutine
|
||||
manager.cancel()
|
||||
|
||||
// Context should be done
|
||||
select {
|
||||
case <-manager.ctx.Done():
|
||||
// Expected
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Context should be done after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_NoSessions tests cleanup with no sessions.
|
||||
func TestCleanupStaleSessions_NoSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Should not panic with empty sessions
|
||||
manager.cleanupStaleSessions()
|
||||
assert.Equal(t, 0, manager.GetActiveSessionCount())
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_FreshSession tests that fresh sessions are not cleaned.
|
||||
func TestCleanupStaleSessions_FreshSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Add a fresh session
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
manager.sessions[1] = &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
StartTime: time.Now(), // Fresh
|
||||
pendingMessages: []PendingMessage{},
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
manager.cleanupStaleSessions()
|
||||
|
||||
// Session should still exist (not stale)
|
||||
assert.Equal(t, 1, manager.GetActiveSessionCount())
|
||||
sessionCancel()
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_StaleSession tests that stale sessions are cleaned.
|
||||
func TestCleanupStaleSessions_StaleSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Add a stale session
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
manager.sessions[1] = &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
|
||||
pendingMessages: []PendingMessage{},
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
manager.cleanupStaleSessions()
|
||||
|
||||
// Session should be deleted
|
||||
assert.Equal(t, 0, manager.GetActiveSessionCount())
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_StaleWithPending tests stale sessions with pending messages are not cleaned.
|
||||
func TestCleanupStaleSessions_StaleWithPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Add a stale session with pending messages
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
defer sessionCancel()
|
||||
manager.sessions[1] = &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
|
||||
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
manager.cleanupStaleSessions()
|
||||
|
||||
// Session should NOT be deleted (has pending messages)
|
||||
assert.Equal(t, 1, manager.GetActiveSessionCount())
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_StaleWithActiveGenerator tests stale sessions with active generator are not cleaned.
|
||||
func TestCleanupStaleSessions_StaleWithActiveGenerator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Add a stale session with active generator
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
defer sessionCancel()
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
|
||||
pendingMessages: []PendingMessage{},
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
session.generatorActive.Store(true)
|
||||
manager.sessions[1] = session
|
||||
|
||||
manager.cleanupStaleSessions()
|
||||
|
||||
// Session should NOT be deleted (generator is active)
|
||||
assert.Equal(t, 1, manager.GetActiveSessionCount())
|
||||
}
|
||||
|
||||
// TestCleanupStaleSessions_MixedSessions tests cleanup with mixed fresh and stale sessions.
|
||||
func TestCleanupStaleSessions_MixedSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Fresh session
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
defer cancel1()
|
||||
manager.sessions[1] = &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
StartTime: time.Now(),
|
||||
pendingMessages: []PendingMessage{},
|
||||
ctx: ctx1,
|
||||
cancel: cancel1,
|
||||
}
|
||||
|
||||
// Stale session (should be deleted)
|
||||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||||
manager.sessions[2] = &ActiveSession{
|
||||
SessionDBID: 2,
|
||||
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
|
||||
pendingMessages: []PendingMessage{},
|
||||
ctx: ctx2,
|
||||
cancel: cancel2,
|
||||
}
|
||||
|
||||
// Stale session with pending (should NOT be deleted)
|
||||
ctx3, cancel3 := context.WithCancel(context.Background())
|
||||
defer cancel3()
|
||||
manager.sessions[3] = &ActiveSession{
|
||||
SessionDBID: 3,
|
||||
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
|
||||
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
|
||||
ctx: ctx3,
|
||||
cancel: cancel3,
|
||||
}
|
||||
|
||||
manager.cleanupStaleSessions()
|
||||
|
||||
// Should have 2 sessions left (1 fresh, 1 stale with pending)
|
||||
assert.Equal(t, 2, manager.GetActiveSessionCount())
|
||||
|
||||
// Verify which sessions remain
|
||||
manager.mu.RLock()
|
||||
_, has1 := manager.sessions[1]
|
||||
_, has2 := manager.sessions[2]
|
||||
_, has3 := manager.sessions[3]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
assert.True(t, has1, "Fresh session should remain")
|
||||
assert.False(t, has2, "Stale session should be deleted")
|
||||
assert.True(t, has3, "Stale session with pending should remain")
|
||||
}
|
||||
|
||||
// TestCleanupLoop_ExitsOnCancel tests that cleanup loop exits when context is cancelled.
|
||||
func TestCleanupLoop_ExitsOnCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Start cleanup loop in goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
manager.cleanupLoop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
|
||||
// Should exit quickly
|
||||
select {
|
||||
case <-done:
|
||||
// Success - loop exited
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Cleanup loop should exit when context is cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR InitializeSession (without DB)
|
||||
// =============================================================================
|
||||
|
||||
// TestInitializeSession_AlreadyActive tests reusing an already active session.
|
||||
func TestInitializeSession_AlreadyActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add an active session
|
||||
existingSession := &ActiveSession{
|
||||
SessionDBID: 42,
|
||||
ClaudeSessionID: "claude-existing",
|
||||
Project: "test-project",
|
||||
UserPrompt: "original prompt",
|
||||
LastPromptNumber: 1,
|
||||
StartTime: time.Now(),
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
}
|
||||
manager.sessions[42] = existingSession
|
||||
|
||||
// Initialize same session - should reuse
|
||||
session, err := manager.InitializeSession(context.Background(), 42, "new prompt", 5)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, session)
|
||||
assert.Same(t, existingSession, session)
|
||||
assert.Equal(t, "new prompt", session.UserPrompt)
|
||||
assert.Equal(t, 5, session.LastPromptNumber)
|
||||
}
|
||||
|
||||
// TestInitializeSession_AlreadyActive_EmptyPrompt tests reusing session with empty prompt.
|
||||
func TestInitializeSession_AlreadyActive_EmptyPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add an active session
|
||||
existingSession := &ActiveSession{
|
||||
SessionDBID: 42,
|
||||
UserPrompt: "original prompt",
|
||||
LastPromptNumber: 1,
|
||||
}
|
||||
manager.sessions[42] = existingSession
|
||||
|
||||
// Initialize with empty prompt - should NOT update
|
||||
session, err := manager.InitializeSession(context.Background(), 42, "", 0)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, session)
|
||||
assert.Equal(t, "original prompt", session.UserPrompt) // Unchanged
|
||||
assert.Equal(t, 1, session.LastPromptNumber) // Unchanged
|
||||
}
|
||||
|
||||
// TestInitializeSession_NoStore tests initialization without session store.
|
||||
func TestInitializeSession_NoStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessionStore: nil, // No store
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Should fail gracefully with nil store (panic recovery not expected)
|
||||
// This tests the guard against nil sessionStore
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
_ = r // Expected panic when calling nil store - intentionally ignored
|
||||
}
|
||||
}()
|
||||
|
||||
_, _ = manager.InitializeSession(context.Background(), 999, "prompt", 1)
|
||||
}
|
||||
|
||||
// TestInitializeSession_CallbackTriggered tests that created callback is triggered.
|
||||
func TestInitializeSession_CallbackTriggered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
var calledWithID int64
|
||||
manager.SetOnSessionCreated(func(id int64) {
|
||||
calledWithID = id
|
||||
})
|
||||
|
||||
// Add session directly (simulating what would happen after DB fetch)
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
defer sessionCancel()
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 100,
|
||||
ClaudeSessionID: "test",
|
||||
Project: "project",
|
||||
StartTime: time.Now(),
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.sessions[100] = session
|
||||
onCreated := manager.onCreated
|
||||
manager.mu.Unlock()
|
||||
|
||||
// Trigger callback
|
||||
if onCreated != nil {
|
||||
onCreated(100)
|
||||
}
|
||||
|
||||
assert.Equal(t, int64(100), calledWithID)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR QueueObservation AND QueueSummarize (without DB)
|
||||
// =============================================================================
|
||||
|
||||
// TestQueueObservation_ToExistingSession tests queuing to an existing session.
|
||||
func TestQueueObservation_ToExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Queue observation
|
||||
err := manager.QueueObservation(context.Background(), 1, ObservationData{
|
||||
ToolName: "Read",
|
||||
ToolInput: map[string]string{"path": "/test"},
|
||||
ToolResponse: "content",
|
||||
PromptNumber: 1,
|
||||
CWD: "/project",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, manager.GetTotalQueueDepth())
|
||||
|
||||
// Verify message
|
||||
messages := manager.DrainMessages(1)
|
||||
assert.Len(t, messages, 1)
|
||||
assert.Equal(t, MessageTypeObservation, messages[0].Type)
|
||||
assert.Equal(t, "Read", messages[0].Observation.ToolName)
|
||||
assert.Equal(t, "/project", messages[0].Observation.CWD)
|
||||
}
|
||||
|
||||
// TestQueueObservation_NotifiesSession tests that notification is sent to session.
|
||||
func TestQueueObservation_NotifiesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session with notify channel
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Queue observation
|
||||
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should receive notification on session channel
|
||||
select {
|
||||
case <-session.notify:
|
||||
// Success
|
||||
default:
|
||||
t.Error("Session should receive notification")
|
||||
}
|
||||
|
||||
// Should receive notification on process channel
|
||||
select {
|
||||
case <-manager.ProcessNotify:
|
||||
// Success
|
||||
default:
|
||||
t.Error("Manager ProcessNotify should receive notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueSummarize_ToExistingSession tests queuing summarize to an existing session.
|
||||
func TestQueueSummarize_ToExistingSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Queue summarize
|
||||
err := manager.QueueSummarize(context.Background(), 1, "User asked question", "Assistant answered")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, manager.GetTotalQueueDepth())
|
||||
|
||||
// Verify message
|
||||
messages := manager.DrainMessages(1)
|
||||
assert.Len(t, messages, 1)
|
||||
assert.Equal(t, MessageTypeSummarize, messages[0].Type)
|
||||
assert.Equal(t, "User asked question", messages[0].Summarize.LastUserMessage)
|
||||
assert.Equal(t, "Assistant answered", messages[0].Summarize.LastAssistantMessage)
|
||||
}
|
||||
|
||||
// TestQueueSummarize_NotifiesSession tests that notification is sent to session.
|
||||
func TestQueueSummarize_NotifiesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session with notify channel
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Queue summarize
|
||||
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Should receive notification on session channel
|
||||
select {
|
||||
case <-session.notify:
|
||||
// Success
|
||||
default:
|
||||
t.Error("Session should receive notification")
|
||||
}
|
||||
|
||||
// Should receive notification on process channel
|
||||
select {
|
||||
case <-manager.ProcessNotify:
|
||||
// Success
|
||||
default:
|
||||
t.Error("Manager ProcessNotify should receive notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueOperations_MultipleMessages tests queuing multiple messages.
|
||||
func TestQueueOperations_MultipleMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Queue multiple messages
|
||||
for i := 0; i < 10; i++ {
|
||||
if i%2 == 0 {
|
||||
err := manager.QueueObservation(context.Background(), 1, ObservationData{
|
||||
ToolName: "Tool" + string(rune('A'+i)),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 10, manager.GetTotalQueueDepth())
|
||||
|
||||
// Drain and verify
|
||||
messages := manager.DrainMessages(1)
|
||||
assert.Len(t, messages, 10)
|
||||
}
|
||||
|
||||
// TestQueueOperations_NonBlockingNotification tests non-blocking notification behavior.
|
||||
func TestQueueOperations_NonBlockingNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add session with full notify channel
|
||||
session := &ActiveSession{
|
||||
SessionDBID: 1,
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
// Fill the notify channel
|
||||
session.notify <- struct{}{}
|
||||
manager.sessions[1] = session
|
||||
|
||||
// Fill ProcessNotify channel
|
||||
manager.ProcessNotify <- struct{}{}
|
||||
|
||||
// Queue should NOT block even with full channels
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success - didn't block
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Queue operation should not block even with full notification channels")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentQueueAndCleanup tests concurrent queue operations and cleanup.
|
||||
func TestConcurrentQueueAndCleanup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := &Manager{
|
||||
sessions: make(map[int64]*ActiveSession),
|
||||
ProcessNotify: make(chan struct{}, 1),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
manager.ctx = ctx
|
||||
manager.cancel = cancel
|
||||
|
||||
// Pre-add multiple sessions
|
||||
for i := int64(1); i <= 5; i++ {
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
manager.sessions[i] = &ActiveSession{
|
||||
SessionDBID: i,
|
||||
StartTime: time.Now(),
|
||||
pendingMessages: make([]PendingMessage, 0),
|
||||
notify: make(chan struct{}, 1),
|
||||
ctx: sessionCtx,
|
||||
cancel: sessionCancel,
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent queue operations
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sessionID := int64((idx % 5) + 1)
|
||||
if idx%2 == 0 {
|
||||
_ = manager.QueueObservation(context.Background(), sessionID, ObservationData{ToolName: "Test"})
|
||||
} else {
|
||||
_ = manager.QueueSummarize(context.Background(), sessionID, "user", "assistant")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent cleanup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
manager.cleanupStaleSessions()
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent reads
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = manager.GetActiveSessionCount()
|
||||
_ = manager.GetTotalQueueDepth()
|
||||
_ = manager.IsAnySessionProcessing()
|
||||
_ = manager.GetAllSessions()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should have all sessions (none are stale)
|
||||
assert.Equal(t, 5, manager.GetActiveSessionCount())
|
||||
// Should have 50 messages total
|
||||
assert.Equal(t, 50, manager.GetTotalQueueDepth())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user