package tui import ( "strings" "testing" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/agentcore" ) // typeCommand feeds "/name" into the model and presses Enter, mirroring how a // user runs a slash command from the composer (the popup is open at Enter, so it // routes through submitSlashSelected, exactly like the REPL path). func typeCommand(t *testing.T, m Model, cmd string) Model { t.Helper() m = typeInto(t, m, cmd).(Model) got, c := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if c != nil { if msg := c(); msg != nil { if _, isQuit := msg.(tea.QuitMsg); isQuit { t.Fatalf("%s should not quit", cmd) } } } return got.(Model) } // TestStatusWithSessionRendersSections drives /status on a session-bound model // and asserts every report section appears in the transcript, with the model // staying idle (no run is started). func TestStatusWithSessionRendersSections(t *testing.T) { store := newTestStore(t) s, _, err := newRunSessionWithStore(store, Options{ Model: "status-model", ProviderName: "status-provider", ThinkingLevel: agentcore.ThinkingLevel("low"), }) if err != nil { t.Fatalf("newRunSessionWithStore: %v", err) } // The /status environment section keys off the launch directory; keep it // deterministic rather than depending on the test's cwd. s.cwd = "/tmp/tui-status" m := NewModel(Options{}).withSession(s, nil) m = typeCommand(t, m, "/status") if m.running { t.Error("/status is an action command; model should stay idle") } if m.input.Value() != "" { t.Errorf("after executing /status, input = %q, want cleared", m.input.Value()) } joined := strings.Join(blockTexts(m.transcript), "\n") for _, want := range []string{ "runtime config:", "model: status-model", "provider: status-provider", "context:", "project & environment:", "cwd: /tmp/tui-status", "credentials & connectivity:", "telemetry:", "no telemetry yet", } { if !strings.Contains(joined, want) { t.Errorf("/status output missing %q; transcript:\n%s", want, joined) } } } // TestStatusWithoutSessionNotice verifies a session-less model reports the // unavailable notice rather than panicking on nil collaborators, and still // clears the input. func TestStatusWithoutSessionNotice(t *testing.T) { m := NewModel(Options{}) m = typeCommand(t, m, "/status") if m.running { t.Error("/status must not start a run on a session-less model") } if m.input.Value() != "" { t.Errorf("after executing /status, input = %q, want cleared", m.input.Value()) } joined := strings.Join(blockTexts(m.transcript), "\n") if !strings.Contains(joined, "status unavailable: no active session") { t.Errorf("expected an unavailable notice in transcript, got:\n%s", joined) } } // TestSessionCommandRendersSummary drives /session on a session-bound model and // asserts the summary lines (session id, message count, tokens, model/provider, // compactions) match the REPL's /session format. func TestSessionCommandRendersSummary(t *testing.T) { store := newTestStore(t) s, _, err := newRunSessionWithStore(store, Options{ Model: "session-model", ProviderName: "session-provider", }) if err != nil { t.Fatalf("newRunSessionWithStore: %v", err) } // Simulate two user/assistant turns in the live context (unsaved messages are // counted too, mirroring the REPL's in-memory source of truth). s.agentCtx.Messages = agentcore.MessageList{ agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q1")}}, agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a1")}}, agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q2")}}, agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a2")}}, } m := NewModel(Options{}).withSession(s, nil) m = typeCommand(t, m, "/session") if m.running { t.Error("/session is an action command; model should stay idle") } joined := strings.Join(blockTexts(m.transcript), "\n") for _, want := range []string{ "session: " + s.header.ID, "messages: 4", "tokens (est):", "model: session-model (provider: session-provider)", "compactions: 0", } { if !strings.Contains(joined, want) { t.Errorf("/session output missing %q; transcript:\n%s", want, joined) } } } // TestSessionWithoutSessionNotice verifies a session-less model reports the // unavailable notice for /session. func TestSessionWithoutSessionNotice(t *testing.T) { m := NewModel(Options{}) m = typeCommand(t, m, "/session") joined := strings.Join(blockTexts(m.transcript), "\n") if !strings.Contains(joined, "session unavailable: no active session") { t.Errorf("expected an unavailable notice in transcript, got:\n%s", joined) } } // TestTelemetryFoldingFeedsStatus verifies a telemetryMsg from the bridge is // folded into the session's telemetry holder, so /status renders the cumulative // and last-run telemetry blocks instead of "no telemetry yet". func TestTelemetryFoldingFeedsStatus(t *testing.T) { store := newTestStore(t) s, _, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"}) if err != nil { t.Fatalf("newRunSessionWithStore: %v", err) } if s.telemetry == nil { t.Fatal("session telemetry holder should be initialized") } m := NewModel(Options{}).withSession(s, nil) // A telemetryMsg is bridged while a run is pumping; set up that state so // Update keeps the pump running (pumpNext) exactly as during a real run. m.running = true m.runCh = make(chan tea.Msg) next, cmd := m.Update(telemetryMsg{ev: agentcore.TelemetryEvent{ Turns: 3, TruncationCount: 1, CompactionCount: 1, ContextTokens: 1000, ContextWindow: 200000, ContextUtilization: 0.005, }}) if cmd == nil { t.Fatal("telemetryMsg should return a pump cmd while a run is in flight") } m = next.(Model) // The event must be retained on the session's holder (not just the status // bar), otherwise /status could not render the telemetry section. if !s.telemetry.HasTelemetry() { t.Fatal("telemetry event should be folded into the session holder") } if s.telemetry.CumulativeTurns() != 3 { t.Errorf("CumulativeTurns = %d, want 3", s.telemetry.CumulativeTurns()) } // The run has since ended, returning the model to idle so the user can // issue /status (key presses are dropped while a run is in flight). m.running = false m.runCh = nil m = typeCommand(t, m, "/status") joined := strings.Join(blockTexts(m.transcript), "\n") for _, want := range []string{ "telemetry:", "since session start:", "turns: 3", "truncations: 1", "last run:", } { if !strings.Contains(joined, want) { t.Errorf("/status output missing %q after telemetry fold; transcript:\n%s", want, joined) } } if strings.Contains(joined, "no telemetry yet") { t.Error("/status should render real telemetry after a fold, not 'no telemetry yet'") } } // TestStatusNotInterceptedForStatusFoo verifies "/statusfoo" is NOT intercepted // as /status (mirroring the REPL's guard), so it resolves as an unknown command // rather than rendering the status report. func TestStatusNotInterceptedForStatusFoo(t *testing.T) { m := NewModel(Options{}) m = typeCommand(t, m, "/statusfoo") joined := strings.Join(blockTexts(m.transcript), "\n") if strings.Contains(joined, "runtime config:") { t.Errorf("/statusfoo must not run the status command; transcript:\n%s", joined) } }