Orchestrate semantic nudge on the on-task path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package session
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -549,3 +550,208 @@ func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||
t.Fatal("coach view must be gone once Active")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNudger is a controllable ai.Nudger for tests. An optional gate channel
|
||||
// blocks the call until closed, mirroring fakeJudge.
|
||||
type fakeNudger struct {
|
||||
msg string
|
||||
err error
|
||||
gate chan struct{}
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (f *fakeNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
|
||||
atomic.AddInt32(&f.calls, 1)
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
return f.msg, f.err
|
||||
}
|
||||
|
||||
// nudgerFunc adapts a function to ai.Nudger for tests needing per-call results.
|
||||
type nudgerFunc func(context.Context, string, []string) (string, error)
|
||||
|
||||
func (f nudgerFunc) Nudge(ctx context.Context, c string, t []string) (string, error) {
|
||||
return f(ctx, c, t)
|
||||
}
|
||||
|
||||
func waitNudge(t *testing.T, c *Controller, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
st := c.State()
|
||||
if st.Drift != nil && st.Drift.Nudge == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("nudge never reached %q (last: %+v)", want, c.State().Drift)
|
||||
}
|
||||
|
||||
func TestNudgeFiresOnOnTaskPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "drifted to an unrelated file"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "auth.go")) // history 1 -> not yet eligible
|
||||
c.RecordWindow(obs("code", "styles.css")) // history 2 -> eligible, launches
|
||||
waitNudge(t, c, "drifted to an unrelated file")
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("expected exactly one nudge call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "should not run"}
|
||||
c.SetNudge(fn)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
c.RecordWindow(obs("firefox", "Reddit"))
|
||||
waitDriftStatus(t, c, "drifting")
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 0 {
|
||||
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeDebounced(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
fn := &fakeNudger{msg: "drift"}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // fires nudge #1
|
||||
waitNudge(t, c, "drift")
|
||||
c.RecordWindow(obs("code", "c.go")) // within debounce window -> suppressed
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 1 {
|
||||
t.Fatalf("debounce should allow one nudge, calls=%d", got)
|
||||
}
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "d.go")) // debounce elapsed -> nudge #2
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) < 2 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if got := atomic.LoadInt32(&fn.calls); got != 2 {
|
||||
t.Fatalf("expected second nudge after debounce, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeErrorDoesNotFabricate(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{err: errors.New("backend down")}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if st := c.State(); st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("error must not set a nudge message: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeClearsWhenBackOnTrack(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
now := time.Now()
|
||||
c.SetClock(func() time.Time { return now })
|
||||
msgs := make(chan string, 2)
|
||||
msgs <- "drifted"
|
||||
msgs <- "" // back on track
|
||||
c.SetNudge(nudgerFunc(func(context.Context, string, []string) (string, error) {
|
||||
return <-msgs, nil
|
||||
}))
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
waitNudge(t, c, "drifted")
|
||||
now = now.Add(2 * nudgeDebounce)
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
waitNudge(t, c, "") // an on-track result clears the prior advisory
|
||||
}
|
||||
|
||||
func TestNilNudgerStaysSilentOnTask(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.Drift == nil || st.Drift.Status != "ontask" {
|
||||
t.Fatalf("expected ontask, got %+v", st.Drift)
|
||||
}
|
||||
if st.Drift.Nudge != "" {
|
||||
t.Fatalf("nil nudger must stay silent, got %q", st.Drift.Nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeSurvivesInterleavedDriftJudgment(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "kept", gate: make(chan struct{})}
|
||||
c.SetNudge(fn)
|
||||
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // nudge launches, blocks on gate
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
// An interleaved drift judgment bumps driftGen; the nudge must NOT be
|
||||
// discarded by it — it is guarded by session identity, not driftGen.
|
||||
c.RecordWindow(obs("firefox", "YouTube"))
|
||||
close(fn.gate) // nudge returns within the same session
|
||||
waitNudge(t, c, "kept")
|
||||
}
|
||||
|
||||
func TestStaleNudgeDiscardedAfterEnd(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
fn := &fakeNudger{msg: "late", gate: make(chan struct{})}
|
||||
c.SetNudge(fn)
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "a.go"))
|
||||
c.RecordWindow(obs("code", "b.go")) // launches nudge, blocks on gate
|
||||
deadline := time.Now().Add(1 * time.Second)
|
||||
for time.Now().Before(deadline) && atomic.LoadInt32(&fn.calls) == 0 {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
_ = c.Complete()
|
||||
_ = c.End()
|
||||
// Start a fresh session; the previous session's in-flight nudge must not
|
||||
// leak into it.
|
||||
c.SetNudge(nil) // the new session launches no nudges of its own
|
||||
startActive(t, c, []string{"code"})
|
||||
c.RecordWindow(obs("code", "c.go"))
|
||||
close(fn.gate) // release the stale goroutine from the first session
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
st := c.State()
|
||||
if st.RuntimeState != domain.RuntimeActive {
|
||||
t.Fatalf("expected Active second session, got %s", st.RuntimeState)
|
||||
}
|
||||
if st.Drift == nil || st.Drift.Nudge != "" {
|
||||
t.Fatalf("stale nudge must not leak into the new session: %+v", st.Drift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentTitlesRingCapsAtTen(t *testing.T) {
|
||||
c, _ := newTestController(t)
|
||||
startActive(t, c, []string{"code"})
|
||||
for i := 0; i < 15; i++ {
|
||||
c.RecordWindow(obs("code", fmt.Sprintf("file%d.go", i)))
|
||||
}
|
||||
titles := c.recentTitlesForTest()
|
||||
if len(titles) != 10 {
|
||||
t.Fatalf("ring should cap at 10, got %d", len(titles))
|
||||
}
|
||||
if titles[0] != "file5.go" {
|
||||
t.Fatalf("ring should drop oldest first, got first=%q", titles[0])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user