172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
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 <q>" 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 <q>" 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))
|
|
}
|
|
}
|