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

232 lines
5.7 KiB
Go

package agent
import (
"context"
"encoding/json"
"iter"
"testing"
"time"
"nub/internal/llm"
"nub/internal/session"
"nub/internal/tool"
)
// fakeProvider spielt eine vordefinierte Folge von Event-Batches ab, eine
// pro Stream()-Aufruf. Damit lässt sich Multi-Turn-Verhalten ohne Netzwerk
// testen (Teststrategie, Abschnitt 7).
type fakeProvider struct {
batches [][]llm.Event
call int
caps llm.Caps
}
func (p *fakeProvider) Name() string { return "fake" }
func (p *fakeProvider) Caps() llm.Caps { return p.caps }
func (p *fakeProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
if p.call >= len(p.batches) {
return func(yield func(llm.Event, error) bool) {
yield(llm.Done{Stop: llm.StopEnd}, nil)
}, nil
}
batch := p.batches[p.call]
p.call++
return func(yield func(llm.Event, error) bool) {
for _, ev := range batch {
if !yield(ev, nil) {
return
}
}
}, nil
}
type echoTool struct{}
func (echoTool) Name() string { return "echo" }
func (echoTool) Description() string { return "echoes input" }
func (echoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (echoTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
return tool.Result{ForModel: "ok:" + string(input)}, nil
}
func textEvents(s string) []llm.Event {
return []llm.Event{
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}},
llm.BlockDelta{Index: 0, Text: s},
llm.BlockStop{Index: 0},
llm.Done{Stop: llm.StopEnd, Usage: llm.Usage{OutputTokens: 1}},
}
}
func toolCallEvents(id, name, args string) []llm.Event {
return []llm.Event{
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindToolUse, ID: id, Name: name}},
llm.BlockDelta{Index: 0, PartialJSON: args},
llm.BlockStop{Index: 0},
llm.Done{Stop: llm.StopToolUse, Usage: llm.Usage{OutputTokens: 1}},
}
}
func newTestLoop(provider llm.Provider) (*Loop, *tool.Registry) {
reg := tool.NewRegistry()
reg.Register(echoTool{})
return &Loop{
Provider: provider,
Tools: reg,
Model: "test-model",
Env: tool.Env{Cwd: ".", RepoRoot: "."},
Session: session.New("test-session"),
}, reg
}
func drain(t *testing.T, out <-chan tool.UIEvent, timeout time.Duration) []tool.UIEvent {
t.Helper()
var events []tool.UIEvent
deadline := time.After(timeout)
for {
select {
case ev, ok := <-out:
if !ok {
return events
}
events = append(events, ev)
case <-deadline:
t.Fatal("timed out waiting for events")
return nil
}
}
}
func TestLoop_SimpleTextTurn(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{textEvents("hello")}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "hi"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
var gotText string
var gotDone bool
for _, ev := range events {
switch e := ev.(type) {
case tool.TextDelta:
gotText += e.Text
case tool.TurnDone:
gotDone = true
if e.Stop != llm.StopEnd {
t.Errorf("stop = %q, want end_turn", e.Stop)
}
}
}
if gotText != "hello" {
t.Errorf("text = %q", gotText)
}
if !gotDone {
t.Error("expected TurnDone event")
}
}
func TestLoop_ToolCallRoundTrip(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "echo", `{"x":1}`),
textEvents("done"),
}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "run echo"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
var gotOutput *tool.ToolCallOutput
for _, ev := range events {
if o, ok := ev.(tool.ToolCallOutput); ok {
cp := o
gotOutput = &cp
}
}
if gotOutput == nil {
t.Fatal("expected a ToolCallOutput event")
}
if gotOutput.Result.ForModel != `ok:{"x":1}` {
t.Errorf("tool result = %q", gotOutput.Result.ForModel)
}
if provider.call != 2 {
t.Errorf("expected 2 provider calls (tool_use + follow-up), got %d", provider.call)
}
}
func TestLoop_UnknownToolProducesErrorResult(t *testing.T) {
provider := &fakeProvider{batches: [][]llm.Event{
toolCallEvents("call_1", "does_not_exist", `{}`),
textEvents("done"),
}}
loop, _ := newTestLoop(provider)
in := make(chan Input, 1)
in <- Input{Text: "go"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
found := false
for _, ev := range events {
if o, ok := ev.(tool.ToolCallOutput); ok && o.Name == "does_not_exist" {
found = true
if !o.Result.IsError {
t.Error("expected IsError for unknown tool")
}
}
}
if !found {
t.Error("expected ToolCallOutput for unknown tool")
}
}
func TestLoop_StreamErrorAbortsWithoutIncompleteMessage(t *testing.T) {
provider := &fakeProvider{}
loop, _ := newTestLoop(provider)
loop.Provider = errorProvider{}
in := make(chan Input, 1)
in <- Input{Text: "hi"}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out := loop.Run(ctx, in)
close(in)
events := drain(t, out, 3*time.Second)
sawError := false
for _, ev := range events {
if _, ok := ev.(tool.ErrorEvent); ok {
sawError = true
}
}
if !sawError {
t.Error("expected ErrorEvent")
}
}
type errorProvider struct{}
func (errorProvider) Name() string { return "error" }
func (errorProvider) Caps() llm.Caps { return llm.Caps{} }
func (errorProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
return func(yield func(llm.Event, error) bool) {
yield(nil, context.DeadlineExceeded)
}, nil
}