package session import ( "context" "errors" "fmt" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "antidrift/internal/ai" "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/knowledge" "antidrift/internal/store" "antidrift/internal/tasks" ) 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, domain.EnforcementWarn); 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, domain.EnforcementWarn); 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, domain.EnforcementWarn) 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, domain.EnforcementWarn) 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, domain.EnforcementWarn) // 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, domain.EnforcementWarn) // 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, domain.EnforcementWarn) 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, domain.EnforcementWarn) 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 mu sync.Mutex gotGrounding string } func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { f.mu.Lock() f.gotGrounding = grounding f.mu.Unlock() if f.gate != nil { <-f.gate } return f.prop, f.err } func (f *fakeCoach) grounding() string { f.mu.Lock() defer f.mu.Unlock() return f.gotGrounding } // 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"}, domain.EnforcementWarn); 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, domain.EnforcementWarn) 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() startActiveLevel(t, c, allowed, domain.EnforcementWarn) } func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) { 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, level); 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"}, domain.EnforcementWarn); 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") } type fakeGuard struct{ calls int32 } func (g *fakeGuard) MinimizeActive(context.Context) error { atomic.AddInt32(&g.calls, 1) return nil } func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if atomic.LoadInt32(&g.calls) >= min { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls)) } func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) { c, _ := newTestController(t) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) g := &fakeGuard{} c.SetGuard(g) startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) // Async path: first off-task observation launches the judge; on confirmation // the guard minimizes once. c.RecordWindow(obs("firefox", "YouTube")) st := waitDriftStatus(t, c, "drifting") if !st.Drift.Enforced { t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift) } waitGuardCalls(t, g, 1) // Synchronous cached path: same class again hits the per-class cache, sets // drifting under the lock, and minimizes again without a new judgment. c.RecordWindow(obs("firefox", "Reddit")) waitGuardCalls(t, g, 2) if got := atomic.LoadInt32(&g.calls); got < 2 { t.Fatalf("cached-drift path did not minimize: %d calls", got) } } func TestWarnLevelNeverMinimizes(t *testing.T) { c, _ := newTestController(t) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) g := &fakeGuard{} c.SetGuard(g) startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn) c.RecordWindow(obs("firefox", "YouTube")) st := waitDriftStatus(t, c, "drifting") if st.Drift.Enforced { t.Fatalf("Enforced must be false at warn: %+v", st.Drift) } time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run if got := atomic.LoadInt32(&g.calls); got != 0 { t.Fatalf("warn level must not minimize, got %d calls", got) } } func TestOnTaskNeverMinimizes(t *testing.T) { c, _ := newTestController(t) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}) g := &fakeGuard{} c.SetGuard(g) startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) c.RecordWindow(obs("code", "main.go")) // local on-task match time.Sleep(50 * time.Millisecond) if got := atomic.LoadInt32(&g.calls); got != 0 { t.Fatalf("on-task must not minimize, got %d calls", got) } } func TestBlockWithoutGuardDoesNotPanic(t *testing.T) { c, _ := newTestController(t) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) // No guard wired. startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) c.RecordWindow(obs("firefox", "YouTube")) waitDriftStatus(t, c, "drifting") // must reach drifting without panicking } 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, domain.EnforcementWarn); 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 TestNudgeClearedOnLeavingAllowedApp(t *testing.T) { c, _ := newTestController(t) c.SetNudge(&fakeNudger{msg: "drifting within editor"}) startActive(t, c, []string{"code"}) c.RecordWindow(obs("code", "a.go")) c.RecordWindow(obs("code", "b.go")) waitNudge(t, c, "drifting within editor") // Leaving the allowed app ends the on-task stretch and voids the advisory. c.RecordWindow(obs("firefox", "YouTube")) if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" { t.Fatalf("advisory must clear when leaving the allowed app: %+v", st.Drift) } } func TestNudgeInFlightDiscardedAfterDriftEpisode(t *testing.T) { c, _ := newTestController(t) now := time.Now() c.SetClock(func() time.Time { return now }) fn := &fakeNudger{msg: "stale", gate: make(chan struct{})} c.SetNudge(fn) 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) } c.RecordWindow(obs("firefox", "YouTube")) // ends the on-task stretch -> epoch++ close(fn.gate) // in-flight nudge returns now c.RecordWindow(obs("code", "c.go")) // back on task (debounced; no new nudge) time.Sleep(50 * time.Millisecond) if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" { t.Fatalf("in-flight nudge from an ended stretch must not surface: %+v", st.Drift) } } 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]) } } type fakeProvider struct { list []tasks.Task err error gate chan struct{} // if non-nil, Today blocks until it receives } func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) { if f.gate != nil { <-f.gate } return f.list, f.err } // waitTasksStatus polls until the tasks view reaches want, or fails after 2s. func waitTasksStatus(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.Tasks != nil && st.Tasks.Status == want { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks) return State{} } func TestEnterPlanningFetchesTasks(t *testing.T) { c, _ := newTestController(t) c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } st := waitTasksStatus(t, c, "ready") if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" { t.Fatalf("tasks list wrong: %+v", st.Tasks) } } func TestTasksFetchError(t *testing.T) { c, _ := newTestController(t) c.SetTasks(&fakeProvider{err: errors.New("am down")}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } st := waitTasksStatus(t, c, "error") if len(st.Tasks.Tasks) != 0 { t.Fatalf("error state should carry no tasks: %+v", st.Tasks) } } func TestNoProviderNoTasksView(t *testing.T) { c, _ := newTestController(t) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } if c.State().Tasks != nil { t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks) } } func TestTasksViewAbsentOutsidePlanning(t *testing.T) { c, _ := newTestController(t) c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}}) if c.State().Tasks != nil { t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks) } } // TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the // "left planning" arm of the discard guard in startKnowledgeFetchLocked: // a slow knowledge load that returns after the user has left planning must // not clobber the fresh state produced by a later planning entry. // // Sequence: // 1. Enter planning with a gated source (load in flight, blocked on the gate). // 2. StartManualCommitment moves Planning -> Active, leaving planning. // 3. Complete + End the session to return to Locked. // 4. Install a second (ungated) source and re-enter planning. // 5. Wait for the fresh load to reach "ready" with the new source's profile. // 6. Release the gate — the stale goroutine from step 1 now runs and must be // discarded (knowledgeGen has moved on / runtimeState differs at commit). // 7. Assert, through the now-visible Planning-state projection, that the stale // result did not clobber the fresh knowledge view. The "/stale" vs "/fresh" // Path distinguishes the two (both texts are 5 chars, so Chars cannot). func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) { c, _ := newTestController(t) // Step 1: gated source whose result ("/stale") must never surface. staleGate := make(chan struct{}) stale := &fakeSource{ profile: knowledge.Profile{Text: "STALE", Path: "/stale"}, gate: staleGate, } c.SetKnowledge(stale) if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1) t.Fatalf("enter planning: %v", err) } // Step 2: leave planning via a commitment — the stale load is still blocked. if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil { t.Fatalf("start commitment: %v", err) } if c.State().RuntimeState != domain.RuntimeActive { t.Fatalf("expected Active after StartManualCommitment") } // Step 3: end the session to return to Locked. if err := c.Complete(); err != nil { t.Fatalf("complete: %v", err) } if err := c.End(); err != nil { t.Fatalf("end: %v", err) } // Step 4: fresh, ungated source whose result ("/fresh") is the expected state. fresh := &fakeSource{profile: knowledge.Profile{Text: "FRESH", Path: "/fresh"}} c.SetKnowledge(fresh) if err := c.EnterPlanning(); err != nil { t.Fatalf("second enter planning: %v", err) } // Step 5: wait for the fresh load to complete and be visible. st := waitKnowledgeStatus(t, c, "ready") if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") { t.Fatalf("expected FRESH knowledge after second planning entry, got %+v", st.Knowledge) } // Step 6: release the stale goroutine — it must detect that its generation is // stale and discard rather than overwrite the fresh view. close(staleGate) time.Sleep(50 * time.Millisecond) // Step 7: the fresh view must still be intact; "/stale" must not have landed. st = c.State() if st.Knowledge == nil { t.Fatalf("knowledge view should still be present in planning") } if st.Knowledge.Status != "ready" { t.Fatalf("knowledge status should still be ready, got %q", st.Knowledge.Status) } if st.Knowledge.Path != "/fresh" || st.Knowledge.Chars != len("FRESH") { t.Fatalf("stale knowledge fetch clobbered the fresh view: %+v", st.Knowledge) } } // TestStaleTasksFetchDiscardedAfterLeavingPlanning exercises the // "left planning" arm of the discard guard in startTasksFetchLocked. // // Sequence: // 1. Enter planning with a gated provider (fetch in flight, blocked). // 2. StartManualCommitment moves Planning -> Active, leaving planning. // 3. End the session to return to Locked. // 4. Install a second (ungated) provider and re-enter planning. // 5. Wait for the fresh fetch to reach "ready" with the new provider's list. // 6. Release the gate — the stale goroutine from step 1 now runs and must be // discarded (runtimeState != Planning at commit time). // 7. Assert the stale result did not clobber the fresh tasks list. func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) { c, _ := newTestController(t) // Step 1: gated provider whose result ("STALE") must never surface. staleGate := make(chan struct{}) stale := &fakeProvider{ list: []tasks.Task{{ID: "s1", Title: "STALE"}}, gate: staleGate, } c.SetTasks(stale) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } // Wait until the goroutine has started and is pending (blocked on gate). waitTasksStatus(t, c, "pending") // Step 2: leave planning via a commitment — the stale fetch is still blocked. if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start commitment: %v", err) } if c.State().RuntimeState != domain.RuntimeActive { t.Fatalf("expected Active after StartManualCommitment") } // Step 3: end the session to return to Locked. if err := c.Complete(); err != nil { t.Fatalf("complete: %v", err) } if err := c.End(); err != nil { t.Fatalf("end: %v", err) } // Step 4: fresh provider whose result ("FRESH") is the expected state. fresh := &fakeProvider{list: []tasks.Task{{ID: "f1", Title: "FRESH"}}} c.SetTasks(fresh) if err := c.EnterPlanning(); err != nil { t.Fatalf("second enter planning: %v", err) } // Step 5: wait for the fresh fetch to complete and be visible. st := waitTasksStatus(t, c, "ready") if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" { t.Fatalf("expected FRESH tasks after second planning entry, got %+v", st.Tasks) } // Step 6: release the stale goroutine — it must detect runtimeState != Planning // (we are now in Planning again, but tasksGen has moved on) and discard. close(staleGate) time.Sleep(50 * time.Millisecond) // Step 7: the fresh list must still be intact; STALE must not have landed. st = c.State() if st.Tasks == nil { t.Fatalf("tasks view should still be present in planning") } if st.Tasks.Status != "ready" { t.Fatalf("tasks status should still be ready, got %q", st.Tasks.Status) } if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "FRESH" { t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks) } } type fakeSource struct { profile knowledge.Profile err error gate chan struct{} // if non-nil, Load blocks until it receives } func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { if f.gate != nil { <-f.gate } return f.profile, f.err } // waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. func waitKnowledgeStatus(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.Knowledge != nil && st.Knowledge.Status == want { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge) return State{} } func TestEnterPlanningLoadsKnowledge(t *testing.T) { c, _ := newTestController(t) c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } st := waitKnowledgeStatus(t, c, "ready") if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") { t.Fatalf("knowledge view wrong: %+v", st.Knowledge) } } func TestKnowledgeAbsentWhenEmptyText(t *testing.T) { c, _ := newTestController(t) c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } st := waitKnowledgeStatus(t, c, "absent") if st.Knowledge.Path != "/p/missing.md" { t.Fatalf("absent view should still carry the path: %+v", st.Knowledge) } } func TestKnowledgeLoadError(t *testing.T) { c, _ := newTestController(t) c.SetKnowledge(&fakeSource{err: errors.New("permission denied")}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } waitKnowledgeStatus(t, c, "error") } func TestNoSourceNoKnowledgeView(t *testing.T) { c, _ := newTestController(t) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } if c.State().Knowledge != nil { t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge) } } func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) { c, _ := newTestController(t) c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}}) if c.State().Knowledge != nil { t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge) } } func TestCoachReceivesCachedGrounding(t *testing.T) { c, _ := newTestController(t) fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} c.SetCoach(fc) c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}}) if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } waitKnowledgeStatus(t, c, "ready") if err := c.RequestCoach("ship it"); err != nil { t.Fatalf("request coach: %v", err) } // Poll until the coach has run (proposal ready), then assert grounding flowed. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && fc.grounding() == "" { time.Sleep(5 * time.Millisecond) } if fc.grounding() != "I value small diffs." { t.Fatalf("coach grounding = %q, want the cached profile", fc.grounding()) } } type fakeReviewer struct { refl ai.Reflection err error gate chan struct{} // if non-nil, Review blocks until it receives calls int32 } func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) { atomic.AddInt32(&f.calls, 1) if f.gate != nil { <-f.gate } return f.refl, f.err } // waitReflectionStatus polls until the reflection view reaches want, or fails after 2s. func waitReflectionStatus(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.Reflection != nil && st.Reflection.Status == want { return st } time.Sleep(5 * time.Millisecond) } t.Fatalf("reflection status never reached %q (last: %+v)", want, c.State().Reflection) return State{} } func driveToReview(t *testing.T, c *Controller) { t.Helper() if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } if err := c.Complete(); err != nil { t.Fatalf("complete: %v", err) } } func TestReflectionFetchedOnReview(t *testing.T) { c, _ := newTestController(t) rev := &fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}} c.SetReviewer(rev) driveToReview(t, c) st := waitReflectionStatus(t, c, "ready") if st.Reflection.Recap != "held focus" { t.Fatalf("recap not projected on Review: %+v", st.Reflection) } if n := atomic.LoadInt32(&rev.calls); n != 1 { t.Fatalf("reviewer should be called exactly once per session, got %d", n) } } func TestNoReviewerYieldsIdleReflection(t *testing.T) { c, _ := newTestController(t) driveToReview(t, c) st := c.State() if st.Reflection == nil || st.Reflection.Status != "idle" { t.Fatalf("nil reviewer should yield idle reflection, got %+v", st.Reflection) } if err := c.End(); err != nil { t.Fatalf("End must still work with no reviewer: %v", err) } } func TestCarryForwardGroundsNextCoach(t *testing.T) { c, _ := newTestController(t) c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "begin with the hardest test"}}) fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} c.SetCoach(fc) driveToReview(t, c) waitReflectionStatus(t, c, "ready") if err := c.End(); err != nil { t.Fatalf("end: %v", err) } if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } if err := c.RequestCoach("ship it"); err != nil { t.Fatalf("request coach: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && fc.grounding() == "" { time.Sleep(5 * time.Millisecond) } if g := fc.grounding(); !strings.Contains(g, "begin with the hardest test") { t.Fatalf("carry-forward not threaded into coach grounding: %q", g) } } func TestReflectionStaleResultDiscarded(t *testing.T) { c, _ := newTestController(t) slow := &fakeReviewer{refl: ai.Reflection{Recap: "OLD", CarryForward: "old"}, gate: make(chan struct{})} c.SetReviewer(slow) driveToReview(t, c) // gen1 pending, goroutine blocks on gate waitReflectionStatus(t, c, "pending") _ = c.End() // Review -> Locked; gen1 still blocked fast := &fakeReviewer{refl: ai.Reflection{Recap: "NEW", CarryForward: "new"}} c.SetReviewer(fast) driveToReview(t, c) // gen2 -> ready NEW st := waitReflectionStatus(t, c, "ready") if st.Reflection.Recap != "NEW" { t.Fatalf("expected NEW, got %+v", st.Reflection) } close(slow.gate) // release gen1; it must be discarded time.Sleep(50 * time.Millisecond) if got := c.State().Reflection.Recap; got != "NEW" { t.Fatalf("stale gen1 overwrote reflection: %q", got) } } func TestCarryForwardSurvivesRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") first, err := New(path) if err != nil { t.Fatalf("new: %v", err) } first.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "lead with tests"}}) if err := first.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } if err := first.Complete(); err != nil { t.Fatalf("complete: %v", err) } waitReflectionStatus(t, first, "ready") if err := first.End(); err != nil { t.Fatalf("end: %v", err) } second, err := New(path) if err != nil { t.Fatalf("reopen: %v", err) } if err := second.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } st := second.State() if st.Reflection == nil || st.Reflection.CarryForward != "lead with tests" { t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection) } } func TestEnforcementLevelPersists(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "state.json") first, err := New(path) if err != nil { t.Fatal(err) } first.SetClock(func() time.Time { return time.Unix(1000, 0) }) if err := first.EnterPlanning(); err != nil { t.Fatal(err) } if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil { t.Fatal(err) } second, err := New(path) if err != nil { t.Fatal(err) } if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock { t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock) } } func TestCreditLockedSplitsByDriftStatus(t *testing.T) { c, _ := newTestController(t) c.stats = &EvidenceStats{ Buckets: map[bucketKey]time.Duration{}, OnTask: map[bucketKey]time.Duration{}, OffTask: map[bucketKey]time.Duration{}, } k := bucketKey{Class: "code", Title: "main.go"} c.driftStatus = driftOnTask c.creditLocked(k, 10*time.Second) c.driftStatus = driftDrifting c.creditLocked(k, 5*time.Second) c.driftStatus = driftIdle c.creditLocked(k, 3*time.Second) c.driftStatus = driftPending c.creditLocked(k, 2*time.Second) if got := c.stats.OnTask[k]; got != 10*time.Second { t.Errorf("OnTask = %v, want 10s", got) } if got := c.stats.OffTask[k]; got != 5*time.Second { t.Errorf("OffTask = %v, want 5s", got) } if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending t.Errorf("unclassified = %v, want 5s", got) } if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four t.Errorf("Buckets total = %v, want 20s", got) } } func TestReflectionBlockShowsOnOffSplit(t *testing.T) { c, _ := newTestController(t) startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats c.stats.SwitchCount = 2 c.stats.OnTask = map[bucketKey]time.Duration{ {Class: "code", Title: "main.go"}: 20 * time.Minute, } c.stats.OffTask = map[bucketKey]time.Duration{ {Class: "firefox", Title: "YouTube"}: 8 * time.Minute, {Class: "firefox", Title: "Reddit"}: 4 * time.Minute, } c.stats.unclassified = 5 * time.Minute c.outcomePending = "completed" c.mu.Lock() got := c.buildReflectionFinishedLocked() c.mu.Unlock() want := "Next action: write report\n" + "Success condition: report drafted\n" + "Outcome: completed\n" + "On-task 20m / Off-task 12m / Unclassified 5m\n" + "Context switches: 2\n" + "On-task:\n" + "- code · main.go: 20m\n" + "Off-task:\n" + "- firefox · YouTube: 8m\n" + "- firefox · Reddit: 4m" if got != want { t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) } } func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) { c, _ := newTestController(t) startActive(t, c, nil) c.stats.OnTask = map[bucketKey]time.Duration{ {Class: "code", Title: "main.go"}: 20 * time.Minute, } c.stats.OffTask = map[bucketKey]time.Duration{} // none c.stats.unclassified = 0 c.outcomePending = "completed" c.mu.Lock() got := c.buildReflectionFinishedLocked() c.mu.Unlock() if strings.Contains(got, "Off-task:") { t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got) } if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") { t.Errorf("totals line missing or wrong, got:\n%s", got) } }