162 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|