2977b903c2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
12 KiB
Go
402 lines
12 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"antidrift/internal/ai"
|
|
"antidrift/internal/domain"
|
|
"antidrift/internal/evidence"
|
|
"antidrift/internal/store"
|
|
)
|
|
|
|
func newTestController(t *testing.T) (*Controller, string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
c, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("new controller: %v", err)
|
|
}
|
|
return c, path
|
|
}
|
|
|
|
func TestHappyPathDrivesStates(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
t.Fatalf("should start Locked")
|
|
}
|
|
if err := c.EnterPlanning(); err != nil {
|
|
t.Fatalf("enter planning: %v", err)
|
|
}
|
|
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
|
|
t.Fatalf("start commitment: %v", err)
|
|
}
|
|
st := c.State()
|
|
if st.RuntimeState != domain.RuntimeActive {
|
|
t.Fatalf("should be Active, got %s", st.RuntimeState)
|
|
}
|
|
if st.Commitment == nil || st.Commitment.NextAction != "Port session" {
|
|
t.Fatalf("active commitment missing: %+v", st.Commitment)
|
|
}
|
|
if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() {
|
|
t.Fatalf("deadline should be in the future")
|
|
}
|
|
if err := c.Complete(); err != nil {
|
|
t.Fatalf("complete: %v", err)
|
|
}
|
|
if c.State().RuntimeState != domain.RuntimeReview {
|
|
t.Fatalf("should be Review after complete")
|
|
}
|
|
if err := c.End(); err != nil {
|
|
t.Fatalf("end: %v", err)
|
|
}
|
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
|
t.Fatalf("should be Locked after end")
|
|
}
|
|
}
|
|
|
|
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
|
|
t.Fatalf("empty next action should error")
|
|
}
|
|
if c.State().RuntimeState != domain.RuntimePlanning {
|
|
t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState)
|
|
}
|
|
}
|
|
|
|
func TestStateRestoresFromSnapshot(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
first, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
_ = first.EnterPlanning()
|
|
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
|
|
|
|
second, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
st := second.State()
|
|
if st.RuntimeState != domain.RuntimeActive {
|
|
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
|
}
|
|
if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" {
|
|
t.Fatalf("restored commitment missing: %+v", st.Commitment)
|
|
}
|
|
}
|
|
|
|
func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
first, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
_ = first.EnterPlanning()
|
|
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
|
|
want := first.State().Commitment.DeadlineUnixSecs
|
|
|
|
second, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
got := second.State().Commitment
|
|
if got == nil || got.DeadlineUnixSecs != want {
|
|
t.Fatalf("restored deadline: got %+v want %d", got, want)
|
|
}
|
|
}
|
|
|
|
// fakeClock returns successive instants on demand.
|
|
type fakeClock struct{ now time.Time }
|
|
|
|
func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) }
|
|
func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } }
|
|
|
|
func snap(class, title string) evidence.WindowSnapshot {
|
|
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
|
}
|
|
|
|
func bucketSeconds(t *testing.T, st State, class, title string) int64 {
|
|
t.Helper()
|
|
if st.Evidence == nil {
|
|
t.Fatalf("expected evidence projection")
|
|
}
|
|
for _, b := range st.Evidence.Buckets {
|
|
if b.Class == class && b.Title == title {
|
|
return b.Seconds
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
c.SetClock(clk.fn())
|
|
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
|
|
|
_ = c.EnterPlanning()
|
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
|
|
|
|
clk.advance(10 * time.Second)
|
|
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
|
clk.advance(20 * time.Second)
|
|
c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back
|
|
|
|
st := c.State()
|
|
if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 {
|
|
t.Errorf("code bucket = %d, want 10", got)
|
|
}
|
|
if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 {
|
|
t.Errorf("firefox bucket = %d, want 20", got)
|
|
}
|
|
if st.Evidence.SwitchCount != 2 {
|
|
t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount)
|
|
}
|
|
}
|
|
|
|
func TestNoAccountingOutsideActive(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
c.SetClock(clk.fn())
|
|
// Locked: RecordWindow must not create stats.
|
|
c.RecordWindow(snap("code", "x"))
|
|
if c.State().Evidence != nil {
|
|
t.Fatalf("no session: evidence should be nil")
|
|
}
|
|
}
|
|
|
|
func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
c.SetClock(clk.fn())
|
|
_ = c.EnterPlanning()
|
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
|
|
// latestWindow was zero-value (unavailable) at seed time.
|
|
clk.advance(5 * time.Second)
|
|
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
|
st := c.State()
|
|
if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 {
|
|
t.Errorf("unavailable bucket = %d, want 5", got)
|
|
}
|
|
}
|
|
|
|
func TestCrashReplayRebuildsStats(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
first, _ := New(path)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
first.SetClock(clk.fn())
|
|
first.RecordWindow(snap("code", "antidrift"))
|
|
_ = first.EnterPlanning()
|
|
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
|
|
clk.advance(15 * time.Second)
|
|
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
|
|
|
// Simulate crash + restart: New replays the raw log.
|
|
second, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
st := second.State()
|
|
if st.RuntimeState != domain.RuntimeActive {
|
|
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
|
}
|
|
if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 {
|
|
t.Errorf("replayed code bucket = %d, want 15", got)
|
|
}
|
|
if st.Evidence.SwitchCount != 1 {
|
|
t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount)
|
|
}
|
|
}
|
|
|
|
func TestEndWritesAuditSummary(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "state.json")
|
|
c, _ := New(path)
|
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
|
c.SetClock(clk.fn())
|
|
c.RecordWindow(snap("code", "antidrift"))
|
|
_ = c.EnterPlanning()
|
|
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
|
|
clk.advance(30 * time.Second)
|
|
c.RecordWindow(snap("firefox", "docs"))
|
|
clk.advance(10 * time.Second)
|
|
if err := c.Complete(); err != nil { // flush: +10s to firefox/docs
|
|
t.Fatalf("complete: %v", err)
|
|
}
|
|
if err := c.End(); err != nil {
|
|
t.Fatalf("end: %v", err)
|
|
}
|
|
|
|
auditPath := filepath.Join(dir, "audit.jsonl")
|
|
if err := store.VerifyChain(auditPath); err != nil {
|
|
t.Fatalf("chain should verify: %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeCoach struct {
|
|
prop ai.Proposal
|
|
err error
|
|
gate chan struct{} // if non-nil, Coach blocks until it receives
|
|
}
|
|
|
|
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
|
if f.gate != nil {
|
|
<-f.gate
|
|
}
|
|
return f.prop, f.err
|
|
}
|
|
|
|
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
|
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
st := c.State()
|
|
if st.Coach != nil && st.Coach.Status == want {
|
|
return st
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
|
|
return State{}
|
|
}
|
|
|
|
func TestRequestCoachReady(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
|
|
if err := c.RequestCoach("do x"); err != nil {
|
|
t.Fatalf("request coach: %v", err)
|
|
}
|
|
st := waitCoachStatus(t, c, "ready")
|
|
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
|
|
t.Fatalf("ready proposal wrong: %+v", st.Coach)
|
|
}
|
|
}
|
|
|
|
func TestRequestCoachError(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
|
|
if err := c.RequestCoach("x"); err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
st := waitCoachStatus(t, c, "error")
|
|
if st.Coach.Error == "" {
|
|
t.Fatal("error status should carry a message")
|
|
}
|
|
}
|
|
|
|
func TestRequestCoachUnavailable(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
if err := c.RequestCoach("x"); err != nil {
|
|
t.Fatalf("nil coach must degrade, not error: %v", err)
|
|
}
|
|
st := c.State()
|
|
if st.Coach == nil || st.Coach.Status != "error" {
|
|
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
|
|
}
|
|
}
|
|
|
|
func TestRequestCoachWrongState(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
|
if err := c.RequestCoach("x"); err == nil {
|
|
t.Fatal("coaching from Locked should error")
|
|
}
|
|
if c.State().Coach != nil {
|
|
t.Fatal("no coach view outside planning")
|
|
}
|
|
}
|
|
|
|
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
|
|
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
|
|
c.SetCoach(slow)
|
|
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
|
|
|
|
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
|
|
c.SetCoach(fast)
|
|
_ = c.RequestCoach("second") // gen2 -> ready NEW
|
|
st := waitCoachStatus(t, c, "ready")
|
|
if st.Coach.Proposal.NextAction != "NEW" {
|
|
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
|
|
}
|
|
|
|
close(slow.gate) // release gen1; it must be discarded
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
|
|
t.Fatalf("stale gen1 overwrote state: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestOnTaskRequiresActive(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
|
|
t.Fatalf("OnTask outside Active should error, got %v", err)
|
|
}
|
|
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
|
|
t.Fatalf("Refocus outside Active should error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
first, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
_ = first.EnterPlanning()
|
|
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
second, err := New(path)
|
|
if err != nil {
|
|
t.Fatalf("reload: %v", err)
|
|
}
|
|
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
|
t.Fatalf("allowed classes not restored: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestOnTaskAppendsCurrentClass(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
|
|
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
|
|
if err := c.OnTask(); err != nil {
|
|
t.Fatalf("ontask: %v", err)
|
|
}
|
|
got := c.AllowedClassesForTest()
|
|
if len(got) != 1 || got[0] != "slack" {
|
|
t.Fatalf("OnTask should append current class, got %#v", got)
|
|
}
|
|
st := c.State()
|
|
if st.Drift == nil || st.Drift.Status != "ontask" {
|
|
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
|
|
}
|
|
}
|
|
|
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
|
c, _ := newTestController(t)
|
|
_ = c.EnterPlanning()
|
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
|
_ = c.RequestCoach("x")
|
|
waitCoachStatus(t, c, "ready")
|
|
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
if c.State().Coach != nil {
|
|
t.Fatal("coach view must be gone once Active")
|
|
}
|
|
}
|