Skip to content

Commit e3ca08c

Browse files
authored
Fixing overwriting auto loaded facts on refresh (#54)
1 parent 4c32e8d commit e3ca08c

2 files changed

Lines changed: 123 additions & 6 deletions

File tree

internal/server/e2e_test.go

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strings"
2020
"testing"
2121

22+
"github.com/enola-labs/enola/internal/config"
2223
"github.com/enola-labs/enola/pkg/bootstrap"
2324
"github.com/modelcontextprotocol/go-sdk/mcp"
2425
)
@@ -46,14 +47,31 @@ type session struct {
4647
// and returns a connected client session plus a fresh temp copy of go_sample.
4748
func startInMemory(t *testing.T) *session {
4849
t.Helper()
49-
ctx := context.Background()
50+
eng, cfg := newTestEngine(t)
51+
s := connect(t, eng, cfg)
52+
s.repo = copyTree(t, filepath.Join("..", "engine", "testdata", "repos", "go_sample"), t.TempDir())
53+
return s
54+
}
5055

56+
// newTestEngine builds a bootstrap engine with all OSS plugins and a config that
57+
// falls back to defaults (no config file on disk).
58+
func newTestEngine(t *testing.T) (*bootstrap.Engine, *config.Config) {
59+
t.Helper()
5160
eng, cfg, err := bootstrap.NewEngine(bootstrap.Options{
5261
ConfigPath: filepath.Join(t.TempDir(), "no-such-config.yaml"),
5362
})
5463
if err != nil {
5564
t.Fatalf("bootstrap.NewEngine: %v", err)
5665
}
66+
return eng, cfg
67+
}
68+
69+
// connect wires the given engine into an MCP server over an in-memory transport
70+
// and returns a connected client session.
71+
func connect(t *testing.T, eng *bootstrap.Engine, cfg *config.Config) *session {
72+
t.Helper()
73+
ctx := context.Background()
74+
5775
srv, err := bootstrap.NewServer(eng, cfg)
5876
if err != nil {
5977
t.Fatalf("bootstrap.NewServer: %v", err)
@@ -70,8 +88,7 @@ func startInMemory(t *testing.T) *session {
7088
}
7189
t.Cleanup(func() { _ = cs.Close() })
7290

73-
repo := copyTree(t, filepath.Join("..", "engine", "testdata", "repos", "go_sample"), t.TempDir())
74-
return &session{cs: cs, repo: repo}
91+
return &session{cs: cs}
7592
}
7693

7794
// call invokes a tool and fails the test on transport error (a transport error
@@ -269,6 +286,88 @@ func TestE2E_RequiredArgValidation(t *testing.T) {
269286
}
270287
}
271288

289+
// writeSnapshotToDisk indexes repo with a throwaway engine and writes its
290+
// artifacts (including .enola/facts.jsonl) to disk, simulating a workspace that
291+
// already has a prior snapshot on disk for AutoLoadSnapshot to pick up.
292+
func writeSnapshotToDisk(t *testing.T, repo string) {
293+
t.Helper()
294+
eng, _ := newTestEngine(t)
295+
if _, err := eng.GenerateSnapshot(context.Background(), repo, false); err != nil {
296+
t.Fatalf("prep GenerateSnapshot(%s): %v", repo, err)
297+
}
298+
if err := eng.WriteArtifacts(repo); err != nil {
299+
t.Fatalf("prep WriteArtifacts(%s): %v", repo, err)
300+
}
301+
}
302+
303+
// TestE2E_AutoLoadedSnapshotResetOnFreshGenerate is a regression test for the
304+
// bug where a snapshot auto-loaded at startup caused the first
305+
// generate_snapshot(append=false) to silently switch to append mode, carrying
306+
// the auto-loaded repo forward as a stale service node. A non-append call must
307+
// discard the auto-loaded state and index only the requested repo.
308+
func TestE2E_AutoLoadedSnapshotResetOnFreshGenerate(t *testing.T) {
309+
// repoA: a fixture whose snapshot we pre-write to disk so AutoLoadSnapshot
310+
// picks it up at startup. ts_sample gives a distinct repo label from repoB.
311+
repoA := copyTree(t, filepath.Join("..", "engine", "testdata", "repos", "ts_sample"), t.TempDir())
312+
writeSnapshotToDisk(t, repoA)
313+
314+
// Build an engine pointed at repoA and auto-load its snapshot, exactly as the
315+
// server does on startup in a pre-populated workspace.
316+
eng, cfg := newTestEngine(t)
317+
cfg.Repo = repoA
318+
bootstrap.AutoLoadSnapshot(eng, cfg)
319+
if eng.Store().Count() == 0 {
320+
t.Fatalf("expected AutoLoadSnapshot to populate the store from %s", repoA)
321+
}
322+
s := connect(t, eng, cfg)
323+
324+
// First generate_snapshot, for a DIFFERENT repo, with no append. It must reset.
325+
repoB := copyTree(t, filepath.Join("..", "engine", "testdata", "repos", "go_sample"), t.TempDir())
326+
res := s.call(t, "generate_snapshot", map[string]any{"repo_path": repoB})
327+
if res.IsError {
328+
t.Fatalf("generate_snapshot(repoB) errored: %s", text(res))
329+
}
330+
if out := text(res); strings.Contains(out, "Multi-repo mode active") || strings.Contains(out, "auto-enabled") {
331+
t.Errorf("non-append generate_snapshot over auto-loaded state must not enter append mode; got:\n%s", out)
332+
}
333+
334+
// coverage_report must report no service nodes (single-repo) — the stale
335+
// repoA service must be gone.
336+
if cov := text(s.call(t, "coverage_report", map[string]any{})); !strings.Contains(cov, "No service nodes") {
337+
t.Errorf("expected no service nodes after fresh single-repo snapshot; got:\n%s", cov)
338+
}
339+
340+
// repoA's facts must have been discarded entirely.
341+
repoALabel := filepath.Base(repoA)
342+
if q := text(s.call(t, "query_facts", map[string]any{"kind": "service"})); strings.Contains(q, repoALabel) {
343+
t.Errorf("expected repoA (%s) to be discarded, but it still appears as a service node; got:\n%s", repoALabel, q)
344+
}
345+
}
346+
347+
// TestE2E_MultiRepoAppendStillAccumulates guards against the session-flag gate
348+
// over-resetting: a genuine multi-repo flow (first snapshot resets, then
349+
// append=true) must still accumulate both repos as service nodes.
350+
func TestE2E_MultiRepoAppendStillAccumulates(t *testing.T) {
351+
s := startInMemory(t)
352+
s.snapshot(t) // go_sample, first snapshot (no append): resets, marks session
353+
354+
repoB := copyTree(t, filepath.Join("..", "engine", "testdata", "repos", "ts_sample"), t.TempDir())
355+
res := s.call(t, "generate_snapshot", map[string]any{"repo_path": repoB, "append": true})
356+
if res.IsError {
357+
t.Fatalf("append generate_snapshot errored: %s", text(res))
358+
}
359+
if !strings.Contains(text(res), "Multi-repo mode active") {
360+
t.Errorf("append=true should report multi-repo mode; got:\n%s", text(res))
361+
}
362+
363+
cov := text(s.call(t, "coverage_report", map[string]any{}))
364+
for _, label := range []string{"go_sample", "ts_sample"} {
365+
if !strings.Contains(cov, label) {
366+
t.Errorf("coverage_report should list service %q after append; got:\n%s", label, cov)
367+
}
368+
}
369+
}
370+
272371
func keys(m map[string]bool) []string {
273372
out := make([]string, 0, len(m))
274373
for k := range m {

internal/server/server.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ type Server struct {
2828
cfg *config.Config
2929
startTime time.Time
3030
toolCallback func(string)
31+
32+
// snapshotsGenerated records whether generate_snapshot has run at least once
33+
// in this session. It distinguishes a user-driven multi-repo session from a
34+
// store that was merely pre-populated by AutoLoadSnapshot at startup, so the
35+
// auto-append heuristic never fires on top of auto-loaded-only state.
36+
snapshotsGenerated bool
3137
}
3238

3339
// New creates a new MCP server wired to the given engine.
@@ -384,11 +390,14 @@ func (s *Server) registerTools() {
384390
return errorResult(fmt.Sprintf("invalid repo path: %v", err)), nil, nil
385391
}
386392

387-
// Auto-enable append mode when switching to a different repo
388-
// while facts from another repo are already loaded.
393+
// Auto-enable append mode when switching to a different repo while facts
394+
// from another repo are already loaded — but only once this session has
395+
// explicitly generated a snapshot. A store pre-populated solely by
396+
// AutoLoadSnapshot must not trigger append: an explicit/default
397+
// append=false resets and discards the auto-loaded state.
389398
appendMode := args.Append
390399
autoAppended := false
391-
if !appendMode && s.eng.Store().Count() > 0 && s.eng.Snapshot() != nil {
400+
if !appendMode && s.snapshotsGenerated && s.eng.Store().Count() > 0 && s.eng.Snapshot() != nil {
392401
prevRepo := s.eng.Snapshot().Meta.RepoPath
393402
if prevRepo != "" && prevRepo != absRepo {
394403
appendMode = true
@@ -397,10 +406,19 @@ func (s *Server) registerTools() {
397406
}
398407
}
399408

409+
// A fresh (non-append) snapshot that discards an auto-loaded store is
410+
// silent otherwise; log it so the reset is visible.
411+
if !appendMode && !s.snapshotsGenerated && s.eng.Store().Count() > 0 && s.eng.Snapshot() != nil {
412+
if prevRepo := s.eng.Snapshot().Meta.RepoPath; prevRepo != "" && prevRepo != absRepo {
413+
log.Printf("[server] discarding auto-loaded snapshot from %s; generating fresh single-repo snapshot for %s", prevRepo, absRepo)
414+
}
415+
}
416+
400417
snapshot, err := s.eng.GenerateSnapshot(ctx, absRepo, appendMode)
401418
if err != nil {
402419
return errorResult(fmt.Sprintf("snapshot generation failed: %v", err)), nil, nil
403420
}
421+
s.snapshotsGenerated = true
404422

405423
// Write artifacts to disk
406424
if err := s.eng.WriteArtifacts(absRepo); err != nil {

0 commit comments

Comments
 (0)