package repl // Isolation / zero-pollution tests for /btw (#284, PRD Success Metrics). These // lock the feature's most important correctness guarantee: a side thread's // question and answer NEVER touch the main conversation and NEVER hit disk. The // tests drive the whole runREPL loop with the fake replProvider and assert, in // one place, every observable that a leak would perturb: the main context's // message count AND content, the session store on disk, and the persistence // bookkeeping (deps.persisted / deps.curLeaf / deps.header.UpdatedAt). import ( "bytes" "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" ) // snapshotMessages returns the flattened text of every message so a test can // assert the main context is byte-for-byte unchanged, not merely the same // length (a leak could replace a message without changing the count). Content // is a field on the concrete message types, not on the Message interface, so we // type-switch on the two kinds /btw could ever append. func snapshotMessages(msgs agentcore.MessageList) []string { out := make([]string, len(msgs)) for i, m := range msgs { var text string switch v := m.(type) { case agentcore.UserMessage: text = agentcore.ContentToText(v.Content) case agentcore.AssistantMessage: text = agentcore.ContentToText(v.Content) } out[i] = m.Role() + ":" + text } return out } // seedMainContext appends a real user+assistant exchange to the main context so // the isolation tests start from a non-empty conversation — proving /btw leaves // existing history untouched, not just that it avoids growing an empty slice. func seedMainContext(deps *replDeps) { deps.agentCtx.Messages = append(deps.agentCtx.Messages, agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("main question")}}, agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("main answer")}}, ) } // TestBtwMainContextZeroGrowth verifies one /btw Q&A leaves the main context's // message count AND content exactly as before (AC-1). func TestBtwMainContextZeroGrowth(t *testing.T) { p := &replProvider{reply: "side answer"} deps, _ := newTestDeps(t, p) seedMainContext(&deps) before := snapshotMessages(deps.agentCtx.Messages) var out bytes.Buffer if err := runREPL(strings.NewReader("/btw a quick question?\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 1 { t.Fatalf("expected exactly 1 side run, got %d", p.calls) } after := snapshotMessages(deps.agentCtx.Messages) if len(after) != len(before) { t.Fatalf("main context grew: %d → %d messages", len(before), len(after)) } for i := range before { if after[i] != before[i] { t.Fatalf("main context message %d changed:\n before %q\n after %q", i, before[i], after[i]) } } } // TestBtwNoStoreWrites verifies /btw writes nothing to the session store: no // entries are appended for the session on disk (AC-2). func TestBtwNoStoreWrites(t *testing.T) { p := &replProvider{reply: "answer"} deps, store := newTestDeps(t, p) seedMainContext(&deps) var out bytes.Buffer if err := runREPL(strings.NewReader("/btw does this persist?\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } // The main loop persists only on a real turn; /btw must trigger none. Since // the seeded messages were never run through streamRun, the store should hold // no entries for this session at all. if _, entries, err := store.LoadEntries(deps.header.ID); err == nil && len(entries) != 0 { t.Fatalf("/btw must not write to the store, got %d entries", len(entries)) } } // TestBtwFollowUpsLeaveContextUnchanged verifies that ≥3 follow-ups in the same // side thread still leave the main context's count and content unchanged (AC-3). func TestBtwFollowUpsLeaveContextUnchanged(t *testing.T) { p := &replProvider{reply: "ok"} deps, _ := newTestDeps(t, p) seedMainContext(&deps) before := snapshotMessages(deps.agentCtx.Messages) var out bytes.Buffer // One /btw plus three bare follow-ups, then leave the thread and exit. in := strings.NewReader("/btw first?\nsecond?\nthird?\nfourth?\n/exit\n/exit\n") if err := runREPL(in, &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 4 { t.Fatalf("expected 4 side runs (1 + 3 follow-ups), got %d", p.calls) } after := snapshotMessages(deps.agentCtx.Messages) if len(after) != len(before) { t.Fatalf("main context grew across follow-ups: %d → %d", len(before), len(after)) } for i := range before { if after[i] != before[i] { t.Fatalf("follow-ups changed main message %d: %q → %q", i, before[i], after[i]) } } } // TestBtwPersistenceBookkeepingUnchanged verifies /btw does not advance the // persistence bookkeeping: deps.persisted, deps.curLeaf and deps.header.UpdatedAt // are all identical before and after (AC-4). func TestBtwPersistenceBookkeepingUnchanged(t *testing.T) { p := &replProvider{reply: "x"} deps, _ := newTestDeps(t, p) seedMainContext(&deps) // Give the bookkeeping non-zero starting values so the test would catch a // reset-to-zero as well as an increment. deps.persisted = 2 deps.curLeaf = "leaf-abc" beforeUpdated := deps.header.UpdatedAt var out bytes.Buffer if err := runREPL(strings.NewReader("/btw hi\nmore?\n/exit\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if deps.persisted != 2 { t.Errorf("deps.persisted changed: 2 → %d", deps.persisted) } if deps.curLeaf != "leaf-abc" { t.Errorf("deps.curLeaf changed: %q → %q", "leaf-abc", deps.curLeaf) } if !deps.header.UpdatedAt.Equal(beforeUpdated) { t.Errorf("deps.header.UpdatedAt changed: %v → %v", beforeUpdated, deps.header.UpdatedAt) } }