Files
2026-08-14 23:41:57 +08:00

149 lines
5.7 KiB
Go

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)
}
}