Load the knowledge profile on planning and ground the coach
The controller loads the profile asynchronously on entering planning, mirroring the tasks fetch: generation-guarded goroutine, status enum (idle/pending/ready/absent/error), projected into State only while planning and only when a source is set. The loaded text is cached and passed to the coach as grounding; SetKnowledgePath repoints the file at runtime. nil source degrades to no view and an ungrounded coach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"antidrift/internal/ai"
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/knowledge"
|
||||
"antidrift/internal/store"
|
||||
"antidrift/internal/tasks"
|
||||
)
|
||||
@@ -246,15 +248,27 @@ 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 string) (ai.Proposal, error) {
|
||||
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()
|
||||
@@ -918,3 +932,100 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) {
|
||||
t.Fatalf("stale fetch result clobbered the fresh tasks: %+v", st.Tasks)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSource struct {
|
||||
profile knowledge.Profile
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user