mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
* feat(channelmemory): add passive extraction tuning * feat(ui): expose passive memory tuning controls * docs(memory): document passive extraction tuning --------- Co-authored-by: Collective Developer <man@collective.dev>
1022 lines
29 KiB
Go
1022 lines
29 KiB
Go
package consolidation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bgalert"
|
|
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// recallCall is a single RecordRecall invocation captured by the mock store
|
|
// so tests can assert the (id, score) stream forwarded by memory_search.
|
|
type recallCall struct {
|
|
ID string
|
|
Score float64
|
|
}
|
|
|
|
// mockEpisodicStore implements store.EpisodicStore for testing.
|
|
type mockEpisodicStore struct {
|
|
created []*store.EpisodicSummary
|
|
existsByID map[string]bool
|
|
unpromoted []store.EpisodicSummary
|
|
promoted map[string]bool
|
|
countResult int
|
|
countCalls int // set by CountUnpromoted — used by disabled-skip tests
|
|
recallCalls []recallCall
|
|
pruneErr error
|
|
pruneCount int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (m *mockEpisodicStore) Create(_ context.Context, ep *store.EpisodicSummary) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.created = append(m.created, ep)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) Get(context.Context, string) (*store.EpisodicSummary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) Delete(context.Context, string) error { return nil }
|
|
|
|
func (m *mockEpisodicStore) List(context.Context, string, string, int, int) ([]store.EpisodicSummary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) Search(context.Context, string, string, string, store.EpisodicSearchOptions) ([]store.EpisodicSearchResult, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) ExistsBySourceID(_ context.Context, _, _, sourceID string) (bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.existsByID[sourceID], nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) GetBySourceID(_ context.Context, _, _, sourceID string) (*store.EpisodicSummary, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if !m.existsByID[sourceID] {
|
|
return nil, nil
|
|
}
|
|
return &store.EpisodicSummary{ID: uuid.New(), SourceID: sourceID}, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) PruneExpired(_ context.Context) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.pruneErr != nil {
|
|
return 0, m.pruneErr
|
|
}
|
|
result := m.pruneCount
|
|
m.pruneCount = 0
|
|
return result, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) CountUnpromoted(_ context.Context, _, _ string) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.countCalls++
|
|
return m.countResult, nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) ListUnpromoted(_ context.Context, _, _ string, _ int) ([]store.EpisodicSummary, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.unpromoted, nil
|
|
}
|
|
|
|
// ListUnpromotedScored returns the same fixture as ListUnpromoted; tests that
|
|
// need to verify sort order override the method directly on a dedicated mock.
|
|
func (m *mockEpisodicStore) ListUnpromotedScored(_ context.Context, _, _ string, _ int) ([]store.EpisodicSummary, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.unpromoted, nil
|
|
}
|
|
|
|
// RecordRecall appends the call into the recallCalls slice so tests can
|
|
// assert which episodic IDs received which score.
|
|
func (m *mockEpisodicStore) RecordRecall(_ context.Context, id string, score float64) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.recallCalls = append(m.recallCalls, recallCall{ID: id, Score: score})
|
|
return nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) MarkPromoted(_ context.Context, ids []string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, id := range ids {
|
|
m.promoted[id] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockEpisodicStore) SetEmbeddingProvider(store.EmbeddingProvider) {}
|
|
|
|
func (m *mockEpisodicStore) Close() error { return nil }
|
|
|
|
// mockKGStore implements store.KnowledgeGraphStore for testing.
|
|
type mockKGStore struct {
|
|
ingestedEntities []store.Entity
|
|
ingestedRelations []store.Relation
|
|
dedupMerged int
|
|
dedupFlagged int
|
|
dedupErr error
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (m *mockKGStore) IngestExtraction(ctx context.Context, agentID, userID string, entities []store.Entity, relations []store.Relation) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.ingestedEntities = append(m.ingestedEntities, entities...)
|
|
m.ingestedRelations = append(m.ingestedRelations, relations...)
|
|
|
|
// Return mock IDs for ingested entities
|
|
ids := make([]string, len(entities))
|
|
for i := range entities {
|
|
ids[i] = fmt.Sprintf("entity-%d", i)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (m *mockKGStore) DedupAfterExtraction(_ context.Context, _, _ string, _ []string) (int, int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.dedupErr != nil {
|
|
return 0, 0, m.dedupErr
|
|
}
|
|
return m.dedupMerged, m.dedupFlagged, nil
|
|
}
|
|
|
|
// Implement remaining store.KnowledgeGraphStore methods
|
|
func (m *mockKGStore) UpsertEntity(context.Context, *store.Entity) error { return nil }
|
|
func (m *mockKGStore) GetEntity(context.Context, string, string, string) (*store.Entity, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) DeleteEntity(context.Context, string, string, string) error { return nil }
|
|
func (m *mockKGStore) ListEntities(context.Context, string, string, store.EntityListOptions) ([]store.Entity, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) SearchEntities(context.Context, string, string, string, int) ([]store.Entity, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) UpsertRelation(context.Context, *store.Relation) error { return nil }
|
|
func (m *mockKGStore) DeleteRelation(context.Context, string, string, string) error { return nil }
|
|
func (m *mockKGStore) ListRelations(context.Context, string, string, string) ([]store.Relation, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) ListAllRelations(context.Context, string, string, int) ([]store.Relation, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) Traverse(context.Context, string, string, string, int) ([]store.TraversalResult, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) PruneByConfidence(context.Context, string, string, float64) (int, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockKGStore) ScanDuplicates(context.Context, string, string, float64, int) (int, error) {
|
|
return 0, nil
|
|
}
|
|
func (m *mockKGStore) ListDedupCandidates(context.Context, string, string, int) ([]store.DedupCandidate, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) MergeEntities(context.Context, string, string, string, string) error {
|
|
return nil
|
|
}
|
|
func (m *mockKGStore) DismissCandidate(context.Context, string, string) error { return nil }
|
|
func (m *mockKGStore) Stats(context.Context, string, string) (*store.GraphStats, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) ListEntitiesTemporal(context.Context, string, string, store.EntityListOptions, store.TemporalQueryOptions) ([]store.Entity, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockKGStore) SupersedeEntity(context.Context, *store.Entity, *store.Entity) error {
|
|
return nil
|
|
}
|
|
func (m *mockKGStore) SetEmbeddingProvider(store.EmbeddingProvider) {}
|
|
func (m *mockKGStore) Close() error { return nil }
|
|
|
|
// mockMemoryStore implements store.MemoryStore for testing.
|
|
type mockMemoryStore struct {
|
|
docs map[string]string
|
|
indexed map[string]bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockMemoryStore() *mockMemoryStore {
|
|
return &mockMemoryStore{
|
|
docs: make(map[string]string),
|
|
indexed: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (m *mockMemoryStore) PutDocument(_ context.Context, _, _, path, content string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.docs[path] = content
|
|
return nil
|
|
}
|
|
|
|
func (m *mockMemoryStore) IndexDocument(_ context.Context, _, _, path string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.indexed[path] = true
|
|
return nil
|
|
}
|
|
|
|
// Implement remaining store.MemoryStore methods
|
|
func (m *mockMemoryStore) GetDocument(context.Context, string, string, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
func (m *mockMemoryStore) DeleteDocument(context.Context, string, string, string) error { return nil }
|
|
func (m *mockMemoryStore) ListDocuments(context.Context, string, string) ([]store.DocumentInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) ListAllDocumentsGlobal(context.Context) ([]store.DocumentInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) ListAllDocuments(context.Context, string) ([]store.DocumentInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) GetDocumentDetail(context.Context, string, string, string) (*store.DocumentDetail, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) ListChunks(context.Context, string, string, string) ([]store.ChunkInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) Search(context.Context, string, string, string, store.MemorySearchOptions) ([]store.MemorySearchResult, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockMemoryStore) IndexAll(context.Context, string, string) error { return nil }
|
|
func (m *mockMemoryStore) SetEmbeddingProvider(store.EmbeddingProvider) {}
|
|
func (m *mockMemoryStore) Close() error { return nil }
|
|
|
|
// mockSessionStore implements store.SessionCoreStore for testing.
|
|
type mockSessionStore struct {
|
|
history []providers.Message
|
|
summary string
|
|
}
|
|
|
|
func (m *mockSessionStore) GetOrCreate(context.Context, string) *store.SessionData { return nil }
|
|
func (m *mockSessionStore) Get(context.Context, string) *store.SessionData { return nil }
|
|
func (m *mockSessionStore) AddMessage(context.Context, string, providers.Message) {}
|
|
|
|
func (m *mockSessionStore) GetHistory(_ context.Context, _ string) []providers.Message {
|
|
return m.history
|
|
}
|
|
|
|
func (m *mockSessionStore) GetSummary(_ context.Context, _ string) string {
|
|
return m.summary
|
|
}
|
|
|
|
func (m *mockSessionStore) SetSummary(context.Context, string, string) {}
|
|
func (m *mockSessionStore) GetLabel(context.Context, string) string { return "" }
|
|
func (m *mockSessionStore) SetLabel(context.Context, string, string) {}
|
|
func (m *mockSessionStore) SetAgentInfo(context.Context, string, uuid.UUID, string) {}
|
|
func (m *mockSessionStore) TruncateHistory(context.Context, string, int) {}
|
|
func (m *mockSessionStore) SetHistory(context.Context, string, []providers.Message) {}
|
|
func (m *mockSessionStore) Reset(context.Context, string) {}
|
|
func (m *mockSessionStore) Delete(context.Context, string) error { return nil }
|
|
func (m *mockSessionStore) Save(context.Context, string) error { return nil }
|
|
|
|
type mockSystemConfigStore struct {
|
|
data map[string]string
|
|
deleteKeys []string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (m *mockSystemConfigStore) Get(_ context.Context, key string) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if val, ok := m.data[key]; ok {
|
|
return val, nil
|
|
}
|
|
return "", errors.New("not found")
|
|
}
|
|
|
|
func (m *mockSystemConfigStore) Set(_ context.Context, key, value string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.data == nil {
|
|
m.data = make(map[string]string)
|
|
}
|
|
m.data[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSystemConfigStore) Delete(_ context.Context, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.data, key)
|
|
m.deleteKeys = append(m.deleteKeys, key)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSystemConfigStore) List(context.Context) (map[string]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make(map[string]string, len(m.data))
|
|
for k, v := range m.data {
|
|
out[k] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// mockDomainEventBus implements eventbus.DomainEventBus for testing.
|
|
type mockDomainEventBus struct {
|
|
published []eventbus.DomainEvent
|
|
handlers map[eventbus.EventType][]eventbus.DomainEventHandler
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockDomainEventBus() *mockDomainEventBus {
|
|
return &mockDomainEventBus{
|
|
handlers: make(map[eventbus.EventType][]eventbus.DomainEventHandler),
|
|
}
|
|
}
|
|
|
|
func (m *mockDomainEventBus) Publish(event eventbus.DomainEvent) {
|
|
m.mu.Lock()
|
|
m.published = append(m.published, event)
|
|
handlers := m.handlers[event.Type]
|
|
m.mu.Unlock()
|
|
|
|
for _, h := range handlers {
|
|
_ = h(context.Background(), event)
|
|
}
|
|
}
|
|
|
|
func (m *mockDomainEventBus) Subscribe(eventType eventbus.EventType, handler eventbus.DomainEventHandler) func() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.handlers[eventType] = append(m.handlers[eventType], handler)
|
|
return func() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
handlers := m.handlers[eventType]
|
|
for i, h := range handlers {
|
|
if h != nil { // simple equality check for cleanup
|
|
handlers = append(handlers[:i], handlers[i+1:]...)
|
|
m.handlers[eventType] = handlers
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *mockDomainEventBus) Start(_ context.Context) {}
|
|
|
|
func (m *mockDomainEventBus) Drain(_ time.Duration) error { return nil }
|
|
|
|
// Test episodic worker
|
|
|
|
func TestEpisodicWorkerHandle_WithSummary(t *testing.T) {
|
|
mockStore := &mockEpisodicStore{existsByID: make(map[string]bool)}
|
|
mockEventBus := newMockDomainEventBus()
|
|
mockProvider := &mockProvider{
|
|
chatResp: &providers.ChatResponse{
|
|
Content: "Summarized content",
|
|
},
|
|
}
|
|
|
|
worker := &episodicWorker{
|
|
store: mockStore,
|
|
registry: testRegistry(mockProvider),
|
|
eventBus: mockEventBus,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: providers.MasterTenantID.String(),
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: "session-123",
|
|
CompactionCount: 1,
|
|
Summary: "Pre-computed summary",
|
|
MessageCount: 10,
|
|
TokensUsed: 1000,
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
|
|
if len(mockStore.created) != 1 {
|
|
t.Errorf("Expected 1 created episodic, got %d", len(mockStore.created))
|
|
}
|
|
if mockStore.created[0].Summary != "Pre-computed summary" {
|
|
t.Errorf("Expected summary 'Pre-computed summary', got %q", mockStore.created[0].Summary)
|
|
}
|
|
if len(mockEventBus.published) == 0 {
|
|
t.Error("Expected episodic.created event to be published")
|
|
}
|
|
}
|
|
|
|
func TestEpisodicWorkerHandle_InvalidPayload(t *testing.T) {
|
|
worker := &episodicWorker{}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
Payload: &eventbus.EntityUpsertedPayload{}, // wrong payload type
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid payload type")
|
|
}
|
|
}
|
|
|
|
func TestEpisodicWorkerHandle_DuplicateSourceID(t *testing.T) {
|
|
mockStore := &mockEpisodicStore{
|
|
existsByID: map[string]bool{"session-123:1": true},
|
|
}
|
|
|
|
worker := &episodicWorker{
|
|
store: mockStore,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: "session-123",
|
|
CompactionCount: 1,
|
|
Summary: "Summary",
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
|
|
// Should not create new episodic for duplicate
|
|
if len(mockStore.created) != 0 {
|
|
t.Errorf("Expected 0 created episodics for duplicate, got %d", len(mockStore.created))
|
|
}
|
|
}
|
|
|
|
// TestEpisodicWorkerHandle_NonUUIDAgentID guards the regression where Loop
|
|
// published DomainEvent.AgentID as the agent key (e.g. "goctech-leader")
|
|
// instead of l.agentUUID.String(). The episodic worker must reject such
|
|
// events with a clear error — never panic, never leak a raw PG error.
|
|
func TestEpisodicWorkerHandle_NonUUIDAgentID(t *testing.T) {
|
|
mockStore := &mockEpisodicStore{}
|
|
worker := &episodicWorker{store: mockStore}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: "goctech-leader", // agent key, not a UUID
|
|
UserID: "test-user",
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: "session-123",
|
|
CompactionCount: 0,
|
|
Summary: "Summary",
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err == nil {
|
|
t.Fatal("Expected error for non-UUID agent_id, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid agent_id") {
|
|
t.Errorf("Expected 'invalid agent_id' error, got: %v", err)
|
|
}
|
|
if len(mockStore.created) != 0 {
|
|
t.Errorf("Expected no episodic created on bad agent_id, got %d", len(mockStore.created))
|
|
}
|
|
}
|
|
|
|
// TestEpisodicWorkerHandle_NonUUIDTenantID mirrors the agent_id guard for tenant_id.
|
|
func TestEpisodicWorkerHandle_NonUUIDTenantID(t *testing.T) {
|
|
mockStore := &mockEpisodicStore{}
|
|
worker := &episodicWorker{store: mockStore}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventSessionCompleted,
|
|
TenantID: "not-a-uuid",
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.SessionCompletedPayload{
|
|
SessionKey: "session-123",
|
|
CompactionCount: 0,
|
|
Summary: "Summary",
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err == nil {
|
|
t.Fatal("Expected error for non-UUID tenant_id, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid tenant_id") {
|
|
t.Errorf("Expected 'invalid tenant_id' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test semantic worker
|
|
|
|
func TestSemanticWorkerHandle_WithValidExtraction(t *testing.T) {
|
|
mockKG := &mockKGStore{}
|
|
mockExtractor := &mockExtractor{
|
|
result: &knowledgegraph.ExtractionResult{
|
|
Entities: []store.Entity{
|
|
{Name: "Entity1", EntityType: "Person"},
|
|
{Name: "Entity2", EntityType: "Organization"},
|
|
},
|
|
Relations: []store.Relation{
|
|
{SourceEntityID: "e1", TargetEntityID: "e2", RelationType: "works_at"},
|
|
},
|
|
},
|
|
}
|
|
mockEventBus := newMockDomainEventBus()
|
|
|
|
worker := &semanticWorker{
|
|
kgStore: mockKG,
|
|
extractor: mockExtractor,
|
|
eventBus: mockEventBus,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
EpisodicID: "ep-123",
|
|
Summary: "Alice works at TechCorp",
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
|
|
if len(mockKG.ingestedEntities) != 2 {
|
|
t.Errorf("Expected 2 ingested entities, got %d", len(mockKG.ingestedEntities))
|
|
}
|
|
|
|
// Should publish entity.upserted event
|
|
entityUpsertedCount := 0
|
|
for _, pub := range mockEventBus.published {
|
|
if pub.Type == eventbus.EventEntityUpserted {
|
|
entityUpsertedCount++
|
|
}
|
|
}
|
|
if entityUpsertedCount != 1 {
|
|
t.Errorf("Expected 1 entity.upserted event, got %d", entityUpsertedCount)
|
|
}
|
|
}
|
|
|
|
func TestSemanticWorkerHandle_PassesTopicsAndEntitiesAsContextHints(t *testing.T) {
|
|
mockExtractor := &mockExtractor{result: &knowledgegraph.ExtractionResult{}}
|
|
worker := &semanticWorker{
|
|
kgStore: &mockKGStore{},
|
|
extractor: mockExtractor,
|
|
eventBus: newMockDomainEventBus(),
|
|
}
|
|
|
|
err := worker.Handle(context.Background(), eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
EpisodicID: "ep-123",
|
|
Summary: "Project Orion uses ExampleCo for collaboration.",
|
|
KeyEntities: []string{"Project Orion", "ExampleCo"},
|
|
KeyTopics: []string{"collaboration", "planning"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
if len(mockExtractor.inputs) != 1 {
|
|
t.Fatalf("extractor calls = %d, want 1", len(mockExtractor.inputs))
|
|
}
|
|
input := mockExtractor.inputs[0]
|
|
for _, want := range []string{
|
|
"Fact summary:",
|
|
"Project Orion uses ExampleCo for collaboration.",
|
|
"Context hints for disambiguation only",
|
|
"Do not create entities or relations solely because a value appears below",
|
|
"Candidate entities:",
|
|
"- Project Orion",
|
|
"- ExampleCo",
|
|
"Topics / context tags:",
|
|
"- collaboration",
|
|
"- planning",
|
|
} {
|
|
if !strings.Contains(input, want) {
|
|
t.Fatalf("semantic extraction input missing %q:\n%s", want, input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSemanticWorkerHandle_SuccessClearsBackgroundProviderAlert(t *testing.T) {
|
|
mockKG := &mockKGStore{}
|
|
mockExtractor := &mockExtractor{
|
|
result: &knowledgegraph.ExtractionResult{
|
|
Entities: []store.Entity{{Name: "Entity1", EntityType: "Person"}},
|
|
},
|
|
}
|
|
sc := &mockSystemConfigStore{
|
|
data: map[string]string{
|
|
bgalert.AlertKeyProviderError: `{"reason":"auth_permanent","worker":"kg_extraction"}`,
|
|
},
|
|
}
|
|
worker := &semanticWorker{
|
|
kgStore: mockKG,
|
|
extractor: mockExtractor,
|
|
eventBus: newMockDomainEventBus(),
|
|
alertDeps: bgalert.AlertDeps{SystemConfigs: sc},
|
|
}
|
|
|
|
err := worker.Handle(context.Background(), eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: uuid.New().String(),
|
|
UserID: "test-user",
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
EpisodicID: "ep-123",
|
|
Summary: "Alice works at TechCorp",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
if _, ok := sc.data[bgalert.AlertKeyProviderError]; ok {
|
|
t.Fatal("expected successful extraction to clear stale background provider alert")
|
|
}
|
|
if len(sc.deleteKeys) != 1 || sc.deleteKeys[0] != bgalert.AlertKeyProviderError {
|
|
t.Fatalf("deleteKeys = %#v, want [%q]", sc.deleteKeys, bgalert.AlertKeyProviderError)
|
|
}
|
|
}
|
|
|
|
func TestSemanticWorkerHandle_NilExtractor(t *testing.T) {
|
|
worker := &semanticWorker{
|
|
extractor: nil,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
Payload: &eventbus.EpisodicCreatedPayload{Summary: "test"},
|
|
}
|
|
|
|
// Should not error, just return nil
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle should not error with nil extractor: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSemanticWorkerHandle_InvalidPayload(t *testing.T) {
|
|
worker := &semanticWorker{}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
Payload: &eventbus.EntityUpsertedPayload{}, // wrong payload type
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid payload type")
|
|
}
|
|
}
|
|
|
|
func TestSemanticWorkerHandle_ExtractionError(t *testing.T) {
|
|
mockKG := &mockKGStore{}
|
|
mockExtractor := &mockExtractor{
|
|
err: errors.New("extraction failed"),
|
|
}
|
|
|
|
worker := &semanticWorker{
|
|
kgStore: mockKG,
|
|
extractor: mockExtractor,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
Payload: &eventbus.EpisodicCreatedPayload{Summary: "test"},
|
|
}
|
|
|
|
// Should not propagate error (non-fatal in worker design)
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle should not propagate extraction error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test dedup worker
|
|
|
|
func TestDedupWorkerHandle_ValidPayload(t *testing.T) {
|
|
mockKG := &mockKGStore{
|
|
dedupMerged: 2,
|
|
dedupFlagged: 1,
|
|
}
|
|
|
|
worker := &dedupWorker{
|
|
kgStore: mockKG,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEntityUpserted,
|
|
AgentID: "agent-123",
|
|
UserID: "user-123",
|
|
Payload: &eventbus.EntityUpsertedPayload{
|
|
EntityIDs: []string{"e1", "e2", "e3"},
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDedupWorkerHandle_EmptyEntityList(t *testing.T) {
|
|
mockKG := &mockKGStore{}
|
|
|
|
worker := &dedupWorker{
|
|
kgStore: mockKG,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEntityUpserted,
|
|
Payload: &eventbus.EntityUpsertedPayload{EntityIDs: []string{}},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDedupWorkerHandle_InvalidPayload(t *testing.T) {
|
|
worker := &dedupWorker{}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEntityUpserted,
|
|
Payload: &eventbus.EpisodicCreatedPayload{}, // wrong payload type
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err == nil {
|
|
t.Fatal("Expected error for invalid payload type")
|
|
}
|
|
}
|
|
|
|
func TestDedupWorkerHandle_DedupError(t *testing.T) {
|
|
mockKG := &mockKGStore{
|
|
dedupErr: errors.New("dedup failed"),
|
|
}
|
|
|
|
worker := &dedupWorker{
|
|
kgStore: mockKG,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEntityUpserted,
|
|
Payload: &eventbus.EntityUpsertedPayload{EntityIDs: []string{"e1"}},
|
|
}
|
|
|
|
// Should not propagate error (non-fatal)
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle should not propagate dedup error: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test dreaming worker
|
|
|
|
func TestDreamingWorkerHandle_BelowThreshold(t *testing.T) {
|
|
mockEpisodic := &mockEpisodicStore{countResult: 2} // below default threshold of 5
|
|
|
|
worker := &dreamingWorker{
|
|
episodicStore: mockEpisodic,
|
|
threshold: 5,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
AgentID: "agent-123",
|
|
UserID: "user-123",
|
|
Payload: &eventbus.EpisodicCreatedPayload{},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
|
|
// Should not have synthesized (below threshold)
|
|
if len(mockEpisodic.promoted) > 0 {
|
|
t.Errorf("Expected no promotions below threshold, got %d", len(mockEpisodic.promoted))
|
|
}
|
|
}
|
|
|
|
func TestDreamingWorkerHandle_MeetsThreshold(t *testing.T) {
|
|
summaries := []store.EpisodicSummary{
|
|
{ID: uuid.New(), Summary: "Session 1 summary"},
|
|
{ID: uuid.New(), Summary: "Session 2 summary"},
|
|
{ID: uuid.New(), Summary: "Session 3 summary"},
|
|
{ID: uuid.New(), Summary: "Session 4 summary"},
|
|
{ID: uuid.New(), Summary: "Session 5 summary"},
|
|
}
|
|
|
|
mockEpisodic := &mockEpisodicStore{
|
|
countResult: 5,
|
|
unpromoted: summaries,
|
|
promoted: make(map[string]bool),
|
|
}
|
|
mockMemory := newMockMemoryStore()
|
|
mockProvider := &mockProvider{
|
|
chatResp: &providers.ChatResponse{
|
|
Content: "Consolidated insights",
|
|
},
|
|
}
|
|
|
|
worker := &dreamingWorker{
|
|
episodicStore: mockEpisodic,
|
|
memoryStore: mockMemory,
|
|
registry: testRegistry(mockProvider),
|
|
threshold: 5,
|
|
debounce: 1 * time.Second,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
event := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: providers.MasterTenantID.String(),
|
|
AgentID: "agent-123",
|
|
UserID: "user-123",
|
|
Payload: &eventbus.EpisodicCreatedPayload{},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("Handle failed: %v", err)
|
|
}
|
|
|
|
// Should have promoted entries
|
|
if len(mockEpisodic.promoted) != 5 {
|
|
t.Errorf("Expected 5 promoted entries, got %d", len(mockEpisodic.promoted))
|
|
}
|
|
|
|
// Should have stored consolidated document
|
|
if len(mockMemory.docs) == 0 {
|
|
t.Error("Expected consolidated document to be stored")
|
|
}
|
|
|
|
if len(mockMemory.indexed) == 0 {
|
|
t.Error("Expected consolidated document to be indexed")
|
|
}
|
|
}
|
|
|
|
func TestDreamingWorkerHandle_DebounceSkip(t *testing.T) {
|
|
summaries := []store.EpisodicSummary{
|
|
{ID: uuid.New(), Summary: "Session 1"},
|
|
{ID: uuid.New(), Summary: "Session 2"},
|
|
{ID: uuid.New(), Summary: "Session 3"},
|
|
{ID: uuid.New(), Summary: "Session 4"},
|
|
{ID: uuid.New(), Summary: "Session 5"},
|
|
}
|
|
|
|
mockEpisodic := &mockEpisodicStore{
|
|
countResult: 5,
|
|
unpromoted: summaries,
|
|
promoted: make(map[string]bool),
|
|
}
|
|
mockMemory := newMockMemoryStore()
|
|
mockProvider := &mockProvider{
|
|
chatResp: &providers.ChatResponse{Content: "Consolidated"},
|
|
}
|
|
|
|
worker := &dreamingWorker{
|
|
episodicStore: mockEpisodic,
|
|
memoryStore: mockMemory,
|
|
registry: testRegistry(mockProvider),
|
|
threshold: 5,
|
|
debounce: 10 * time.Second,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// First run should succeed
|
|
event1 := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: providers.MasterTenantID.String(),
|
|
AgentID: "agent-123",
|
|
UserID: "user-123",
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
Summary: "summary1",
|
|
},
|
|
}
|
|
|
|
err := worker.Handle(ctx, event1)
|
|
if err != nil {
|
|
t.Fatalf("First Handle failed: %v", err)
|
|
}
|
|
|
|
firstPromotedCount := len(mockEpisodic.promoted)
|
|
if firstPromotedCount == 0 {
|
|
t.Error("Expected first run to have promoted entries")
|
|
}
|
|
|
|
// Second run immediately after should skip (debounced)
|
|
mockEpisodic.promoted = make(map[string]bool) // reset for second run
|
|
event2 := eventbus.DomainEvent{
|
|
Type: eventbus.EventEpisodicCreated,
|
|
TenantID: uuid.New().String(),
|
|
AgentID: "agent-123",
|
|
UserID: "user-123",
|
|
Payload: &eventbus.EpisodicCreatedPayload{
|
|
Summary: "summary2",
|
|
},
|
|
}
|
|
|
|
err = worker.Handle(ctx, event2)
|
|
if err != nil {
|
|
t.Fatalf("Second Handle failed: %v", err)
|
|
}
|
|
|
|
// Second run should have been debounced (no new promotions)
|
|
if len(mockEpisodic.promoted) > 0 {
|
|
t.Errorf("Expected debounced skip, but promotions were made: %d", len(mockEpisodic.promoted))
|
|
}
|
|
}
|
|
|
|
// Test Register function
|
|
|
|
func TestRegister_WiresAllWorkers(t *testing.T) {
|
|
mockEpisodic := &mockEpisodicStore{existsByID: make(map[string]bool)}
|
|
mockKG := &mockKGStore{}
|
|
mockMemory := newMockMemoryStore()
|
|
mockSession := &mockSessionStore{}
|
|
mockEventBus := newMockDomainEventBus()
|
|
mockProvider := &mockProvider{
|
|
chatResp: &providers.ChatResponse{Content: "test"},
|
|
}
|
|
mockExtractor := &mockExtractor{
|
|
result: &knowledgegraph.ExtractionResult{
|
|
Entities: []store.Entity{},
|
|
Relations: []store.Relation{},
|
|
},
|
|
}
|
|
|
|
deps := ConsolidationDeps{
|
|
EpisodicStore: mockEpisodic,
|
|
MemoryStore: mockMemory,
|
|
KGStore: mockKG,
|
|
SessionStore: mockSession,
|
|
EventBus: mockEventBus,
|
|
Registry: testRegistry(mockProvider),
|
|
Extractor: mockExtractor,
|
|
}
|
|
|
|
cleanup := Register(deps)
|
|
if cleanup == nil {
|
|
t.Fatal("Register should return a cleanup function")
|
|
}
|
|
|
|
// Verify event handlers were registered
|
|
if len(mockEventBus.handlers) == 0 {
|
|
t.Error("Expected event handlers to be registered")
|
|
}
|
|
|
|
cleanup()
|
|
}
|