package web import ( "context" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "keel/internal/ai" "keel/internal/evidence" "keel/internal/harness" "keel/internal/knowledge" "keel/internal/mode" "keel/internal/mode/focus" "keel/internal/mode/focus/domain" "keel/internal/settings" "keel/internal/tasks" "github.com/gin-gonic/gin" ) // newTestServer builds a harness around a freshly-constructed focus mode and // returns the server plus the mode so tests can inject per-role stubs (the old // controller's Set* API lives on focus.Mode) and assert on the typed focus // state. The mode is registered behind a factory that wires the harness change // notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on // every focus change. The mode starts Locked; tests drive it through the // command routes (e.g. POST /mode/command/planning), mirroring the original // suite which POSTed /planning first. func newTestServer(t *testing.T) (*Server, *focus.Mode) { t.Helper() gin.SetMode(gin.TestMode) path := filepath.Join(t.TempDir(), "state.json") m, err := focus.New(path) if err != nil { t.Fatalf("focus.New: %v", err) } h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{ "focus": func(svc harness.Services) mode.Mode { m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener return m }, }) s := NewServer(h) if err := h.Start("focus"); err != nil { t.Fatalf("harness.Start(focus): %v", err) } return s, m } func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for // the real-factory parity test without reaching any model. type fakeBackend struct{} func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil } func (fakeBackend) Name() string { return "fake" } func TestPlanningThenCommitmentReachesActive(t *testing.T) { s, m := newTestServer(t) r := s.Router() if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } if m.State().RuntimeState != domain.RuntimeActive { t.Fatalf("expected Active, got %s", m.State().RuntimeState) } } func TestCommitmentRejectsInvalidInput(t *testing.T) { s, _ := newTestServer(t) r := s.Router() _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` w := post(t, r, "/mode/command/commitment", body) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestIllegalTransitionReturns409(t *testing.T) { s, _ := newTestServer(t) r := s.Router() // /complete from Locked is illegal. w := post(t, r, "/mode/command/complete", "") if w.Code != http.StatusConflict { t.Fatalf("expected 409, got %d", w.Code) } } // TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with // the focus state nested under "mode" and the kind under "active_mode". func TestEnvelopeShape(t *testing.T) { s, _ := newTestServer(t) js := s.stateJSON() if !strings.Contains(js, `"active_mode":"focus"`) { t.Fatalf("payload missing active_mode: %s", js) } if !strings.Contains(js, `"mode"`) { t.Fatalf("payload missing nested mode object: %s", js) } } func TestActiveStatePayloadCarriesEvidence(t *testing.T) { s, m := newTestServer(t) r := s.Router() _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } // A focus update should appear in the serialized state envelope. m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}}) js := s.stateJSON() if !strings.Contains(js, `"evidence"`) { t.Fatalf("payload missing evidence object: %s", js) } if !strings.Contains(js, `"class":"code"`) { t.Fatalf("payload missing current window: %s", js) } } func TestLockedStateHasNullEvidence(t *testing.T) { s, m := newTestServer(t) // End any active session to return to Locked; a freshly-started focus mode is // already Locked (Start does not auto-plan in this harness wiring). if m.State().RuntimeState != domain.RuntimeLocked { t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState) } js := s.stateJSON() if !strings.Contains(js, `"evidence":null`) { t.Fatalf("locked payload should have null evidence: %s", js) } } type stubCoach struct { prop ai.Proposal err error } func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { return s.prop, s.err } func TestCoachRouteHappyPath(t *testing.T) { s, m := newTestServer(t) m.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}}) r := s.Router() _ = post(t, r, "/mode/command/planning", "") if w := post(t, r, "/mode/command/coach", `{"intent":"do something"}`); w.Code != http.StatusOK { t.Fatalf("/coach code %d body %s", w.Code, w.Body.String()) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if cv := m.State().Coach; cv != nil && cv.Status == "ready" { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("coach never reached ready: %+v", m.State().Coach) } func TestCoachRouteOutsidePlanning(t *testing.T) { s, m := newTestServer(t) m.SetCoach(stubCoach{}) r := s.Router() if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteInvalidJSON(t *testing.T) { s, _ := newTestServer(t) r := s.Router() _ = post(t, r, "/mode/command/planning", "") if w := post(t, r, "/mode/command/coach", `not json`); w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCoachRouteUnavailableDegrades(t *testing.T) { s, m := newTestServer(t) // no SetCoach r := s.Router() _ = post(t, r, "/mode/command/planning", "") if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusOK { t.Fatalf("unavailable coach should still 200, got %d", w.Code) } if cv := m.State().Coach; cv == nil || cv.Status != "error" { t.Fatalf("want error status, got %+v", cv) } } func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) { s, _ := newTestServer(t) r := s.Router() if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest { t.Fatalf("refocus outside Active: want 400, got %d", w.Code) } if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest { t.Fatalf("ontask outside Active: want 400, got %d", w.Code) } } type stubNudger struct{ msg string } func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) { return s.msg, nil } func TestActiveStatePayloadCarriesNudge(t *testing.T) { s, m := newTestServer(t) m.SetNudge(stubNudger{msg: "drifted to unrelated work"}) r := s.Router() _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } // Two distinct on-task titles trip the nudge (history >= 2; first call has no // debounce to wait on). m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}}) m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if d := m.State().Drift; d != nil && d.Nudge != "" { break } time.Sleep(5 * time.Millisecond) } js := s.stateJSON() if !strings.Contains(js, `"nudge":"drifted to unrelated work"`) { t.Fatalf("payload missing nudge: %s", js) } } func TestServesStaticAssets(t *testing.T) { s, _ := newTestServer(t) r := s.Router() cases := []struct { path string ctSubstr string }{ {"/app.css", "css"}, {"/app.js", "javascript"}, {"/favicon.ico", "image"}, {"/favicon.png", "image"}, } for _, tc := range cases { req := httptest.NewRequest(http.MethodGet, tc.path, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("GET %s code = %d, want 200", tc.path, w.Code) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, tc.ctSubstr) { t.Errorf("GET %s Content-Type = %q, want substring %q", tc.path, ct, tc.ctSubstr) } if w.Body.Len() == 0 { t.Errorf("GET %s body is empty", tc.path) } } } type stubProvider struct { list []tasks.Task } func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) { return s.list, nil } func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil } func TestPlanningStatePayloadCarriesTasks(t *testing.T) { s, m := newTestServer(t) m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) r := s.Router() if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if tv := m.State().Tasks; tv != nil && tv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) } js := s.stateJSON() if !strings.Contains(js, `"tasks"`) { t.Fatalf("planning payload missing tasks object: %s", js) } if !strings.Contains(js, `"title":"Write the spec"`) { t.Fatalf("planning payload missing task title: %s", js) } } func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) { s, m := newTestServer(t) r := s.Router() _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String()) } if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" { t.Fatalf("commitment did not carry allowed classes: %#v", got) } // Now Active: overrides succeed. if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK { t.Fatalf("ontask while Active: want 200, got %d", w.Code) } if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK { t.Fatalf("refocus while Active: want 200, got %d", w.Code) } } type stubSource struct { profile knowledge.Profile } func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { if path != "" { return knowledge.Profile{Text: "from " + path, Path: path}, nil } return s.profile, nil } func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { s, m := newTestServer(t) m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}}) r := s.Router() if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) } js := s.stateJSON() if !strings.Contains(js, `"knowledge"`) || !strings.Contains(js, `"status":"ready"`) { t.Fatalf("planning payload missing knowledge object: %s", js) } if strings.Contains(js, "small diffs") { t.Fatalf("profile text must NOT cross the wire: %s", js) } } type stubReviewer struct { refl ai.Reflection } func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) { return s.refl, nil } func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { s, m := newTestServer(t) m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) r := s.Router() _ = post(t, r, "/mode/command/planning", "") body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK { t.Fatalf("/complete code %d", w.Code) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if rv := m.State().Reflection; rv != nil && rv.Status == "ready" { break } time.Sleep(5 * time.Millisecond) } js := s.stateJSON() if !strings.Contains(js, `"recap":"held focus well"`) { t.Fatalf("review payload missing recap: %s", js) } // End -> Locked: the harness releases the focus mode and goes idle. Re-start // the mode (re-adopting the same in-memory focus, carry-forward intact) and // re-enter Planning; the carry-forward should surface there. if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK { t.Fatalf("/end code %d", w.Code) } if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK { t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String()) } if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } js2 := s.stateJSON() if !strings.Contains(js2, `"carry_forward":"start in the editor"`) { t.Fatalf("planning payload missing carry-forward: %s", js2) } } func TestStateJSONIncludesAmbient(t *testing.T) { h := harness.New(harness.Services{}, map[string]harness.Factory{}) s := NewServer(h) s.SetAmbient(func() string { return "deep in YouTube" }, func(time.Duration) {}) js := s.stateJSON() if !strings.Contains(js, `"ambient":"deep in YouTube"`) { t.Fatalf("state JSON missing ambient field: %s", js) } } func TestAmbientSnoozeRoute(t *testing.T) { h := harness.New(harness.Services{}, map[string]harness.Factory{}) s := NewServer(h) var snoozed time.Duration s.SetAmbient(func() string { return "" }, func(d time.Duration) { snoozed = d }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/ambient/snooze", nil) s.Router().ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Fatalf("snooze status = %d, want 204", w.Code) } if snoozed != time.Hour { t.Fatalf("snooze duration = %v, want 1h", snoozed) } } type stubJudge struct{ verdict ai.Verdict } func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) { return j.verdict, nil } func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { s, m := newTestServer(t) r := s.Router() m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}}) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if strings.Contains(s.stateJSON(), `"enforced":true`) { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("state never reported enforced:true; last: %s", s.stateJSON()) } func TestKnowledgePathSelectionReloads(t *testing.T) { s, m := newTestServer(t) m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(ss settings.Settings) error { m.SetKnowledgePath(ss.KnowledgePath) return nil }) r := s.Router() if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK { t.Fatalf("/planning code %d", w.Code) } if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK { t.Fatalf("/settings code %d body %s", w.Code, w.Body.String()) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("path selection did not reload: %+v", m.State().Knowledge) } // TestRealFocusFactoryParity drives the REAL focus.Factory through the harness // (Start -> commitment -> complete -> end), proving the generalized routes work // end-to-end against a focus mode the harness itself constructs from Services — // not a pre-built mode injected by the test. focus.Factory auto-enters Planning // on Start, so no explicit planning command is needed. func TestRealFocusFactoryParity(t *testing.T) { gin.SetMode(gin.TestMode) svc := harness.Services{ AI: ai.NewService(fakeBackend{}), Clock: time.Now, Dir: t.TempDir(), } h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory}) s := NewServer(h) r := s.Router() if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK { t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String()) } // Start auto-plans; the envelope should report focus active and Planning. js := s.stateJSON() if !strings.Contains(js, `"active_mode":"focus"`) { t.Fatalf("envelope missing active_mode focus: %s", js) } if !strings.Contains(js, `"runtime_state":"planning"`) { t.Fatalf("expected planning after start: %s", js) } body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK { t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) } if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) { t.Fatalf("expected active after commitment: %s", s.stateJSON()) } if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK { t.Fatalf("/complete code %d", w.Code) } if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) { t.Fatalf("expected review after complete: %s", s.stateJSON()) } if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK { t.Fatalf("/end code %d", w.Code) } // End drives focus to Locked; the harness releases it and goes idle. if got := h.State().ActiveMode; got != "" { t.Fatalf("after end ActiveMode = %q, want idle", got) } }