first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
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)
}
}