Files
antidrift/internal/session/session_test.go
T
2026-05-31 18:07:31 -04:00

758 lines
24 KiB
Go

package session
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync/atomic"
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/store"
)
func newTestController(t *testing.T) (*Controller, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.json")
c, err := New(path)
if err != nil {
t.Fatalf("new controller: %v", err)
}
return c, path
}
func TestHappyPathDrivesStates(t *testing.T) {
c, _ := newTestController(t)
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should start Locked")
}
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
t.Fatalf("start commitment: %v", err)
}
st := c.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("should be Active, got %s", st.RuntimeState)
}
if st.Commitment == nil || st.Commitment.NextAction != "Port session" {
t.Fatalf("active commitment missing: %+v", st.Commitment)
}
if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() {
t.Fatalf("deadline should be in the future")
}
if err := c.Complete(); err != nil {
t.Fatalf("complete: %v", err)
}
if c.State().RuntimeState != domain.RuntimeReview {
t.Fatalf("should be Review after complete")
}
if err := c.End(); err != nil {
t.Fatalf("end: %v", err)
}
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should be Locked after end")
}
}
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
t.Fatalf("empty next action should error")
}
if c.State().RuntimeState != domain.RuntimePlanning {
t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState)
}
}
func TestStateRestoresFromSnapshot(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
st := second.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
}
if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" {
t.Fatalf("restored commitment missing: %+v", st.Commitment)
}
}
func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
want := first.State().Commitment.DeadlineUnixSecs
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
got := second.State().Commitment
if got == nil || got.DeadlineUnixSecs != want {
t.Fatalf("restored deadline: got %+v want %d", got, want)
}
}
// fakeClock returns successive instants on demand.
type fakeClock struct{ now time.Time }
func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) }
func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } }
func snap(class, title string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
}
func bucketSeconds(t *testing.T, st State, class, title string) int64 {
t.Helper()
if st.Evidence == nil {
t.Fatalf("expected evidence projection")
}
for _, b := range st.Evidence.Buckets {
if b.Class == class && b.Title == title {
return b.Seconds
}
}
return -1
}
func TestAccumulatesBucketsAndSwitches(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
c.RecordWindow(snap("code", "antidrift")) // latest window before start
_ = c.EnterPlanning()
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
clk.advance(10 * time.Second)
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
clk.advance(20 * time.Second)
c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back
st := c.State()
if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 {
t.Errorf("code bucket = %d, want 10", got)
}
if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 {
t.Errorf("firefox bucket = %d, want 20", got)
}
if st.Evidence.SwitchCount != 2 {
t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount)
}
}
func TestNoAccountingOutsideActive(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
// Locked: RecordWindow must not create stats.
c.RecordWindow(snap("code", "x"))
if c.State().Evidence != nil {
t.Fatalf("no session: evidence should be nil")
}
}
func TestUnavailableAccruesToReservedBucket(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
_ = c.EnterPlanning()
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
// latestWindow was zero-value (unavailable) at seed time.
clk.advance(5 * time.Second)
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
st := c.State()
if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 {
t.Errorf("unavailable bucket = %d, want 5", got)
}
}
func TestCrashReplayRebuildsStats(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, _ := New(path)
clk := &fakeClock{now: time.Unix(1000, 0)}
first.SetClock(clk.fn())
first.RecordWindow(snap("code", "antidrift"))
_ = first.EnterPlanning()
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
clk.advance(15 * time.Second)
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
// Simulate crash + restart: New replays the raw log.
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
st := second.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
}
if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 {
t.Errorf("replayed code bucket = %d, want 15", got)
}
if st.Evidence.SwitchCount != 1 {
t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount)
}
}
func TestEndWritesAuditSummary(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
c, _ := New(path)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
c.RecordWindow(snap("code", "antidrift"))
_ = c.EnterPlanning()
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
clk.advance(30 * time.Second)
c.RecordWindow(snap("firefox", "docs"))
clk.advance(10 * time.Second)
if err := c.Complete(); err != nil { // flush: +10s to firefox/docs
t.Fatalf("complete: %v", err)
}
if err := c.End(); err != nil {
t.Fatalf("end: %v", err)
}
auditPath := filepath.Join(dir, "audit.jsonl")
if err := store.VerifyChain(auditPath); err != nil {
t.Fatalf("chain should verify: %v", err)
}
}
type fakeCoach struct {
prop ai.Proposal
err error
gate chan struct{} // if non-nil, Coach blocks until it receives
}
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
if f.gate != nil {
<-f.gate
}
return f.prop, f.err
}
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Coach != nil && st.Coach.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
return State{}
}
func TestRequestCoachReady(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
if err := c.RequestCoach("do x"); err != nil {
t.Fatalf("request coach: %v", err)
}
st := waitCoachStatus(t, c, "ready")
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
t.Fatalf("ready proposal wrong: %+v", st.Coach)
}
}
func TestRequestCoachError(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
if err := c.RequestCoach("x"); err != nil {
t.Fatalf("request: %v", err)
}
st := waitCoachStatus(t, c, "error")
if st.Coach.Error == "" {
t.Fatal("error status should carry a message")
}
}
func TestRequestCoachUnavailable(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
if err := c.RequestCoach("x"); err != nil {
t.Fatalf("nil coach must degrade, not error: %v", err)
}
st := c.State()
if st.Coach == nil || st.Coach.Status != "error" {
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
}
}
func TestRequestCoachWrongState(t *testing.T) {
c, _ := newTestController(t)
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
if err := c.RequestCoach("x"); err == nil {
t.Fatal("coaching from Locked should error")
}
if c.State().Coach != nil {
t.Fatal("no coach view outside planning")
}
}
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
c.SetCoach(slow)
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
c.SetCoach(fast)
_ = c.RequestCoach("second") // gen2 -> ready NEW
st := waitCoachStatus(t, c, "ready")
if st.Coach.Proposal.NextAction != "NEW" {
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
}
close(slow.gate) // release gen1; it must be discarded
time.Sleep(50 * time.Millisecond)
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
t.Fatalf("stale gen1 overwrote state: %q", got)
}
}
func TestOnTaskRequiresActive(t *testing.T) {
c, _ := newTestController(t)
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
t.Fatalf("OnTask outside Active should error, got %v", err)
}
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
t.Fatalf("Refocus outside Active should error, got %v", err)
}
}
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
t.Fatalf("start: %v", err)
}
second, err := New(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("allowed classes not restored: %#v", got)
}
}
func TestOnTaskAppendsCurrentClass(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
if err := c.OnTask(); err != nil {
t.Fatalf("ontask: %v", err)
}
got := c.AllowedClassesForTest()
if len(got) != 1 || got[0] != "slack" {
t.Fatalf("OnTask should append current class, got %#v", got)
}
st := c.State()
if st.Drift == nil || st.Drift.Status != "ontask" {
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
}
}
type fakeJudge struct {
verdict ai.Verdict
err error
gate chan struct{}
calls int32
}
func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
atomic.AddInt32(&f.calls, 1)
if f.gate != nil {
<-f.gate
}
return f.verdict, f.err
}
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Drift != nil && st.Drift.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift)
return State{}
}
func startActive(t *testing.T, c *Controller, allowed []string) {
t.Helper()
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
t.Fatalf("start: %v", err)
}
}
func obs(class, title string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
}
func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) {
c, _ := newTestController(t)
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("Code", "main.go"))
st := c.State()
if st.Drift == nil || st.Drift.Status != "ontask" {
t.Fatalf("local match should be on-task, got %+v", st.Drift)
}
if atomic.LoadInt32(&fj.calls) != 0 {
t.Fatalf("judge must not be called on a local match")
}
}
func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
c, _ := newTestController(t)
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
st := waitDriftStatus(t, c, "drifting")
if st.Drift.Reason != "YouTube is off-task" {
t.Fatalf("reason not surfaced: %+v", st.Drift)
}
}
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
waitDriftStatus(t, c, "drifting")
// Advance past the debounce window so a second judge call would be allowed:
// this isolates the per-class cache as the only reason the judge is skipped.
now = now.Add(2 * driftDebounce)
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("cached class should not re-judge, calls=%d", got)
}
}
func TestDebounceLimitsJudgeCalls(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate)
c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("debounce should allow one judge call, calls=%d", got)
}
close(fj.gate)
}
func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) {
c, _ := newTestController(t)
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate
waitDriftStatus(t, c, "pending")
_ = c.Complete()
_ = c.End()
close(fj.gate) // release stale goroutine; must be discarded
time.Sleep(50 * time.Millisecond)
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should be Locked")
}
}
func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) {
c, _ := newTestController(t)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
st := c.State()
if st.Drift == nil || st.Drift.Status != "idle" {
t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift)
}
}
func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
t.Fatalf("start: %v", err)
}
second, err := New(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
waitDriftStatus(t, second, "drifting")
}
func TestLeavingPlanningClearsCoach(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
_ = c.RequestCoach("x")
waitCoachStatus(t, c, "ready")
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
t.Fatalf("start: %v", err)
}
if c.State().Coach != nil {
t.Fatal("coach view must be gone once Active")
}
}
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
// blocks the call until closed, mirroring fakeJudge.
type fakeNudger struct {
msg string
err error
gate chan struct{}
calls int32
}
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
atomic.AddInt32(&f.calls, 1)
if f.gate != nil {
<-f.gate
}
return f.msg, f.err
}
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
type nudgerFunc func(context.Context, string, []string) (string, error)
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
return f(ctx, c, t)
}
func waitNudge(t *testing.T, c *Controller, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Drift != nil && st.Drift.Nudge == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
}
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{msg: "drifted to an unrelated file"}
c.SetNudge(fn)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
waitNudge(t, c, "drifted to an unrelated file")
if got := atomic.LoadInt32(&fn.calls); got != 1 {
t.Fatalf("expected exactly one nudge call, got %d", got)
}
}
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{msg: "should not run"}
c.SetNudge(fn)
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
c.RecordWindow(obs("firefox", "Reddit"))
waitDriftStatus(t, c, "drifting")
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fn.calls); got != 0 {
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
}
}
func TestNudgeDebounced(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fn := &fakeNudger{msg: "drift"}
c.SetNudge(fn)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
waitNudge(t, c, "drift")
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fn.calls); got != 1 {
t.Fatalf("debounce should allow one nudge, calls=%d", got)
}
now = now.Add(2 * nudgeDebounce)
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
time.Sleep(5 * time.Millisecond)
}
if got := atomic.LoadInt32(&fn.calls); got != 2 {
t.Fatalf("expected second nudge after debounce, calls=%d", got)
}
}
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{err: errors.New("backend down")}
c.SetNudge(fn)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go"))
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
time.Sleep(5 * time.Millisecond)
}
time.Sleep(50 * time.Millisecond)
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
}
}
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
msgs := make(chan string, 2)
msgs <- "drifted"
msgs <- "" // back on track
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
return <-msgs, nil
}))
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go"))
waitNudge(t, c, "drifted")
now = now.Add(2 * nudgeDebounce)
c.RecordWindow(obs("code", "c.go"))
waitNudge(t, c, "") // an on-track result clears the prior advisory
}
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
c, _ := newTestController(t)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go"))
time.Sleep(50 * time.Millisecond)
st := c.State()
if st.Drift == nil || st.Drift.Status != "ontask" {
t.Fatalf("expected ontask, got %+v", st.Drift)
}
if st.Drift.Nudge != "" {
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
}
}
func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{msg: "kept", gate: make(chan struct{})}
c.SetNudge(fn)
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go")) // nudge launches, blocks on gate
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
time.Sleep(5 * time.Millisecond)
}
// An interleaved drift judgment bumps driftGen; the nudge must NOT be
// discarded by it — it is guarded by session identity, not driftGen.
c.RecordWindow(obs("firefox", "YouTube"))
close(fn.gate) // nudge returns within the same session
waitNudge(t, c, "kept")
}
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
c, _ := newTestController(t)
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
c.SetNudge(fn)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "a.go"))
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
deadline := time.Now().Add(1 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
time.Sleep(5 * time.Millisecond)
}
_ = c.Complete()
_ = c.End()
// Start a fresh session; the previous session's in-flight nudge must not
// leak into it.
c.SetNudge(nil) // the new session launches no nudges of its own
startActive(t, c, []string{"code"})
c.RecordWindow(obs("code", "c.go"))
close(fn.gate) // release the stale goroutine from the first session
time.Sleep(50 * time.Millisecond)
st := c.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("expected Active second session, got %s", st.RuntimeState)
}
if st.Drift == nil || st.Drift.Nudge != "" {
t.Fatalf("stale nudge must not leak into the new session: %+v", st.Drift)
}
}
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
c, _ := newTestController(t)
startActive(t, c, []string{"code"})
for i := 0; i < 15; i++ {
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
}
titles := c.recentTitlesForTest()
if len(titles) != 10 {
t.Fatalf("ring should cap at 10, got %d", len(titles))
}
if titles[0] != "file5.go" {
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
}
}