nub/internal/agent/compact_test.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

162 lines
4.6 KiB
Go

package agent
import (
"context"
"iter"
"strings"
"testing"
"time"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
func TestCompactionCutIndex_KeepsLastNTurnsRaw(t *testing.T) {
nodes := []*session.Node{
{ID: "u1", Message: textMsgNode(llm.RoleUser, "u1")},
{ID: "a1", Message: textMsgNode(llm.RoleAssistant, "a1")},
{ID: "u2", Message: textMsgNode(llm.RoleUser, "u2")},
{ID: "a2", Message: textMsgNode(llm.RoleAssistant, "a2")},
{ID: "u3", Message: textMsgNode(llm.RoleUser, "u3")},
}
if cut := compactionCutIndex(nodes, 2); cut != 2 {
t.Errorf("cut = %d, want 2 (start of u2)", cut)
}
if cut := compactionCutIndex(nodes, 10); cut != 0 {
t.Errorf("cut = %d, want 0 when keepTurns exceeds available turns", cut)
}
}
func TestIsUserTurnBoundary_IgnoresToolResultOnlyMessages(t *testing.T) {
toolResultMsg := llm.Message{
Role: llm.RoleUser,
Content: []llm.Block{
{Kind: llm.KindToolResult, ToolUseID: "x", Result: []llm.Block{{Kind: llm.KindText, Text: "ok"}}},
},
}
if isUserTurnBoundary(&session.Node{Message: toolResultMsg}) {
t.Error("tool-result-only message should not count as a turn boundary")
}
realUserMsg := textMsgNode(llm.RoleUser, "hello")
if !isUserTurnBoundary(&session.Node{Message: realUserMsg}) {
t.Error("a real user text message should count as a turn boundary")
}
}
func textMsgNode(role llm.Role, text string) llm.Message {
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
}
// scriptedProvider unterscheidet Compaction-Zusammenfassungs-Requests
// (erkennbar am System-Prompt) von normalen Turn-Requests, damit die
// Aufrufreihenfolge im Test nicht von der genauen Compaction-Logik abhängt.
type scriptedProvider struct {
caps llm.Caps
batches [][]llm.Event
idx int
}
func (p *scriptedProvider) Name() string { return "scripted" }
func (p *scriptedProvider) Caps() llm.Caps { return p.caps }
func (p *scriptedProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
if len(req.System) > 0 && strings.Contains(req.System[0].Text, "Zusammenfassung") {
return func(yield func(llm.Event, error) bool) {
yield(llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}}, nil)
yield(llm.BlockDelta{Index: 0, Text: "SUMMARY: files touched, decisions made, todo remains"}, nil)
yield(llm.BlockStop{Index: 0}, nil)
yield(llm.Done{Stop: llm.StopEnd}, nil)
}, nil
}
batch := p.batches[p.idx]
p.idx++
return func(yield func(llm.Event, error) bool) {
for _, ev := range batch {
if !yield(ev, nil) {
return
}
}
}, nil
}
// TestCompaction_AutoTriggersAndPreservesRewind ist das M4-Fertig-Kriterium
// aus Abschnitt 6: eine künstlich verlängerte Session compactet automatisch,
// läuft weiter, und ein Rewind auf einen Knoten *vor* der Compaction
// funktioniert weiterhin.
func TestCompaction_AutoTriggersAndPreservesRewind(t *testing.T) {
provider := &scriptedProvider{
caps: llm.Caps{MaxContext: 10}, // winzig -> Budget nach wenigen Turns überschritten
batches: [][]llm.Event{
textEvents("resp-1"),
textEvents("resp-2"),
textEvents("resp-3"),
},
}
sess := session.New("test-compaction")
loop := &Loop{
Provider: provider,
Tools: tool.NewRegistry(),
Model: "test-model",
Env: tool.Env{Cwd: ".", RepoRoot: "."},
Session: sess,
KeepTurns: 2,
CompactAt: 0.75,
}
in := make(chan Input)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
done := make(chan struct{})
go func() {
defer close(done)
for range out {
}
}()
for _, text := range []string{"user one", "user two", "user three"} {
select {
case in <- Input{Text: text}:
case <-ctx.Done():
t.Fatal("timed out sending input")
}
}
close(in)
<-done
var summary *session.Node
var rootID string
for id, n := range sess.Nodes {
if n.Kind == session.NodeSummary {
summary = n
}
if n.ParentID == "" && n.Kind == session.NodeMessage {
rootID = id
}
}
if summary == nil {
t.Fatal("expected a summary node after auto-compaction")
}
if rootID == "" {
t.Fatal("could not find original root node")
}
path := sess.PathToHead()
if len(path) == 0 || path[0].Content[0].Text != summary.Message.Content[0].Text {
t.Errorf("expected current path to start at the summary, got: %+v", path)
}
// Rewind auf den allerersten (nie kompaktierten) Node muss weiterhin
// funktionieren.
if err := sess.Branch(rootID); err != nil {
t.Fatal(err)
}
rewound := sess.PathToHead()
if len(rewound) != 1 || rewound[0].Content[0].Text != "user one" {
t.Errorf("rewind to pre-compaction root failed: %+v", rewound)
}
}