7d69a1f320
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1585 lines
52 KiB
Go
1585 lines
52 KiB
Go
package focus
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"keel/internal/ai"
|
|
"keel/internal/evidence"
|
|
"keel/internal/knowledge"
|
|
"keel/internal/mode/focus/domain"
|
|
"keel/internal/store"
|
|
"keel/internal/tasks"
|
|
)
|
|
|
|
func newTestController(t *testing.T) (*Mode, 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 *Mode, 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 TestPlanningStateExposesCurrentWindowClass(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
|
if err := c.EnterPlanning(); err != nil {
|
|
t.Fatalf("planning: %v", err)
|
|
}
|
|
st := c.State()
|
|
if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" {
|
|
t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence)
|
|
}
|
|
}
|
|
|
|
func TestAddOnChangeFansOutToAllListeners(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
var a, b int
|
|
c.SetOnChange(func() { a++ })
|
|
c.AddOnChange(func() { b++ })
|
|
c.notify()
|
|
if a != 1 || b != 1 {
|
|
t.Fatalf("every registered listener should fire: a=%d b=%d", a, b)
|
|
}
|
|
}
|
|
|
|
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 *Mode, 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 *Mode, allowed []string) {
|
|
t.Helper()
|
|
startActiveLevel(t, c, allowed, domain.EnforcementWarn)
|
|
}
|
|
|
|
func startActiveLevel(t *testing.T, c *Mode, 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)
|
|
}
|
|
}
|
|
|
|
// titleJudge returns a verdict chosen by which substring the title contains,
|
|
// so a test can model an app (e.g. a browser) hosting both on- and off-task
|
|
// windows under one window class.
|
|
type titleJudge struct {
|
|
byTitle map[string]ai.Verdict
|
|
calls int32
|
|
}
|
|
|
|
func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
|
|
atomic.AddInt32(&j.calls, 1)
|
|
for sub, v := range j.byTitle {
|
|
if strings.Contains(title, sub) {
|
|
return v, nil
|
|
}
|
|
}
|
|
return ai.Verdict{OnTask: true}, nil
|
|
}
|
|
|
|
// A brief off-task window must not poison every later same-class window. Teams
|
|
// and a Consume reading page are both class "Brave-browser"; caching the judge
|
|
// verdict by class alone made the on-task page inherit Teams's drift verdict
|
|
// (and never re-judge), latching drift for the rest of the session.
|
|
func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
now := time.Now()
|
|
c.SetClock(func() time.Time { return now })
|
|
fj := &titleJudge{byTitle: map[string]ai.Verdict{
|
|
"Teams": {OnTask: false, Reason: "Teams is off-task"},
|
|
"Consume": {OnTask: true},
|
|
}}
|
|
c.SetDriftJudge(fj)
|
|
startActive(t, c, []string{"code"}) // Brave-browser never matches locally
|
|
|
|
c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave"))
|
|
waitDriftStatus(t, c, "drifting")
|
|
|
|
now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed
|
|
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
|
|
st := waitDriftStatus(t, c, "ontask")
|
|
if st.Drift.Reason != "" {
|
|
t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift)
|
|
}
|
|
if got := atomic.LoadInt32(&fj.calls); got != 2 {
|
|
t.Fatalf("different title under same class should re-judge, calls=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestPerWindowCacheAvoidsSecondJudge(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-window cache as the only reason the judge is skipped.
|
|
now = now.Add(2 * driftDebounce)
|
|
c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match)
|
|
c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := atomic.LoadInt32(&fj.calls); got != 1 {
|
|
t.Fatalf("cached window 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 judgedWindows 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 *Mode, 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"))
|
|
waitDriftStatus(t, c, "drifting")
|
|
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
|
|
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
|
|
}
|
|
|
|
func (f *fakeProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
|
|
|
|
// waitTasksStatus polls until the tasks view reaches want, or fails after 2s.
|
|
func waitTasksStatus(t *testing.T, c *Mode, 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 *Mode, 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 *Mode, 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 *Mode) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
c.SetClock(clk.fn())
|
|
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}})
|
|
|
|
c.RecordWindow(snap("code", "main.go")) // latest window before start
|
|
startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle
|
|
|
|
clk.advance(5 * time.Minute)
|
|
c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match)
|
|
clk.advance(10 * time.Minute)
|
|
c.RecordWindow(snap("code", "main.go")) // credits 10m on-task
|
|
clk.advance(10 * time.Minute)
|
|
c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending)
|
|
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
|
|
|
|
clk.advance(8 * time.Minute)
|
|
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task
|
|
waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited
|
|
clk.advance(4 * time.Minute)
|
|
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
|
|
t.Fatalf("complete: %v", err)
|
|
}
|
|
|
|
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("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
|
}
|
|
}
|