package learning import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/yeomyeonggeori/blueclaw/internal/persona" "github.com/yeomyeonggeori/blueprotocol/model" ) type failingLearningModel struct{} func (failingLearningModel) GenerateResponse(context.Context, string) (string, error) { return "", errors.New("synthetic reviewer failure") } func (failingLearningModel) GenerateStructuredResponse(context.Context, model.StructuredResponseRequest) (model.StructuredResponse, error) { return model.StructuredResponse{}, errors.New("synthetic reviewer failure") } type blockingLearningModel struct { entered chan struct{} release chan struct{} once sync.Once } func (model *blockingLearningModel) GenerateResponse(context.Context, string) (string, error) { return "", nil } func (reviewModel *blockingLearningModel) GenerateStructuredResponse(ctx context.Context, _ model.StructuredResponseRequest) (model.StructuredResponse, error) { reviewModel.once.Do(func() { close(reviewModel.entered) }) select { case <-reviewModel.release: return model.StructuredResponse{Content: `{"action":"keep","skillID":"","expectedVersion":0,"replaceID":"","replaceVersion":0,"description":"","instruction":"","soulDocument":"","evidenceIDs":[],"reason":"keep"}`}, nil case <-ctx.Done(): return model.StructuredResponse{}, ctx.Err() } } func TestCoordinatorDisabledLearningMakesNoReviewCall(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: false, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } model := &syntheticReviewerModel{responses: []string{`{}`}} coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: model}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "task-1", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.ReviewPending(context.Background(), time.Now().UTC().Add(2*time.Hour)); errorValue != nil { t.Fatal(errorValue) } if model.calls != 0 { t.Fatalf("disabled learning called model %d times", model.calls) } } func TestCoordinatorReviewPersistsEvidenceCursorAcrossRestart(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } model := &syntheticReviewerModel{responses: []string{`{"action":"keep","skillID":"","expectedVersion":0,"replaceID":"","replaceVersion":0,"description":"","instruction":"","soulDocument":"","evidenceIDs":[],"reason":"keep"}`}} coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: model}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "task-2", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } coordinator.state.LastObserved = time.Now().UTC().Add(-6 * time.Minute) coordinator.state.LastReview = time.Now().UTC().Add(-2 * time.Hour) if errorValue := coordinator.ReviewPending(context.Background(), time.Now().UTC()); errorValue != nil { t.Fatal(errorValue) } restarted, errorValue := NewCoordinator(root, store, Reviewer{Model: model}, nil) if errorValue != nil { t.Fatal(errorValue) } if len(restarted.state.Pending) != 0 || len(restarted.state.ReviewedIDs) != 1 { t.Fatalf("cursor was not persisted: %+v", restarted.state) } } func TestCoordinatorDoesNotCommitWhenForegroundTaskStartsDuringReview(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } model := &blockingLearningModel{entered: make(chan struct{}), release: make(chan struct{})} coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: model}, nil) if errorValue != nil { t.Fatal(errorValue) } _ = coordinator.Observe(Experience{TaskID: "task-3", Audience: "company", Outcome: []byte(`{"ok":true}`)}) coordinator.state.LastObserved = time.Now().UTC().Add(-6 * time.Minute) coordinator.state.LastReview = time.Now().UTC().Add(-2 * time.Hour) done := make(chan error, 1) go func() { done <- coordinator.ReviewPending(context.Background(), time.Now().UTC()) }() <-model.entered finishTask := coordinator.BeginTask() close(model.release) if errorValue := <-done; errorValue == nil { t.Fatal("expected active task cancellation") } finishTask() if len(store.List("company", true)) != 0 || len(coordinator.state.Pending) != 1 { t.Fatal("review committed or dropped evidence") } } func TestCoordinatorRecoversInFlightBatchAfterRestart(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: failingLearningModel{}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "recover-me", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } now := time.Now().UTC() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) batch, errorValue := coordinator.claimBatch(now) if errorValue != nil || len(batch) != 1 { t.Fatalf("claim batch: %v %+v", errorValue, batch) } restarted, errorValue := NewCoordinator(root, store, Reviewer{}, nil) if errorValue != nil { t.Fatal(errorValue) } if len(restarted.state.InFlight) != 0 || len(restarted.state.Pending) != 1 || len(restarted.state.ReviewedIDs) != 0 { t.Fatalf("in-flight batch was not recovered: %+v", restarted.state) } } func TestCoordinatorRequeuesBatchWhenSoulCannotBeRead(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } if errorValue := os.WriteFile(filepath.Join(root, persona.SoulFileName), []byte(`{"schemaVersion":1,"unknown":true}`), 0o600); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: failingLearningModel{}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "invalid-soul", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } now := time.Now().UTC() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) if errorValue := coordinator.ReviewPending(context.Background(), now); errorValue == nil { t.Fatal("expected invalid soul failure") } if len(coordinator.state.InFlight) != 0 || len(coordinator.state.Pending) != 1 || coordinator.state.Pending[0].TaskID != "invalid-soul" { t.Fatalf("soul failure lost evidence: %+v", coordinator.state) } } func TestCoordinatorDoesNotOverwriteExistingInFlightBatch(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: failingLearningModel{}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "in-flight", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } now := time.Now().UTC() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) batch, errorValue := coordinator.claimBatch(now) if errorValue != nil || len(batch) != 1 { t.Fatalf("claim batch: %v %+v", errorValue, batch) } if errorValue := coordinator.Observe(Experience{TaskID: "later-pending", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) secondBatch, errorValue := coordinator.claimBatch(now.Add(time.Hour)) if errorValue != nil { t.Fatal(errorValue) } if len(secondBatch) != 0 || len(coordinator.state.InFlight) != 1 || coordinator.state.InFlight[0].TaskID != "in-flight" { t.Fatalf("existing in-flight batch was overwritten: %+v", coordinator.state) } } func TestCoordinatorFailedReviewPreservesEvidenceAndConsumesAttempt(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: failingLearningModel{}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "failed-review", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } now := time.Now().UTC() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) if errorValue := coordinator.ReviewPending(context.Background(), now); errorValue == nil { t.Fatal("expected reviewer failure") } if len(coordinator.state.Pending) != 1 || len(coordinator.state.InFlight) != 0 || coordinator.state.DailyReviews != 1 { t.Fatalf("failed review state: %+v", coordinator.state) } } func TestCoordinatorDailyAttemptCapCountsFailedReviews(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: failingLearningModel{}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "cap-review", Audience: "company", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } now := time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC) for attempt := 0; attempt < 3; attempt++ { coordinator.mutex.Lock() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) coordinator.mutex.Unlock() if errorValue := coordinator.ReviewPending(context.Background(), now.Add(time.Duration(attempt)*time.Hour)); errorValue == nil { t.Fatal("expected reviewer failure") } } coordinator.mutex.Lock() if coordinator.state.DailyReviews != 3 || len(coordinator.state.Pending) != 1 { t.Fatalf("attempt cap state: %+v", coordinator.state) } coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) coordinator.mutex.Unlock() if batch, errorValue := coordinator.claimBatch(now.Add(4 * time.Hour)); errorValue != nil || len(batch) != 0 { t.Fatalf("fourth attempt was allowed: %+v %v", batch, errorValue) } } func TestCoordinatorClaimBatchKeepsSerializedEvidenceWithinBudget(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } coordinator, errorValue := NewCoordinator(root, store, Reviewer{}, nil) if errorValue != nil { t.Fatal(errorValue) } for taskNumber := 0; taskNumber < 20; taskNumber++ { if errorValue := coordinator.Observe(Experience{ TaskID: fmt.Sprintf("task-%d", taskNumber), Audience: "company", Request: strings.Repeat("request ", 3000), Outcome: []byte(`{"ok":true}`), }); errorValue != nil { t.Fatal(errorValue) } } now := time.Now().UTC() coordinator.state.LastObserved = now.Add(-6 * time.Minute) coordinator.state.LastReview = now.Add(-2 * time.Hour) batch, errorValue := coordinator.claimBatch(now) if errorValue != nil { t.Fatal(errorValue) } if len(batch) != 2 || len(coordinator.state.Pending) != 18 { t.Fatalf("expected byte-bounded batch of two with eighteen pending, got batch=%d pending=%d", len(batch), len(coordinator.state.Pending)) } serializedBytes := 0 for _, experience := range batch { serializedExperience, errorValue := json.Marshal(experience) if errorValue != nil { t.Fatal(errorValue) } serializedBytes += len(serializedExperience) } if serializedBytes > maxReviewEvidenceBytes { t.Fatalf("claimed evidence exceeded budget: %d", serializedBytes) } } func TestCoordinatorRetireUsesExpectedVersion(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } created, errorValue := store.Put(Skill{ID: "procedure", Audience: "company", Description: "Sample procedure", Instruction: "Run the sample procedure.", Status: "active"}) if errorValue != nil { t.Fatal(errorValue) } coordinator := &Coordinator{root: root, store: store} input := ReviewInput{Experience: []Experience{{TaskID: "task-retire", Audience: "company"}}} decision := Decision{Action: "retire", SkillID: created.ID, ExpectedVersion: created.Version} if errorValue := coordinator.applyDecisionLocked(decision, input); errorValue != nil { t.Fatalf("retire with current version failed: %v", errorValue) } retired, errorValue := store.Get(created.ID, "company", true) if errorValue != nil || len(retired) != 1 || retired[0].Status != "retired" { t.Fatalf("skill was not retired: %+v %v", retired, errorValue) } created, errorValue = store.Put(Skill{ID: "active-procedure", Audience: "company", Description: "Another procedure", Instruction: "Run another procedure.", Status: "active"}) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.applyDecisionLocked(Decision{Action: "retire", SkillID: created.ID, ExpectedVersion: created.Version - 1}, input); errorValue == nil { t.Fatal("stale retirement succeeded") } active, errorValue := store.Get(created.ID, "company", true) if errorValue != nil || len(active) != 1 || active[0].Status != "active" { t.Fatalf("stale retirement changed skill: %+v %v", active, errorValue) } } func TestCoordinatorOverviewShowsEveryAudienceAndReviewsWithoutTraces(t *testing.T) { root := t.TempDir() store, errorValue := Open(filepath.Join(root, "skills.json"), 20) if errorValue != nil { t.Fatal(errorValue) } for _, audience := range []string{"person:first", "person:second"} { if _, errorValue := store.Put(Skill{ID: audience + "-procedure", Audience: audience, Instruction: "Sample steps", Status: "active"}); errorValue != nil { t.Fatal(errorValue) } } if errorValue := store.UpdateSettings(Settings{Enabled: true, ActiveLimit: 20}); errorValue != nil { t.Fatal(errorValue) } keep := `{"action":"keep","skillID":"","expectedVersion":0,"replaceID":"","replaceVersion":0,"description":"","instruction":"","soulDocument":"","evidenceIDs":[],"reason":"no durable improvement"}` coordinator, errorValue := NewCoordinator(root, store, Reviewer{Model: &syntheticReviewerModel{responses: []string{keep}}}, nil) if errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.Observe(Experience{TaskID: "task-1", Audience: "person:first", Outcome: []byte(`{"ok":true}`)}); errorValue != nil { t.Fatal(errorValue) } if errorValue := coordinator.ReviewPending(context.Background(), time.Now().UTC().Add(2*time.Hour)); errorValue != nil { t.Fatal(errorValue) } overview, errorValue := coordinator.Overview() if errorValue != nil { t.Fatal(errorValue) } if len(overview.Skills) != 2 { t.Fatalf("overview skills = %d, want both audiences", len(overview.Skills)) } if len(overview.Reviews) != 1 || overview.Reviews[0].Decision.Action != "keep" || overview.Reviews[0].Traces != nil { t.Fatalf("overview reviews = %+v, want one keep decision without traces", overview.Reviews) } if !overview.Settings.Enabled || overview.ReviewedCount != 1 || overview.PendingCount != 0 { t.Fatalf("overview activity = %+v", overview) } }