226 lines
6.2 KiB
Go
226 lines
6.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"iter"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
|
|
"nub/internal/agent"
|
|
"nub/internal/llm"
|
|
"nub/internal/session"
|
|
"nub/internal/tool"
|
|
)
|
|
|
|
// idleProvider wird in diesen Tests nie tatsächlich gestreamt — der Loop
|
|
// läuft nur, um startSession realistisch zu testen (in/out-Channel-Handling),
|
|
// ohne dass eine Nachricht gesendet wird.
|
|
type idleProvider struct{}
|
|
|
|
func (idleProvider) Name() string { return "idle" }
|
|
func (idleProvider) Caps() llm.Caps { return llm.Caps{} }
|
|
func (idleProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
|
return func(yield func(llm.Event, error) bool) {}, nil
|
|
}
|
|
|
|
func newSwitchableModel(t *testing.T, dir string, store *session.Store) *Model {
|
|
t.Helper()
|
|
loop := &agent.Loop{
|
|
Provider: idleProvider{},
|
|
Tools: tool.NewRegistry(),
|
|
Model: "test-model",
|
|
Env: tool.Env{Cwd: dir, RepoRoot: dir},
|
|
}
|
|
return &Model{
|
|
ctx: context.Background(),
|
|
loop: loop,
|
|
textarea: textarea.New(),
|
|
viewport: viewport.New(80, 20),
|
|
spinner: spinner.New(),
|
|
modelName: loop.Model,
|
|
}
|
|
}
|
|
|
|
func TestStartSession_ReplaysHistoryAndBumpsGeneration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := session.Create(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "hello from history"), session.NodeMeta{})
|
|
if err := store.Append(n1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hi there"), session.NodeMeta{})
|
|
if err := store.Append(n2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m := newSwitchableModel(t, dir, store)
|
|
if m.sessionGen != 0 {
|
|
t.Fatalf("sessionGen = %d before any startSession call, want 0", m.sessionGen)
|
|
}
|
|
|
|
cmd := m.startSession(store)
|
|
t.Cleanup(func() {
|
|
if m.sessionCancel != nil {
|
|
m.sessionCancel()
|
|
}
|
|
})
|
|
|
|
if m.sessionGen != 1 {
|
|
t.Errorf("sessionGen = %d, want 1 after first startSession", m.sessionGen)
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected startSession to return a listen cmd")
|
|
}
|
|
if len(m.entries) != 2 {
|
|
t.Fatalf("entries = %+v, want 2 replayed messages", m.entries)
|
|
}
|
|
if m.entries[0].kind != entryUser || m.entries[0].text != "hello from history" {
|
|
t.Errorf("entries[0] = %+v", m.entries[0])
|
|
}
|
|
if m.entries[1].kind != entryAssistant || m.entries[1].text != "hi there" {
|
|
t.Errorf("entries[1] = %+v", m.entries[1])
|
|
}
|
|
|
|
// Der zurückgegebene Cmd muss tatsächlich auf dem neuen out-Channel
|
|
// lauschen (gen=1) — daran hängt die Stale-Event-Erkennung beim Wechsel.
|
|
// cmd() blockiert, bis out etwas liefert oder schließt; also im
|
|
// Hintergrund aufrufen und den Kontext canceln, um out zu schließen.
|
|
resultCh := make(chan interface{}, 1)
|
|
go func() { resultCh <- cmd() }()
|
|
m.sessionCancel()
|
|
|
|
select {
|
|
case msg := <-resultCh:
|
|
evMsg, ok := msg.(eventsClosedMsg)
|
|
if !ok {
|
|
t.Fatalf("expected eventsClosedMsg, got %T", msg)
|
|
}
|
|
if evMsg.gen != 1 {
|
|
t.Errorf("listen cmd bound to gen %d, want 1", evMsg.gen)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for the listen cmd to observe channel closure")
|
|
}
|
|
}
|
|
|
|
func TestStartSession_RestoresHistoricalTokenUsage(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := session.Create(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
|
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "hi"), session.NodeMeta{})
|
|
if err := store.Append(n1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hello"),
|
|
session.NodeMeta{Usage: llm.Usage{InputTokens: 1200, OutputTokens: 30, CacheReadTokens: 900}})
|
|
if err := store.Append(n2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n3 := session.NewNode(n2.ID, textMsg(llm.RoleUser, "again"), session.NodeMeta{})
|
|
if err := store.Append(n3); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n4 := session.NewNode(n3.ID, textMsg(llm.RoleAssistant, "again reply"),
|
|
session.NodeMeta{Usage: llm.Usage{InputTokens: 1400, OutputTokens: 20, CacheReadTokens: 1300}})
|
|
if err := store.Append(n4); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m := newSwitchableModel(t, dir, store)
|
|
m.tokensUsed = 999999 // Altlast einer vorherigen Session, muss überschrieben werden
|
|
m.cacheReadTokens = 999999
|
|
|
|
m.startSession(store)
|
|
t.Cleanup(func() {
|
|
if m.sessionCancel != nil {
|
|
m.sessionCancel()
|
|
}
|
|
})
|
|
|
|
wantTokens := 1200 + 30 + 1400 + 20
|
|
if m.tokensUsed != wantTokens {
|
|
t.Errorf("tokensUsed = %d, want %d (sum of persisted NodeMeta.Usage)", m.tokensUsed, wantTokens)
|
|
}
|
|
wantCache := 900 + 1300
|
|
if m.cacheReadTokens != wantCache {
|
|
t.Errorf("cacheReadTokens = %d, want %d", m.cacheReadTokens, wantCache)
|
|
}
|
|
}
|
|
|
|
func TestStartSession_SwitchingCancelsPreviousLoop(t *testing.T) {
|
|
dir := t.TempDir()
|
|
storeA, err := session.Create(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storeA.Close() })
|
|
storeB, err := session.Create(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storeB.Close() })
|
|
|
|
m := newSwitchableModel(t, dir, storeA)
|
|
m.startSession(storeA)
|
|
oldOut := m.out
|
|
oldGen := m.sessionGen
|
|
|
|
m.startSession(storeB)
|
|
t.Cleanup(func() {
|
|
if m.sessionCancel != nil {
|
|
m.sessionCancel()
|
|
}
|
|
})
|
|
|
|
if m.sessionGen != oldGen+1 {
|
|
t.Errorf("sessionGen = %d, want %d after switching", m.sessionGen, oldGen+1)
|
|
}
|
|
if m.store != storeB {
|
|
t.Error("expected store to point at the new session")
|
|
}
|
|
if m.loop.Session != storeB {
|
|
t.Error("expected loop.Session to be rebound to the new store")
|
|
}
|
|
|
|
select {
|
|
case _, ok := <-oldOut:
|
|
if ok {
|
|
t.Error("expected the old session's out channel to close, not emit an event")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for the previous loop to shut down after cancel")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_StaleSessionEventsAreIgnored(t *testing.T) {
|
|
m := newTestModel(t)
|
|
m.sessionGen = 5
|
|
m.turnActive = true
|
|
|
|
_, cmd := m.Update(uiEventMsg{gen: 3, ev: tool.TextDelta{Text: "should be ignored"}})
|
|
if cmd != nil {
|
|
t.Error("stale uiEventMsg should not schedule any follow-up cmd")
|
|
}
|
|
if m.hasLiveText {
|
|
t.Error("stale uiEventMsg must not be applied to model state")
|
|
}
|
|
|
|
_, cmd = m.Update(eventsClosedMsg{gen: 3})
|
|
if m.quitting {
|
|
t.Error("stale eventsClosedMsg from a switched-away session must not quit the TUI")
|
|
}
|
|
_ = cmd
|
|
}
|