package repl // Tests for the /btw side-thread command (#279): a side question must run an // agent stream but MUST NOT mutate or persist the main conversation. These // drive the whole runREPL loop with the fake replProvider, then assert the main // context and persistence state are unchanged. import ( "bytes" "context" "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/cli/btw" ) // TestBtwDoesNotPolluteMainContext verifies that "/btw " launches a run // (provider called) yet appends nothing to deps.agentCtx.Messages. func TestBtwDoesNotPolluteMainContext(t *testing.T) { p := &replProvider{reply: "side answer"} deps, _ := newTestDeps(t, p) var out bytes.Buffer if err := runREPL(strings.NewReader("/btw why pointers?\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 1 { t.Fatalf("expected side question to launch exactly 1 run, got %d", p.calls) } if len(deps.agentCtx.Messages) != 0 { t.Fatalf("main context must be untouched by /btw, got %d messages", len(deps.agentCtx.Messages)) } if !strings.Contains(out.String(), btw.BtwHeader) { t.Errorf("expected side-thread header %q in output", btw.BtwHeader) } if !strings.Contains(out.String(), "side answer") { t.Errorf("expected the side answer to be printed, got: %q", out.String()) } } // TestBtwDoesNotPersist verifies /btw writes nothing to disk: deps.persisted and // deps.curLeaf are unchanged, and no session entries were appended. func TestBtwDoesNotPersist(t *testing.T) { p := &replProvider{reply: "answer"} deps, store := newTestDeps(t, p) var out bytes.Buffer if err := runREPL(strings.NewReader("/btw quick q\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if deps.persisted != 0 { t.Fatalf("deps.persisted must stay 0 after /btw, got %d", deps.persisted) } if deps.curLeaf != "" { t.Fatalf("deps.curLeaf must stay empty after /btw, got %q", deps.curLeaf) } if _, entries, err := store.LoadEntries(deps.header.ID); err == nil && len(entries) != 0 { t.Fatalf("no session entries should be persisted by /btw, got %d", len(entries)) } } // TestBtwBareUsage verifies bare "/btw" with no prior side thread does not // launch a run and prints usage guidance. func TestBtwBareUsage(t *testing.T) { p := &replProvider{reply: "unused"} deps, _ := newTestDeps(t, p) var out bytes.Buffer if err := runREPL(strings.NewReader("/btw\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 0 { t.Fatalf("bare /btw must not launch a run, got %d calls", p.calls) } if !strings.Contains(out.String(), "usage: /btw") { t.Errorf("expected usage hint for bare /btw, got: %q", out.String()) } } // TestBtwBareReopensLastThread verifies that after a side thread exists, a bare // "/btw" reopens it, replays the prior side Q&A, and lets the user keep asking // in the SAME thread. The main context stays untouched throughout. func TestBtwBareReopensLastThread(t *testing.T) { p := &replProvider{reply: "side answer"} deps, _ := newTestDeps(t, p) var out bytes.Buffer // Open a side thread and ask once, leave it, then bare /btw reopens it and // asks a follow-up, then leave again and exit the REPL. in := strings.NewReader("/btw first question?\n/exit\n/btw\nsecond question?\n/exit\n/exit\n") if err := runREPL(in, &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 2 { t.Fatalf("expected 2 side runs (1 initial + 1 after reopen), got %d", p.calls) } if len(deps.agentCtx.Messages) != 0 { t.Fatalf("main context must stay untouched, got %d messages", len(deps.agentCtx.Messages)) } s := out.String() // The reopen must not print the bare-/btw usage hint (a thread existed). if strings.Contains(s, "usage: /btw") { t.Errorf("bare /btw with an existing thread must not print usage, got: %q", s) } // The replay must echo the earlier question. if !strings.Contains(s, "first question?") { t.Errorf("reopen should replay the earlier side question, got: %q", s) } } // TestBtwFollowUpsShareThread verifies that after "/btw " the user can ask // follow-ups at the btw prompt (without retyping /btw), each launching a run, // and that none of them pollute the main context. "/exit" leaves the thread. func TestBtwFollowUpsShareThread(t *testing.T) { p := &replProvider{reply: "ok"} deps, _ := newTestDeps(t, p) var out bytes.Buffer // First /btw asks once; then two bare follow-ups; then /exit leaves the side // thread; then /exit ends the REPL. in := strings.NewReader("/btw first?\nsecond?\nthird?\n/exit\n/exit\n") if err := runREPL(in, &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } if p.calls != 3 { t.Fatalf("expected 3 side runs (1 initial + 2 follow-ups), got %d", p.calls) } if len(deps.agentCtx.Messages) != 0 { t.Fatalf("follow-ups must not pollute main context, got %d messages", len(deps.agentCtx.Messages)) } if !strings.Contains(out.String(), "left side thread") { t.Errorf("expected 'left side thread' on /exit from the side thread") } } // TestBtwFollowUpLoopAccumulates verifies the side context grows across // follow-ups so a later question sees the earlier Q&A. func TestBtwFollowUpLoopAccumulates(t *testing.T) { side := &agentcore.AgentContext{} deps, _ := newTestDeps(t, &replProvider{reply: "a"}) setCancel := func(context.CancelFunc) {} settings := btw.ResolveBtwSettings(&bytes.Buffer{}, &deps) btw.AskSide(setCancel, &bytes.Buffer{}, &deps, side, settings, "q1") n1 := len(side.Messages) btw.AskSide(setCancel, &bytes.Buffer{}, &deps, side, settings, "q2") if len(side.Messages) <= n1 { t.Fatalf("side context should accumulate across follow-ups: %d then %d", n1, len(side.Messages)) } } // appending to the side thread cannot reach the main slice. func TestNewSideContextIsolated(t *testing.T) { main := &agentcore.AgentContext{ SystemPrompt: "sys", Messages: agentcore.MessageList{ agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}}, }, } side := btw.NewSideContext(main) if side.SystemPrompt != "sys" { t.Errorf("side thread should inherit the system prompt") } if len(side.Messages) != 1 { t.Fatalf("side thread should be seeded with the main messages, got %d", len(side.Messages)) } side.Messages = append(side.Messages, agentcore.UserMessage{RoleField: agentcore.RoleUser}) if len(main.Messages) != 1 { t.Fatalf("appending to the side thread must not grow the main context, got %d", len(main.Messages)) } }