211 lines
7.3 KiB
Go
211 lines
7.3 KiB
Go
package tui
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/session"
|
|
)
|
|
|
|
// newTestStore opens a session store rooted at a temp dir so persistence/resume
|
|
// can be exercised without touching ~/.pigo.
|
|
func newTestStore(t *testing.T) *session.Store {
|
|
t.Helper()
|
|
store, err := session.NewStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
// saveSession writes a linear session with the given messages and returns its id.
|
|
func saveSession(t *testing.T, store *session.Store, msgs agentcore.MessageList) string {
|
|
t.Helper()
|
|
now := time.Now().UTC()
|
|
header := session.SessionHeader{
|
|
ID: session.NewID(now),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Model: "test-model",
|
|
Provider: "test-provider",
|
|
}
|
|
if err := store.Save(header, msgs); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
return header.ID
|
|
}
|
|
|
|
// TestResumeSeedsTranscript constructs a session with a few messages, resumes it
|
|
// through newRunSessionWithStore, seeds a transcript with the returned history,
|
|
// and asserts the initial transcript blocks carry those messages (FR-16 resume).
|
|
func TestResumeSeedsTranscript(t *testing.T) {
|
|
store := newTestStore(t)
|
|
id := saveSession(t, store, agentcore.MessageList{
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hello, world")}},
|
|
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("hello back")}},
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("second question")}},
|
|
})
|
|
|
|
s, history, err := newRunSessionWithStore(store, Options{ResumeID: id})
|
|
if err != nil {
|
|
t.Fatalf("newRunSessionWithStore: %v", err)
|
|
}
|
|
if len(history) != 3 {
|
|
t.Fatalf("history len = %d, want 3", len(history))
|
|
}
|
|
// The persisted cursor must cover the full resumed history so the first new
|
|
// turn appends only fresh messages, not a re-save of history.
|
|
if s.persisted != 3 {
|
|
t.Errorf("persisted = %d, want 3", s.persisted)
|
|
}
|
|
if s.curLeaf == "" {
|
|
t.Error("curLeaf should be the resumed leaf, got empty")
|
|
}
|
|
|
|
tr := newTranscript(DefaultTheme())
|
|
seedTranscript(&tr, history)
|
|
|
|
wantTexts := []string{"hello, world", "hello back", "second question"}
|
|
if len(tr.blocks) != len(wantTexts) {
|
|
t.Fatalf("transcript blocks = %d, want %d", len(tr.blocks), len(wantTexts))
|
|
}
|
|
for i, want := range wantTexts {
|
|
if tr.blocks[i].text != want {
|
|
t.Errorf("block[%d] = %q, want %q", i, tr.blocks[i].text, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildConfigAssembly asserts the run-config assembly maps the live config
|
|
// onto RunConfig without a live provider: the model/provider/thinking/window
|
|
// fields flow through, compaction is enabled, and the tool registry is wired.
|
|
func TestBuildConfigAssembly(t *testing.T) {
|
|
store := newTestStore(t)
|
|
s, _, err := newRunSessionWithStore(store, Options{
|
|
Model: "opus-test",
|
|
ProviderName: "anthropic",
|
|
ThinkingLevel: agentcore.ThinkingLevel("high"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newRunSessionWithStore: %v", err)
|
|
}
|
|
|
|
cfg := s.buildConfig()
|
|
if cfg.Model != "opus-test" {
|
|
t.Errorf("cfg.Model = %q, want opus-test", cfg.Model)
|
|
}
|
|
if cfg.Provider != "anthropic" {
|
|
t.Errorf("cfg.Provider = %q, want anthropic", cfg.Provider)
|
|
}
|
|
if cfg.ThinkingLevel != agentcore.ThinkingLevel("high") {
|
|
t.Errorf("cfg.ThinkingLevel = %q, want high", cfg.ThinkingLevel)
|
|
}
|
|
if cfg.ContextWindow <= 0 {
|
|
t.Errorf("cfg.ContextWindow = %d, want a positive default", cfg.ContextWindow)
|
|
}
|
|
if !cfg.Compaction.Enabled {
|
|
t.Error("cfg.Compaction.Enabled = false, want true (DefaultCompactionSettings)")
|
|
}
|
|
if cfg.Batch.Registry == nil {
|
|
t.Error("cfg.Batch.Registry is nil, want the assembled tool registry")
|
|
}
|
|
if cfg.Stream == nil {
|
|
t.Error("cfg.Stream is nil, want a stream fn derived from the provider")
|
|
}
|
|
}
|
|
|
|
// TestFreshSessionPersists starts a fresh session, appends a turn to the context,
|
|
// persists it, and confirms it round-trips back through the store (FR-16 persist).
|
|
func TestFreshSessionPersists(t *testing.T) {
|
|
store := newTestStore(t)
|
|
s, history, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"})
|
|
if err != nil {
|
|
t.Fatalf("newRunSessionWithStore: %v", err)
|
|
}
|
|
if history != nil {
|
|
t.Fatalf("fresh session history = %v, want nil", history)
|
|
}
|
|
|
|
s.agentCtx.Messages = append(s.agentCtx.Messages,
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}},
|
|
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("yo")}},
|
|
)
|
|
if err := s.persist(); err != nil {
|
|
t.Fatalf("persist: %v", err)
|
|
}
|
|
if s.persisted != 2 {
|
|
t.Errorf("persisted = %d, want 2", s.persisted)
|
|
}
|
|
|
|
_, msgs, err := store.Load(s.header.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("persisted messages = %d, want 2", len(msgs))
|
|
}
|
|
|
|
// A second persist with no new messages is a no-op.
|
|
before := s.curLeaf
|
|
if err := s.persist(); err != nil {
|
|
t.Fatalf("persist (no-op): %v", err)
|
|
}
|
|
if s.curLeaf != before {
|
|
t.Errorf("curLeaf changed on no-op persist: %q -> %q", before, s.curLeaf)
|
|
}
|
|
}
|
|
|
|
// TestPersistAfterCompaction reproduces the crash where an automatic compaction
|
|
// shrinks agentCtx.Messages below the persisted cursor: an incremental
|
|
// Messages[persisted:] would panic with a slice-bounds error. persist() must
|
|
// instead re-save the flattened context and reset the cursor to the new length.
|
|
func TestPersistAfterCompaction(t *testing.T) {
|
|
store := newTestStore(t)
|
|
s, _, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"})
|
|
if err != nil {
|
|
t.Fatalf("newRunSessionWithStore: %v", err)
|
|
}
|
|
|
|
// Persist a few turns so the cursor advances past what compaction will keep.
|
|
for i := 0; i < 4; i++ {
|
|
s.agentCtx.Messages = append(s.agentCtx.Messages,
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q")}},
|
|
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a")}},
|
|
)
|
|
}
|
|
if err := s.persist(); err != nil {
|
|
t.Fatalf("persist: %v", err)
|
|
}
|
|
if s.persisted != 8 {
|
|
t.Fatalf("persisted = %d, want 8 before compaction", s.persisted)
|
|
}
|
|
|
|
// Simulate the run loop compacting: Messages is rewritten to a shorter
|
|
// summary + tail (here just a 2-message tail), and the loop signalled it via
|
|
// compactionMsg (which sets s.compacted).
|
|
s.agentCtx.Messages = agentcore.MessageList{
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("recent q")}},
|
|
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("recent a")}},
|
|
}
|
|
s.compacted = true
|
|
|
|
if err := s.persist(); err != nil {
|
|
t.Fatalf("persist after compaction: %v", err)
|
|
}
|
|
if s.compacted {
|
|
t.Error("compacted flag should be cleared after persist")
|
|
}
|
|
if s.persisted != 2 {
|
|
t.Errorf("persisted = %d, want 2 (the compacted length)", s.persisted)
|
|
}
|
|
|
|
_, msgs, err := store.Load(s.header.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("persisted messages = %d, want 2 (flattened compacted context)", len(msgs))
|
|
}
|
|
}
|