first commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package status
|
||||
|
||||
// fakeHost satisfies cli.Host by embedding the interface (so every method is
|
||||
// present) while overriding only the accessors RunStatus reads: Live, Header,
|
||||
// AgentCtx, Cwd, Trust, Slash, Creds, Telemetry. The embedded nil interface
|
||||
// would panic if any other method were called, which these tests never do.
|
||||
// This lets the status tests run without the package-main REPL harness.
|
||||
|
||||
import (
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
"github.com/smallnest/pigo/internal/provider"
|
||||
"github.com/smallnest/pigo/internal/runtime"
|
||||
"github.com/smallnest/pigo/internal/session"
|
||||
"github.com/smallnest/pigo/internal/trust"
|
||||
)
|
||||
|
||||
type fakeHost struct {
|
||||
cli.Host
|
||||
live *cli.LiveConfig
|
||||
header session.SessionHeader
|
||||
agentCtx *agentcore.AgentContext
|
||||
cwd string
|
||||
trust *trust.Manager
|
||||
slash *runtime.SlashRegistry
|
||||
creds *provider.CredentialStore
|
||||
telemetry *cli.TelemetryHolder
|
||||
}
|
||||
|
||||
func (f *fakeHost) Live() *cli.LiveConfig { return f.live }
|
||||
func (f *fakeHost) Header() session.SessionHeader { return f.header }
|
||||
func (f *fakeHost) AgentCtx() *agentcore.AgentContext { return f.agentCtx }
|
||||
func (f *fakeHost) Cwd() string { return f.cwd }
|
||||
func (f *fakeHost) Trust() *trust.Manager { return f.trust }
|
||||
func (f *fakeHost) Slash() *runtime.SlashRegistry { return f.slash }
|
||||
func (f *fakeHost) Creds() *provider.CredentialStore { return f.creds }
|
||||
func (f *fakeHost) Telemetry() *cli.TelemetryHolder { return f.telemetry }
|
||||
|
||||
// newFakeHost builds a fakeHost with empty-but-non-nil live config, agent
|
||||
// context, slash registry and credential store, mirroring a fresh session.
|
||||
// Tests customize the returned host's fields before calling RunStatus.
|
||||
func newFakeHost() *fakeHost {
|
||||
return &fakeHost{
|
||||
live: &cli.LiveConfig{},
|
||||
agentCtx: &agentcore.AgentContext{},
|
||||
slash: runtime.NewSlashRegistry(),
|
||||
creds: provider.NewCredentialStore(nil),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// This file implements the /status slash command (US-002, #292) that prints a
|
||||
// colored multi-section status report with runtime config, context usage, and more.
|
||||
//
|
||||
// It reaches the session's collaborators and mutable state through the cli.Host
|
||||
// contract (like /goal and /btw) rather than importing the concrete replDeps
|
||||
// aggregate, keeping the dependency single-direction (repl→status).
|
||||
package status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
"github.com/smallnest/pigo/internal/cli/ui"
|
||||
"github.com/smallnest/pigo/internal/compaction"
|
||||
"github.com/smallnest/pigo/internal/runtime"
|
||||
"github.com/smallnest/pigo/internal/trust"
|
||||
)
|
||||
|
||||
// RunStatus prints a colored multi-section status report to out using data
|
||||
// read from host through the cli.Host accessors. It shows runtime config,
|
||||
// context usage, project/environment, credentials, and telemetry.
|
||||
func RunStatus(out io.Writer, host cli.Host) {
|
||||
color := ui.Enabled()
|
||||
|
||||
fmt.Fprintln(out)
|
||||
printRuntimeConfig(out, color, host)
|
||||
fmt.Fprintln(out)
|
||||
printContextStatus(out, color, host)
|
||||
fmt.Fprintln(out)
|
||||
printEnvStatus(out, color, host)
|
||||
fmt.Fprintln(out)
|
||||
printCredentialsStatus(out, color, host)
|
||||
fmt.Fprintln(out)
|
||||
printTelemetryStatus(out, color, host)
|
||||
}
|
||||
|
||||
// printRuntimeConfig prints the runtime model configuration section.
|
||||
func printRuntimeConfig(out io.Writer, color bool, host cli.Host) {
|
||||
live := host.Live()
|
||||
header := host.Header()
|
||||
model := live.Model
|
||||
providerName := live.ProviderName
|
||||
baseURL := live.BaseURL
|
||||
protocol := live.Protocol
|
||||
thinkingLevel := string(live.ThinkingLevel)
|
||||
contextWindow := live.ContextWindow
|
||||
|
||||
if model == "" {
|
||||
model = header.Model
|
||||
}
|
||||
if providerName == "" {
|
||||
providerName = header.Provider
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "(default)"
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = "(default)"
|
||||
}
|
||||
if thinkingLevel == "" {
|
||||
thinkingLevel = "(default)"
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(color, ui.Bold, "runtime config:"))
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "model:"), model)
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "provider:"), providerName)
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "base URL:"), baseURL)
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "protocol:"), protocol)
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "thinking:"), thinkingLevel)
|
||||
if contextWindow > 0 {
|
||||
fmt.Fprintf(out, " %s %d tokens\n", ui.Colorize(color, ui.Dim, "context window:"), contextWindow)
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s unknown\n", ui.Colorize(color, ui.Dim, "context window:"))
|
||||
}
|
||||
}
|
||||
|
||||
// printContextStatus prints the current context usage and compaction section.
|
||||
func printContextStatus(out io.Writer, color bool, host cli.Host) {
|
||||
msgs := host.AgentCtx().Messages
|
||||
tokens := compaction.EstimateContextTokens(msgs).Tokens
|
||||
contextWindow := host.Live().ContextWindow
|
||||
|
||||
compactions := 0
|
||||
for _, m := range msgs {
|
||||
if _, ok := m.(agentcore.CompactionMessage); ok {
|
||||
compactions++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(color, ui.Bold, "context:"))
|
||||
fmt.Fprintf(out, " %s %d / %d tokens\n", ui.Colorize(color, ui.Dim, "current:"), tokens, contextWindow)
|
||||
|
||||
// Calculate utilization percentage if possible
|
||||
if contextWindow > 0 {
|
||||
utilization := int(float64(tokens) / float64(contextWindow) * 100)
|
||||
utilColor := ""
|
||||
if utilization >= 90 {
|
||||
utilColor = ui.Red
|
||||
} else if utilization >= 70 {
|
||||
utilColor = ui.Yellow
|
||||
} else {
|
||||
utilColor = ui.Green
|
||||
}
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "utilization:"), ui.Colorize(color, utilColor, fmt.Sprintf("%d%%", utilization)))
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "utilization:"), ui.Colorize(color, ui.Yellow, "unknown"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, " %s %d\n", ui.Colorize(color, ui.Dim, "compactions:"), compactions)
|
||||
|
||||
// Calculate remaining tokens before auto-compaction
|
||||
if contextWindow > 0 {
|
||||
reserve := compaction.DefaultCompactionSettings.ReserveTokens
|
||||
threshold := contextWindow - reserve
|
||||
remaining := threshold - tokens
|
||||
if remaining < 0 {
|
||||
fmt.Fprintf(out, " %s %s (threshold: %d, reserve: %d)\n",
|
||||
ui.Colorize(color, ui.Dim, "before compact:"),
|
||||
ui.Colorize(color, ui.Red, fmt.Sprintf("%d over threshold", -remaining)),
|
||||
threshold,
|
||||
reserve,
|
||||
)
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s %s (threshold: %d, reserve: %d)\n",
|
||||
ui.Colorize(color, ui.Dim, "before compact:"),
|
||||
ui.Colorize(color, ui.Green, fmt.Sprintf("%d tokens remaining", remaining)),
|
||||
threshold,
|
||||
reserve,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s %s\n",
|
||||
ui.Colorize(color, ui.Dim, "before compact:"),
|
||||
ui.Colorize(color, ui.Yellow, "auto-compaction disabled (unknown window)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// printEnvStatus prints the project & environment section: cwd, trust status,
|
||||
// and counts of loaded skills and plugins (with names). User command templates
|
||||
// are listed separately when present.
|
||||
func printEnvStatus(out io.Writer, color bool, host cli.Host) {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(color, ui.Bold, "project & environment:"))
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "cwd:"), host.Cwd())
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "trust:"), trustStatus(host.Trust(), host.Cwd()))
|
||||
|
||||
var skills, plugins, userCmds []string
|
||||
if slash := host.Slash(); slash != nil {
|
||||
for _, c := range slash.List() {
|
||||
switch c.Source {
|
||||
case runtime.SourceSkill:
|
||||
skills = append(skills, c.Name)
|
||||
case runtime.SourcePlugin:
|
||||
plugins = append(plugins, c.Name)
|
||||
case runtime.SourceUser:
|
||||
userCmds = append(userCmds, c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, " %s %d%s\n", ui.Colorize(color, ui.Dim, "skills:"), len(skills), namesSuffix(skills))
|
||||
fmt.Fprintf(out, " %s %d%s\n", ui.Colorize(color, ui.Dim, "plugins:"), len(plugins), namesSuffix(plugins))
|
||||
if len(userCmds) > 0 {
|
||||
fmt.Fprintf(out, " %s %d%s\n", ui.Colorize(color, ui.Dim, "user commands:"), len(userCmds), namesSuffix(userCmds))
|
||||
}
|
||||
}
|
||||
|
||||
// trustStatus classifies the cwd's trust state for display: disabled when trust
|
||||
// is off, trusted when IsTrusted is true (session grant or saved Trusted),
|
||||
// untrusted when a saved Untrusted decision applies, else prompt (undecided).
|
||||
func trustStatus(mgr *trust.Manager, cwd string) string {
|
||||
if mgr == nil {
|
||||
return "disabled"
|
||||
}
|
||||
if mgr.IsTrusted(cwd) {
|
||||
return "trusted"
|
||||
}
|
||||
if res := mgr.NearestTrustDecision(cwd); res.Found && res.Decision == trust.Untrusted {
|
||||
return "untrusted"
|
||||
}
|
||||
return "prompt"
|
||||
}
|
||||
|
||||
// namesSuffix renders " (n1, n2, ...)" for a non-empty name list, capping at 8
|
||||
// names with "+k more". It returns "" for an empty list.
|
||||
func namesSuffix(names []string) string {
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(names)
|
||||
const max = 8
|
||||
if len(names) <= max {
|
||||
return " (" + strings.Join(names, ", ") + ")"
|
||||
}
|
||||
return fmt.Sprintf(" (%s, +%d more)", strings.Join(names[:max], ", "), len(names)-max)
|
||||
}
|
||||
|
||||
// printCredentialsStatus prints the credentials & connectivity section: API key
|
||||
// presence (masked, never plaintext) and the provider endpoint URL.
|
||||
func printCredentialsStatus(out io.Writer, color bool, host cli.Host) {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(color, ui.Bold, "credentials & connectivity:"))
|
||||
live := host.Live()
|
||||
creds := host.Creds()
|
||||
provider := live.ProviderName
|
||||
if creds != nil && creds.HasCredential(context.Background(), provider) {
|
||||
key := creds.GetAPIKey(context.Background(), provider)
|
||||
fmt.Fprintf(out, " %s %s %s\n",
|
||||
ui.Colorize(color, ui.Dim, "api key:"),
|
||||
ui.Colorize(color, ui.Green, "set"),
|
||||
ui.Colorize(color, ui.Dim, maskKey(key)))
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s %s\n",
|
||||
ui.Colorize(color, ui.Dim, "api key:"),
|
||||
ui.Colorize(color, ui.Yellow, "not set"))
|
||||
}
|
||||
endpoint := live.BaseURL
|
||||
if endpoint == "" {
|
||||
endpoint = "(default)"
|
||||
}
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "endpoint:"), endpoint)
|
||||
}
|
||||
|
||||
// maskKey returns a masked hint of an API key showing only the last 4 chars
|
||||
// (e.g. "••••abcd"). A key of 4 chars or fewer is masked entirely. It never
|
||||
// returns the full key.
|
||||
func maskKey(key string) string {
|
||||
const tail = 4
|
||||
r := []rune(key)
|
||||
if len(r) <= tail {
|
||||
return strings.Repeat("•", len(r))
|
||||
}
|
||||
return strings.Repeat("•", tail) + string(r[len(r)-tail:])
|
||||
}
|
||||
|
||||
// printTelemetryStatus prints the telemetry section with two sub-blocks -
|
||||
// cumulative (since session start) and last run - each showing turn count,
|
||||
// truncation/compaction counts, context utilization, and a per-tool table.
|
||||
// Both blocks show "no telemetry yet" before any run has completed.
|
||||
func printTelemetryStatus(out io.Writer, color bool, host cli.Host) {
|
||||
fmt.Fprintf(out, "%s\n", ui.Colorize(color, ui.Bold, "telemetry:"))
|
||||
holder := host.Telemetry()
|
||||
if holder == nil || !holder.HasTelemetry() {
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "since session start:"), ui.Colorize(color, ui.Dim, "no telemetry yet"))
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "last run:"), ui.Colorize(color, ui.Dim, "no telemetry yet"))
|
||||
return
|
||||
}
|
||||
printTelemetryBlock(out, color, "since session start:",
|
||||
holder.CumulativeTurns(),
|
||||
holder.CumulativeTruncationCount(),
|
||||
holder.CumulativeCompactionCount(),
|
||||
holder.CumulativeContextUtilization(),
|
||||
holder.CumulativeToolDurations(),
|
||||
)
|
||||
if last := holder.Last(); last != nil {
|
||||
printTelemetryBlock(out, color, "last run:",
|
||||
last.Turns,
|
||||
last.TruncationCount,
|
||||
last.CompactionCount,
|
||||
last.ContextUtilization,
|
||||
last.ToolDurationsMs,
|
||||
)
|
||||
} else {
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "last run:"), ui.Colorize(color, ui.Dim, "no telemetry yet"))
|
||||
}
|
||||
}
|
||||
|
||||
// printTelemetryBlock renders one telemetry sub-block (cumulative or last run).
|
||||
func printTelemetryBlock(out io.Writer, color bool, label string, turns, trunc, compact int, util float64, tools map[string]agentcore.ToolTiming) {
|
||||
fmt.Fprintf(out, " %s\n", ui.Colorize(color, ui.Cyan, label))
|
||||
fmt.Fprintf(out, " %s %d\n", ui.Colorize(color, ui.Dim, "turns:"), turns)
|
||||
fmt.Fprintf(out, " %s %d\n", ui.Colorize(color, ui.Dim, "truncations:"), trunc)
|
||||
fmt.Fprintf(out, " %s %d\n", ui.Colorize(color, ui.Dim, "compactions:"), compact)
|
||||
fmt.Fprintf(out, " %s %s\n", ui.Colorize(color, ui.Dim, "utilization:"), fmt.Sprintf("%.0f%%", util*100))
|
||||
if len(tools) == 0 {
|
||||
fmt.Fprintf(out, " %s (none)\n", ui.Colorize(color, ui.Dim, "tools:"))
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(tools))
|
||||
for n := range tools {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(out, " %s\n", ui.Colorize(color, ui.Dim, "tools:"))
|
||||
for _, n := range names {
|
||||
t := tools[n]
|
||||
fmt.Fprintf(out, " %-12s %3d calls %dms\n", n, t.Count, t.TotalMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// This file holds the end-to-end and edge-case tests for /status (US-005, #295):
|
||||
// fresh-session behavior, model-switch reflection, telemetry reset on session
|
||||
// switch, and render timing, exercised through direct RunStatus calls with a
|
||||
// fake host. The REPL-intercept and headless-flag tests live in package main.
|
||||
package status
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
)
|
||||
|
||||
// TestStatusFreshSessionAllSections verifies /status renders every section on a
|
||||
// brand-new session before any model turn, with "no telemetry yet", and does not
|
||||
// panic.
|
||||
func TestStatusFreshSessionAllSections(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.cwd = "/tmp/e2e-fresh"
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"runtime config:",
|
||||
"context:",
|
||||
"project & environment:",
|
||||
"credentials & connectivity:",
|
||||
"telemetry:",
|
||||
"no telemetry yet",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("fresh-session /status: expected output to contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusReflectsModelSwitch verifies /status reads the live run config each
|
||||
// invocation, so a /model switch (which mutates live.Model/providerName) is
|
||||
// reflected on the next /status without a restart.
|
||||
func TestStatusReflectsModelSwitch(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.live.Model = "model-a"
|
||||
host.live.ProviderName = "prov-a"
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
if out := buf.String(); !strings.Contains(out, "model: model-a") || !strings.Contains(out, "provider: prov-a") {
|
||||
t.Errorf("expected model-a/prov-a, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Simulate a /model switch mutating the live config.
|
||||
host.live.Model = "model-b"
|
||||
host.live.ProviderName = "prov-b"
|
||||
buf.Reset()
|
||||
RunStatus(&buf, host)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "model: model-b") || !strings.Contains(out, "provider: prov-b") {
|
||||
t.Errorf("expected model-b/prov-b after switch, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "model: model-a") {
|
||||
t.Errorf("stale model-a still present after switch:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusTelemetryResetOnFork verifies that after the telemetry holder is
|
||||
// reset (as runForkClone/runImport do on /fork, /clone, /import - wired in
|
||||
// #291), /status shows "no telemetry yet" again, so cumulative stats do not
|
||||
// bleed across conversations.
|
||||
func TestStatusTelemetryResetOnFork(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
|
||||
holder := cli.NewTelemetryHolder()
|
||||
holder.Fold(agentcore.TelemetryEvent{
|
||||
Turns: 3,
|
||||
TruncationCount: 1,
|
||||
CompactionCount: 0,
|
||||
ContextUtilization: 0.42,
|
||||
ContextTokens: 53760,
|
||||
ContextWindow: 128000,
|
||||
ToolDurationsMs: map[string]agentcore.ToolTiming{"bash": {Count: 2, TotalMs: 150}},
|
||||
})
|
||||
host.telemetry = holder
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
if out := buf.String(); !strings.Contains(out, "turns: 3") {
|
||||
t.Errorf("expected 'turns: 3' before reset, got:\n%s", out)
|
||||
}
|
||||
|
||||
// /fork, /clone, /import all call holder.Reset() (runForkClone/runImport).
|
||||
holder.Reset()
|
||||
buf.Reset()
|
||||
RunStatus(&buf, host)
|
||||
out := buf.String()
|
||||
if n := strings.Count(out, "no telemetry yet"); n != 2 {
|
||||
t.Errorf("expected 2 'no telemetry yet' after reset (cumulative + last run), got %d:\n%s", n, out)
|
||||
}
|
||||
if strings.Contains(out, "turns: 3") {
|
||||
t.Errorf("stale telemetry after reset:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusTiming verifies /status renders fast (target <50ms; assert <100ms
|
||||
// to absorb CI runner variance). It is pure in-memory rendering - no disk or
|
||||
// network I/O on the hot path.
|
||||
func TestStatusTiming(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.cwd = "/tmp/e2e-timing"
|
||||
host.live.ContextWindow = 128000
|
||||
host.agentCtx.Messages = append(host.agentCtx.Messages,
|
||||
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hello")}},
|
||||
)
|
||||
holder := cli.NewTelemetryHolder()
|
||||
holder.Fold(agentcore.TelemetryEvent{
|
||||
Turns: 5,
|
||||
ContextUtilization: 0.5,
|
||||
ContextTokens: 64000,
|
||||
ContextWindow: 128000,
|
||||
ToolDurationsMs: map[string]agentcore.ToolTiming{"bash": {Count: 3, TotalMs: 210}, "read": {Count: 6, TotalMs: 90}},
|
||||
})
|
||||
host.telemetry = holder
|
||||
|
||||
// Warm once (allocs), then measure.
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
buf.Reset()
|
||||
|
||||
start := time.Now()
|
||||
RunStatus(&buf, host)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed >= 100*time.Millisecond {
|
||||
t.Errorf("/status render took %v, want <100ms (target <50ms)", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/cli"
|
||||
"github.com/smallnest/pigo/internal/compaction"
|
||||
"github.com/smallnest/pigo/internal/runtime"
|
||||
)
|
||||
|
||||
func TestRunStatus(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.live.Model = "test-model"
|
||||
host.live.ProviderName = "test-provider"
|
||||
host.live.BaseURL = "https://api.example.com"
|
||||
host.live.Protocol = "anthropic"
|
||||
host.live.ContextWindow = 128000
|
||||
|
||||
host.agentCtx.Messages = append(host.agentCtx.Messages,
|
||||
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hello")}},
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "runtime config:") {
|
||||
t.Error("expected output to contain 'runtime config:'")
|
||||
}
|
||||
if !strings.Contains(output, "model: test-model") {
|
||||
t.Error("expected output to contain 'model: test-model'")
|
||||
}
|
||||
if !strings.Contains(output, "provider: test-provider") {
|
||||
t.Error("expected output to contain 'provider: test-provider'")
|
||||
}
|
||||
if !strings.Contains(output, "base URL: https://api.example.com") {
|
||||
t.Error("expected output to contain 'base URL: https://api.example.com'")
|
||||
}
|
||||
if !strings.Contains(output, "protocol: anthropic") {
|
||||
t.Error("expected output to contain 'protocol: anthropic'")
|
||||
}
|
||||
if !strings.Contains(output, "context window: 128000 tokens") {
|
||||
t.Error("expected output to contain 'context window: 128000 tokens'")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "context:") {
|
||||
t.Error("expected output to contain 'context:'")
|
||||
}
|
||||
if !strings.Contains(output, "current:") {
|
||||
t.Error("expected output to contain 'current:'")
|
||||
}
|
||||
if !strings.Contains(output, "utilization:") {
|
||||
t.Error("expected output to contain 'utilization:'")
|
||||
}
|
||||
if !strings.Contains(output, "compactions: 0") {
|
||||
t.Error("expected output to contain 'compactions: 0'")
|
||||
}
|
||||
if !strings.Contains(output, "before compact:") {
|
||||
t.Error("expected output to contain 'before compact:'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusWithCompaction(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.live.ContextWindow = 128000
|
||||
|
||||
host.agentCtx.Messages = append(host.agentCtx.Messages,
|
||||
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hello")}},
|
||||
agentcore.CompactionMessage{Summary: "compacted history"},
|
||||
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("more")}},
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "compactions: 1") {
|
||||
t.Error("expected output to contain 'compactions: 1'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusUnknownContextWindow(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.live.ContextWindow = 0 // unknown
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "context window: unknown") {
|
||||
t.Error("expected output to contain 'context window: unknown'")
|
||||
}
|
||||
if !strings.Contains(output, "auto-compaction disabled") {
|
||||
t.Error("expected output to contain 'auto-compaction disabled'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCompactCalculation(t *testing.T) {
|
||||
reserve := compaction.DefaultCompactionSettings.ReserveTokens
|
||||
if reserve != 16384 {
|
||||
t.Errorf("expected reserve tokens to be 16384, got %d", reserve)
|
||||
}
|
||||
|
||||
contextWindow := 128000
|
||||
threshold := contextWindow - reserve
|
||||
if threshold != 128000-16384 {
|
||||
t.Errorf("expected threshold to be 128000-16384=%d, got %d", 128000-16384, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusEnvAndCreds(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.cwd = "/tmp/test-cwd"
|
||||
host.live.ProviderName = "test-provider"
|
||||
host.live.BaseURL = "https://api.example.com"
|
||||
|
||||
host.slash.AddSkill(runtime.SlashCommand{Name: "my-skill", Expand: func(string) string { return "" }})
|
||||
host.slash.AddPlugin(runtime.SlashCommand{Name: "my-plugin", Run: func(string) (string, string) { return "", "" }})
|
||||
|
||||
host.creds.SetOverride("test-provider", "sk-secretkey-wxyz")
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "project & environment:") {
|
||||
t.Error("expected 'project & environment:' section")
|
||||
}
|
||||
if !strings.Contains(output, "cwd: /tmp/test-cwd") {
|
||||
t.Error("expected 'cwd: /tmp/test-cwd'")
|
||||
}
|
||||
if !strings.Contains(output, "trust: disabled") {
|
||||
t.Error("expected 'trust: disabled' when trust manager is nil")
|
||||
}
|
||||
if !strings.Contains(output, "skills: 1 (my-skill)") {
|
||||
t.Error("expected 'skills: 1 (my-skill)'")
|
||||
}
|
||||
if !strings.Contains(output, "plugins: 1 (my-plugin)") {
|
||||
t.Error("expected 'plugins: 1 (my-plugin)'")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "credentials & connectivity:") {
|
||||
t.Error("expected 'credentials & connectivity:' section")
|
||||
}
|
||||
if !strings.Contains(output, "api key: set") {
|
||||
t.Error("expected 'api key: set'")
|
||||
}
|
||||
if !strings.Contains(output, "••••wxyz") {
|
||||
t.Error("expected masked key '••••wxyz'")
|
||||
}
|
||||
if strings.Contains(output, "sk-secretkey-wxyz") {
|
||||
t.Error("full API key leaked into /status output")
|
||||
}
|
||||
if !strings.Contains(output, "endpoint: https://api.example.com") {
|
||||
t.Error("expected 'endpoint: https://api.example.com'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusTelemetryNoData(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
// host.telemetry is nil.
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if !strings.Contains(output, "telemetry:") {
|
||||
t.Error("expected 'telemetry:' section")
|
||||
}
|
||||
if n := strings.Count(output, "no telemetry yet"); n != 2 {
|
||||
t.Errorf("expected 2 'no telemetry yet' (cumulative + last run), got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatusTelemetryPopulated(t *testing.T) {
|
||||
host := newFakeHost()
|
||||
host.live.ContextWindow = 128000
|
||||
|
||||
holder := cli.NewTelemetryHolder()
|
||||
holder.Fold(agentcore.TelemetryEvent{
|
||||
Turns: 3,
|
||||
TruncationCount: 1,
|
||||
CompactionCount: 0,
|
||||
ContextUtilization: 0.42,
|
||||
ContextTokens: 53760,
|
||||
ContextWindow: 128000,
|
||||
ToolDurationsMs: map[string]agentcore.ToolTiming{
|
||||
"bash": {Count: 2, TotalMs: 150},
|
||||
"read": {Count: 4, TotalMs: 80},
|
||||
},
|
||||
})
|
||||
host.telemetry = holder
|
||||
|
||||
var buf bytes.Buffer
|
||||
RunStatus(&buf, host)
|
||||
output := buf.String()
|
||||
|
||||
if strings.Contains(output, "no telemetry yet") {
|
||||
t.Error("did not expect 'no telemetry yet' when telemetry is populated")
|
||||
}
|
||||
if !strings.Contains(output, "since session start:") {
|
||||
t.Error("expected 'since session start:' cumulative block")
|
||||
}
|
||||
if !strings.Contains(output, "last run:") {
|
||||
t.Error("expected 'last run:' block")
|
||||
}
|
||||
if !strings.Contains(output, "turns: 3") {
|
||||
t.Error("expected 'turns: 3' (last run == cumulative after one run)")
|
||||
}
|
||||
if !strings.Contains(output, "bash") || !strings.Contains(output, "2 calls") || !strings.Contains(output, "150ms") {
|
||||
t.Error("expected bash tool row with '2 calls' / '150ms'")
|
||||
}
|
||||
if !strings.Contains(output, "utilization: 42%") {
|
||||
t.Error("expected 'utilization: 42%'")
|
||||
}
|
||||
|
||||
holder.Fold(agentcore.TelemetryEvent{
|
||||
Turns: 2,
|
||||
TruncationCount: 0,
|
||||
CompactionCount: 1,
|
||||
ContextUtilization: 0.5,
|
||||
ContextTokens: 64000,
|
||||
ContextWindow: 128000,
|
||||
ToolDurationsMs: map[string]agentcore.ToolTiming{
|
||||
"bash": {Count: 1, TotalMs: 40},
|
||||
},
|
||||
})
|
||||
buf.Reset()
|
||||
RunStatus(&buf, host)
|
||||
output = buf.String()
|
||||
if !strings.Contains(output, "turns: 5") {
|
||||
t.Error("expected cumulative 'turns: 5' after two runs")
|
||||
}
|
||||
if !strings.Contains(output, "turns: 2") {
|
||||
t.Error("expected last-run 'turns: 2' after two runs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskKey(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"sk-secretkey-wxyz", "••••wxyz"},
|
||||
{"abcd", "••••"}, // exactly 4 -> masked entirely
|
||||
{"ab", "••"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := maskKey(c.in); got != c.want {
|
||||
t.Errorf("maskKey(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user