diff --git a/internal/ambient/sentinel.go b/internal/ambient/sentinel.go index af4eaf8..a1cdee6 100644 --- a/internal/ambient/sentinel.go +++ b/internal/ambient/sentinel.go @@ -236,6 +236,16 @@ func (s *Sentinel) evaluate(ctx context.Context) { cancel() s.mu.Lock() + // A mode may have activated during the (off-lock) brain call; re-check so a + // result computed while idle is not surfaced over an now-active mode. + if s.activeModeLocked() != "" { + changed := s.clearLocked() + s.mu.Unlock() + if changed { + s.fireOnChange() + } + return + } s.lastEvalTitles = joined if err != nil { s.mu.Unlock() diff --git a/internal/ambient/sentinel_test.go b/internal/ambient/sentinel_test.go index c623819..4f1d292 100644 --- a/internal/ambient/sentinel_test.go +++ b/internal/ambient/sentinel_test.go @@ -228,6 +228,42 @@ func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) { } } +// flippingJudge simulates a harness mode activating DURING the brain call: it +// returns a drift verdict but flips the active-mode flag as a side effect, so by +// the time evaluate re-acquires the lock a mode is active. +type flippingJudge struct{ active *string } + +func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) { + *f.active = "focus" + return "deep in YouTube", nil +} + +// A mode activating mid-evaluation must suppress the surface: no line, no toast, +// no memory record (coexistence rule holds across the unlock-for-I/O window). +func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) { + active := "" + n := &fakeNotifier{} + mem := &memoryFake{} + s := New(Deps{ + AI: &flippingJudge{active: &active}, + Notifier: n, + Memory: mem, + Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, + ActiveMode: func() string { return active }, + }, time.Minute, modeNotify) + s.OnWindow(snap("YouTube - Brave", "Brave")) + s.evaluate(context.Background()) + if s.Line() != "" { + t.Fatalf("mode active after judge must leave line empty, got %q", s.Line()) + } + if n.calls != 0 { + t.Fatalf("mode active after judge must not toast, got %d", n.calls) + } + if len(mem.events) != 0 { + t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events)) + } +} + func TestSnoozeMutesAndClears(t *testing.T) { j := &fakeJudge{out: []string{"drift", "drift"}} active := ""