f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
920 lines
28 KiB
Go
920 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
)
|
|
|
|
// renderStatsHuman renders stats through printStatsHuman and returns the
|
|
// captured output, asserting the render itself does not error.
|
|
func renderStatsHuman(t *testing.T, stats *db.SessionStats) string {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
require.NoError(t, printStatsHuman(&buf, stats), "printStatsHuman")
|
|
return buf.String()
|
|
}
|
|
|
|
// assertContainsAll asserts every want substring is present in out.
|
|
func assertContainsAll(t *testing.T, out string, wants ...string) {
|
|
t.Helper()
|
|
for _, w := range wants {
|
|
assert.Contains(t, out, w, "missing %q in output", w)
|
|
}
|
|
}
|
|
|
|
// assertContainsNone asserts none of the banned substrings appear in out.
|
|
func assertContainsNone(t *testing.T, out string, banned ...string) {
|
|
t.Helper()
|
|
for _, b := range banned {
|
|
assert.NotContains(t, out, b, "unexpected %q in output", b)
|
|
}
|
|
}
|
|
|
|
// setupGoldenStatsDataDir creates a temp data dir, points
|
|
// AGENTSVIEW_DATA_DIR at it, pins TZ to UTC for deterministic time
|
|
// formatting, seeds the golden fixture DB, and returns the data dir.
|
|
// Shared by the stats, usage, and activity command tests that exercise
|
|
// the offline/read-only archive query path.
|
|
func setupGoldenStatsDataDir(t *testing.T) string {
|
|
t.Helper()
|
|
dataDir := newAgentDataDir(t)
|
|
t.Setenv("AGENTSVIEW_CURSOR_ATTRIBUTION_DB",
|
|
filepath.Join(dataDir, "missing-cursor-attribution.db"))
|
|
// TZ is normally pinned by --timezone=UTC, but the environment can
|
|
// still leak into date parsing on some platforms; pin it too.
|
|
t.Setenv("TZ", "UTC")
|
|
copyGoldenFixtureDB(t, sessionsDBPath(dataDir))
|
|
return dataDir
|
|
}
|
|
|
|
// runDefaultStatsJSON runs the `stats --format json` CLI path over the
|
|
// golden fixture window and returns the raw JSON output. Callers
|
|
// unmarshal into whichever shape they need.
|
|
func runDefaultStatsJSON(t *testing.T) string {
|
|
t.Helper()
|
|
registerSQLiteDaemonRuntime(t, os.Getenv("AGENTSVIEW_DATA_DIR"))
|
|
out, err := executeCommand(newRootCommand(),
|
|
"stats",
|
|
"--format", "json",
|
|
"--since", "2026-04-01",
|
|
"--until", "2026-04-15",
|
|
"--timezone", "UTC",
|
|
)
|
|
require.NoError(t, err, "stats output:\n%s", out)
|
|
return out
|
|
}
|
|
|
|
// writeCustomModelPricingConfig writes a config.toml under dataDir that sets
|
|
// custom per-million-token pricing well above the built-in defaults, so tests
|
|
// can assert that custom pricing is applied.
|
|
func writeCustomModelPricingConfig(t *testing.T, dataDir string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dataDir, "config.toml"),
|
|
[]byte(`
|
|
[custom_model_pricing."claude-sonnet-4-20250514"]
|
|
input = 300.0
|
|
output = 1500.0
|
|
cache_creation = 375.0
|
|
cache_read = 30.0
|
|
|
|
[custom_model_pricing."claude-opus-4-20250514"]
|
|
input = 1500.0
|
|
output = 7500.0
|
|
cache_creation = 1875.0
|
|
cache_read = 150.0
|
|
`),
|
|
0o600,
|
|
))
|
|
}
|
|
|
|
// TestPrintStatsHuman_Populated exercises the happy path with
|
|
// every optional section present. It does not pin exact text — the
|
|
// golden-file test in Task 20 owns that — but it guards the sections
|
|
// and nil-pointer branches that are hardest to eyeball in the stub.
|
|
func TestPrintStatsHuman_Populated(t *testing.T) {
|
|
prsOpened := 12
|
|
prsMerged := 9
|
|
stats := &db.SessionStats{
|
|
SchemaVersion: 1,
|
|
Window: db.StatsWindow{
|
|
Since: "2026-03-21T00:00:00Z",
|
|
Until: "2026-04-18T00:00:00Z",
|
|
Days: 28,
|
|
},
|
|
Filters: db.StatsFilters{
|
|
Agent: "all",
|
|
ProjectsExcluded: []string{},
|
|
Timezone: "America/New_York",
|
|
},
|
|
Totals: db.StatsTotals{
|
|
SessionsAll: 11905,
|
|
SessionsHuman: 322,
|
|
SessionsAutomation: 11583,
|
|
MessagesTotal: 109324,
|
|
UserMessagesTotal: 3012,
|
|
},
|
|
Archetypes: db.StatsArchetypes{
|
|
Automation: 11583,
|
|
Quick: 125,
|
|
Standard: 101,
|
|
Deep: 79,
|
|
Marathon: 17,
|
|
Primary: "automation",
|
|
PrimaryHuman: "quick",
|
|
},
|
|
Distributions: db.StatsDistributions{
|
|
DurationMinutes: db.ScopedDistributionPair{
|
|
ScopeAll: db.ScopedDistribution{Mean: 14.7},
|
|
ScopeHuman: db.ScopedDistribution{Mean: 22.0},
|
|
},
|
|
UserMessages: db.ScopedDistributionPair{
|
|
ScopeAll: db.ScopedDistribution{Mean: 11.2},
|
|
ScopeHuman: db.ScopedDistribution{Mean: 7.2},
|
|
},
|
|
PeakContextTokens: db.PeakContextDistribution{
|
|
ScopeAll: db.ScopedDistribution{Mean: 48000},
|
|
NullCount: 0,
|
|
},
|
|
ToolsPerTurn: db.ScopedDistributionPair{
|
|
ScopeAll: db.ScopedDistribution{Mean: 2.3},
|
|
},
|
|
},
|
|
Velocity: db.StatsVelocity{
|
|
TurnCycleSeconds: db.StatsPercentiles{
|
|
P50: 20, P90: 90, Mean: 45,
|
|
},
|
|
FirstResponseSeconds: db.StatsPercentiles{
|
|
P50: 5, P90: 15, Mean: 8,
|
|
},
|
|
MessagesPerActiveHour: 120.0,
|
|
},
|
|
ToolMix: db.StatsToolMix{
|
|
ByCategory: map[string]int{
|
|
"Bash": 1234, "Edit": 876, "Read": 543,
|
|
"Grep": 321, "Glob": 210, "Write": 50,
|
|
},
|
|
TotalCalls: 3234,
|
|
},
|
|
ModelMix: db.StatsModelMix{
|
|
ByTokens: map[string]int64{
|
|
"claude-opus-4-7": 5600000,
|
|
"claude-sonnet-4-6": 1200000,
|
|
},
|
|
},
|
|
AgentPortfolio: db.StatsAgentPortfolio{
|
|
BySessions: map[string]int{"claude": 11905, "codex": 234},
|
|
ByTokens: map[string]int64{"claude": 6800000, "codex": 120000},
|
|
ByMessages: map[string]int{"claude": 109000, "codex": 2100},
|
|
Primary: "claude",
|
|
},
|
|
CacheEconomics: &db.StatsCacheEconomics{
|
|
ClaudeOnly: true,
|
|
CacheHitRatio: db.CacheHitRatioDistribution{
|
|
Overall: 0.78,
|
|
},
|
|
DollarsSavedVsUncached: 88.54,
|
|
DollarsSpent: 42.13,
|
|
},
|
|
Adoption: &db.StatsAdoption{
|
|
ClaudeOnly: true,
|
|
PlanModeRate: 0.12,
|
|
SubagentsPerSession: 0.3,
|
|
DistinctSkills: 8,
|
|
},
|
|
Temporal: db.StatsTemporal{
|
|
HourlyUTC: []db.TemporalHourlyUTCEntry{
|
|
{TS: "2026-04-01T00:00:00Z", Sessions: 3, UserMessages: 12},
|
|
{TS: "2026-04-01T01:00:00Z", Sessions: 2, UserMessages: 8},
|
|
},
|
|
ReporterTimezone: "America/New_York",
|
|
},
|
|
OutcomeStats: &db.StatsOutcomeStats{
|
|
ReposActive: 3,
|
|
Commits: 84,
|
|
LOCAdded: 5421,
|
|
LOCRemoved: 1823,
|
|
FilesChanged: 127,
|
|
PRsOpened: &prsOpened,
|
|
PRsMerged: &prsMerged,
|
|
},
|
|
Outcomes: &db.StatsOutcomes{
|
|
ClaudeOnly: true,
|
|
Success: 280,
|
|
Failure: 14,
|
|
Unknown: 28,
|
|
GradeDistribution: map[string]int{"A": 120, "B": 95, "C": 52, "D": 13, "F": 0},
|
|
ToolRetryRate: 0.064,
|
|
CompactionsPerSession: 0.1,
|
|
AvgEditChurn: 1.2,
|
|
},
|
|
CodeAttribution: &db.CodeAttribution{
|
|
Sources: []db.CodeAttributionSource{{
|
|
Provider: "cursor",
|
|
Scope: "machine_local",
|
|
Status: "available",
|
|
Metrics: &db.CursorAttributionMetrics{
|
|
ScoredCommits: 2,
|
|
LinesAdded: 30,
|
|
LinesDeleted: 12,
|
|
TabLinesAdded: 8,
|
|
TabLinesDeleted: 2,
|
|
ComposerLinesAdded: 3,
|
|
ComposerLinesDeleted: 1,
|
|
HumanLinesAdded: 19,
|
|
HumanLinesDeleted: 9,
|
|
BlankLinesAdded: 4,
|
|
BlankLinesDeleted: 0,
|
|
AIAuthoredPct: 11.0 / 30.0,
|
|
ConversationCounts: []db.CursorConversationCount{
|
|
{Model: "claude-3.5-sonnet", Mode: "composer", Count: 2},
|
|
{Model: "claude-3.5-sonnet", Mode: "tab", Count: 1},
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
GeneratedAt: "2026-04-18T00:00:00Z",
|
|
}
|
|
|
|
out := renderStatsHuman(t, stats)
|
|
require.GreaterOrEqual(t, len(out), 200,
|
|
"output suspiciously short (%d bytes):\n%s", len(out), out)
|
|
|
|
// Guard every major section header so accidental drops are caught.
|
|
assertContainsAll(t, out,
|
|
"Session window:",
|
|
"Totals",
|
|
"Archetypes",
|
|
"Session shape",
|
|
"Velocity",
|
|
"Tool mix",
|
|
"Model mix",
|
|
"Agent portfolio",
|
|
"Cache economics",
|
|
"Adoption",
|
|
"Temporal",
|
|
"Outcome stats",
|
|
"Outcomes",
|
|
"Code attribution",
|
|
)
|
|
|
|
// Thousands separators must be applied to large counts.
|
|
assert.Contains(t, out, "11,905",
|
|
"expected thousands separator for 11,905")
|
|
assert.Contains(t, out, "claude-3.5-sonnet / composer",
|
|
"expected cursor conversation row")
|
|
}
|
|
|
|
// TestPrintStatsHuman_Empty guards the zero-session short
|
|
// circuit: no optional sections, just the header + "no sessions".
|
|
func TestPrintStatsHuman_Empty(t *testing.T) {
|
|
stats := &db.SessionStats{
|
|
SchemaVersion: 1,
|
|
Window: db.StatsWindow{
|
|
Since: "2026-04-11T00:00:00Z",
|
|
Until: "2026-04-18T00:00:00Z",
|
|
Days: 7,
|
|
},
|
|
Filters: db.StatsFilters{
|
|
Agent: "all",
|
|
ProjectsExcluded: []string{},
|
|
Timezone: "UTC",
|
|
},
|
|
}
|
|
|
|
out := renderStatsHuman(t, stats)
|
|
assert.Contains(t, out, "no sessions",
|
|
"expected zero-session placeholder in output")
|
|
// No optional section headers should appear.
|
|
assertContainsNone(t, out,
|
|
"Archetypes", "Velocity", "Cache economics", "Outcomes",
|
|
"Code attribution")
|
|
}
|
|
|
|
// TestFmtInt64 covers the thousands-separator helper.
|
|
func TestFmtInt64(t *testing.T) {
|
|
cases := []struct {
|
|
in int64
|
|
want string
|
|
}{
|
|
{0, "0"},
|
|
{5, "5"},
|
|
{999, "999"},
|
|
{1000, "1,000"},
|
|
{12345, "12,345"},
|
|
{123456, "123,456"},
|
|
{1234567, "1,234,567"},
|
|
{-1234, "-1,234"},
|
|
}
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.want, fmtInt64(c.in),
|
|
"fmtInt64(%d)", c.in)
|
|
}
|
|
}
|
|
|
|
func TestStatsCommand_OutcomeFlagsRegistered(t *testing.T) {
|
|
cmd := newStatsCommand()
|
|
for _, name := range []string{
|
|
"include-git-outcomes",
|
|
"include-github-outcomes",
|
|
} {
|
|
assert.NotNil(t, cmd.Flags().Lookup(name),
|
|
"missing --%s flag", name)
|
|
}
|
|
}
|
|
|
|
func TestStatsCommandUsesDiscoveredDaemon(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var gotQuery url.Values
|
|
ts := daemonRouteTestServer(t, map[string]http.HandlerFunc{
|
|
"/api/v1/session-stats": func(w http.ResponseWriter, r *http.Request) {
|
|
gotQuery = r.URL.Query()
|
|
writeJSONResponse(w, `{
|
|
"schema_version": 1,
|
|
"window": {
|
|
"since": "2026-04-01T00:00:00Z",
|
|
"until": "2026-04-15T00:00:00Z",
|
|
"days": 14
|
|
},
|
|
"filters": {
|
|
"agent": "codex",
|
|
"projects_excluded": [],
|
|
"timezone": "UTC"
|
|
},
|
|
"totals": {"sessions_all": 5}
|
|
}`)
|
|
},
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out, err := executeCommand(newRootCommand(),
|
|
"stats",
|
|
"--format", "json",
|
|
"--since", "2026-04-01",
|
|
"--until", "2026-04-15",
|
|
"--agent", "codex",
|
|
"--timezone", "UTC",
|
|
)
|
|
|
|
require.NoError(t, err, "stats output:\n%s", out)
|
|
assert.Equal(t, "2026-04-01", gotQuery.Get("since"))
|
|
assert.Equal(t, "2026-04-15", gotQuery.Get("until"))
|
|
assert.Equal(t, "codex", gotQuery.Get("agent"))
|
|
assert.Equal(t, "UTC", gotQuery.Get("timezone"))
|
|
|
|
var got db.SessionStats
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
|
assert.Equal(t, 5, got.Totals.SessionsAll)
|
|
}
|
|
|
|
func TestStatsCommandReportsDaemonValidationError(t *testing.T) {
|
|
dataDir := newAgentDataDir(t)
|
|
|
|
var called bool
|
|
ts := daemonRouteTestServer(t, map[string]http.HandlerFunc{
|
|
"/api/v1/session-stats": func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
writeJSONResponse(w, `{"error":"invalid timezone: Fake/Zone"}`)
|
|
},
|
|
})
|
|
registerSyncRouteTestRuntime(t, dataDir, ts.URL)
|
|
|
|
out, err := executeCommand(newRootCommand(),
|
|
"stats",
|
|
"--format", "json",
|
|
"--timezone", "Fake/Zone",
|
|
)
|
|
|
|
require.Error(t, err, "stats output:\n%s", out)
|
|
assert.True(t, called, "stats should use the discovered daemon")
|
|
assert.Contains(t, err.Error(), "HTTP 400")
|
|
assert.Contains(t, err.Error(), "invalid timezone: Fake/Zone")
|
|
}
|
|
|
|
func TestStatsCommandSkipsReadOnlyDaemon(t *testing.T) {
|
|
dataDir := setupGoldenStatsDataDir(t)
|
|
|
|
var called bool
|
|
ts := daemonRouteTestServer(t, map[string]http.HandlerFunc{
|
|
"/api/v1/session-stats": func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
http.Error(w, "pg session stats unavailable", http.StatusNotImplemented)
|
|
},
|
|
})
|
|
registerTestRuntime(t, dataDir, ts.URL, true)
|
|
|
|
out, err := executeCommand(newRootCommand(),
|
|
"stats",
|
|
"--format", "json",
|
|
"--since", "2026-04-01",
|
|
"--until", "2026-04-15",
|
|
"--timezone", "UTC",
|
|
)
|
|
|
|
require.NoError(t, err, "stats output:\n%s", out)
|
|
assert.False(t, called, "read-only daemon stats endpoint should be skipped")
|
|
var got db.SessionStats
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
|
assert.Equal(t, 9, got.Totals.SessionsAll)
|
|
}
|
|
|
|
// updateGolden toggles regeneration of stats_golden.json.
|
|
// Pass `go test ./cmd/agentsview -run TestStatsGolden -update`
|
|
// after intentionally changing the fixture or the stats pipeline.
|
|
var updateGolden = flag.Bool(
|
|
"update", false,
|
|
"rewrite golden files under testdata/ instead of comparing",
|
|
)
|
|
|
|
// TestStatsGolden is the end-to-end guard for the v1 JSON schema: it
|
|
// seeds a deterministic fixture DB, runs the full `stats --format
|
|
// json` CLI path through the root command, and compares the parsed
|
|
// output to testdata/stats_golden.json.
|
|
//
|
|
// Determinism comes from four levers:
|
|
// 1. Absolute --since/--until dates so windowBounds never reads
|
|
// time.Now for the window boundary.
|
|
// 2. --timezone=UTC so Temporal.ReporterTimezone is a fixed string
|
|
// independent of the host's TZ env.
|
|
// 3. Session and message timestamps are absolute RFC3339 strings
|
|
// inside that window, so temporal.hourly_utc keys are stable.
|
|
// 4. GeneratedAt is stripped from both sides before comparison
|
|
// because GetSessionStats stamps it from time.Now().
|
|
//
|
|
// Regenerate after an intentional change with:
|
|
//
|
|
// go test ./cmd/agentsview -run TestStatsGolden -update
|
|
func TestStatsGolden(t *testing.T) {
|
|
setupGoldenStatsDataDir(t)
|
|
|
|
out := runDefaultStatsJSON(t)
|
|
|
|
var got map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got),
|
|
"unmarshal stats output, output:\n%s", out)
|
|
delete(got, "generated_at")
|
|
|
|
goldenPath := filepath.Join(
|
|
"testdata", "stats_golden.json",
|
|
)
|
|
if *updateGolden {
|
|
buf, err := json.MarshalIndent(got, "", " ")
|
|
require.NoError(t, err, "marshal golden")
|
|
buf = append(buf, '\n')
|
|
require.NoError(t, os.MkdirAll(
|
|
filepath.Dir(goldenPath), 0o755,
|
|
), "mkdir testdata")
|
|
require.NoError(t, os.WriteFile(
|
|
goldenPath, buf, 0o644,
|
|
), "write golden")
|
|
t.Logf("rewrote %s (%d bytes)", goldenPath, len(buf))
|
|
return
|
|
}
|
|
|
|
raw, err := os.ReadFile(goldenPath)
|
|
require.NoError(t, err, "read golden (run with -update to generate)")
|
|
var want map[string]any
|
|
require.NoError(t, json.Unmarshal(raw, &want), "unmarshal golden")
|
|
delete(want, "generated_at")
|
|
|
|
if !assert.Equal(t, want, got) {
|
|
gotBuf, _ := json.MarshalIndent(got, "", " ")
|
|
wantBuf, _ := json.MarshalIndent(want, "", " ")
|
|
t.Fatalf(
|
|
"stats JSON mismatch — regenerate with "+
|
|
"`go test ./cmd/agentsview -run "+
|
|
"TestStatsGolden -update` if intentional.\n"+
|
|
"--- got ---\n%s\n--- want ---\n%s",
|
|
gotBuf, wantBuf,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestStatsReadOnlyOpenAppliesCustomPricing(t *testing.T) {
|
|
dataDir := setupGoldenStatsDataDir(t)
|
|
writeCustomModelPricingConfig(t, dataDir)
|
|
|
|
out := runDefaultStatsJSON(t)
|
|
var got db.SessionStats
|
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
|
require.NotNil(t, got.CacheEconomics)
|
|
assert.Greater(t, got.CacheEconomics.DollarsSpent, 600.0,
|
|
"custom pricing should be applied to the read-only stats DB handle")
|
|
}
|
|
|
|
var (
|
|
goldenFixtureTemplateOnce sync.Once
|
|
goldenFixtureTemplateFiles map[string][]byte
|
|
goldenFixtureTemplateErr error
|
|
)
|
|
|
|
func copyGoldenFixtureDB(t *testing.T, dbPath string) {
|
|
t.Helper()
|
|
goldenFixtureTemplateOnce.Do(func() {
|
|
goldenFixtureTemplateFiles, goldenFixtureTemplateErr =
|
|
buildGoldenFixtureTemplateFiles(t)
|
|
})
|
|
require.NoError(t, goldenFixtureTemplateErr, "build golden fixture template")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(dbPath), 0o755),
|
|
"create golden fixture dir")
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
data, ok := goldenFixtureTemplateFiles[suffix]
|
|
if !ok {
|
|
continue
|
|
}
|
|
require.NoError(t, os.WriteFile(dbPath+suffix, data, 0o600),
|
|
"copy golden fixture db%s", suffix)
|
|
}
|
|
}
|
|
|
|
func buildGoldenFixtureTemplateFiles(t *testing.T) (map[string][]byte, error) {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "agentsview-golden-stats-*")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create golden fixture template dir: %w", err)
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
|
|
dbPath := filepath.Join(dir, "sessions.db")
|
|
d, err := db.Open(dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open golden fixture template db: %w", err)
|
|
}
|
|
closed := false
|
|
defer func() {
|
|
if !closed {
|
|
_ = d.Close()
|
|
}
|
|
}()
|
|
|
|
seedGoldenFixtureDB(t, d)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := d.CheckpointWALTruncate(ctx); err != nil {
|
|
return nil, fmt.Errorf("checkpoint golden fixture template: %w", err)
|
|
}
|
|
if err := d.Close(); err != nil {
|
|
return nil, fmt.Errorf("close golden fixture template: %w", err)
|
|
}
|
|
closed = true
|
|
|
|
files := make(map[string][]byte, 3)
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
data, err := os.ReadFile(dbPath + suffix)
|
|
if err != nil {
|
|
if suffix != "" && errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"read golden fixture template %s: %w",
|
|
dbPath+suffix, err,
|
|
)
|
|
}
|
|
files[suffix] = data
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// seedGoldenFixtureDB seeds a deterministic session set into a fresh
|
|
// SQLite database at dbPath. The fixture exercises the full v1 schema:
|
|
//
|
|
// - Three agents (claude, codex, cursor) so agent_portfolio has variety.
|
|
// - User-message counts spanning every archetype bucket so archetypes,
|
|
// distributions.user_messages, and primary/primary_human all resolve.
|
|
// - Two assistant models (sonnet, opus) so model_mix has >1 row.
|
|
// - A handful of peak_context_tokens values so peak_context has a
|
|
// non-zero mean and a non-zero null_count.
|
|
// - Tool calls across three categories plus the three adoption
|
|
// tool_names (ExitPlanMode, Task, Skill) so tool_mix and adoption
|
|
// are both populated.
|
|
// - A couple of sessions with outcome + health_grade set so
|
|
// outcomes.success / failure / grade_distribution are non-trivial.
|
|
//
|
|
// No cwd is set on any session so outcome_stats stays nil (git
|
|
// integration is out of scope for this test). No GH_TOKEN env is
|
|
// propagated, so PRsOpened/PRsMerged stay nil regardless.
|
|
func seedGoldenFixtureDB(t *testing.T, d *db.DB) {
|
|
t.Helper()
|
|
require.NoError(t, d.UpsertModelPricing([]db.ModelPricing{
|
|
{
|
|
ModelPattern: "claude-sonnet-4-20250514",
|
|
InputPerMTok: 3.0,
|
|
OutputPerMTok: 15.0,
|
|
CacheCreationPerMTok: 3.75,
|
|
CacheReadPerMTok: 0.30,
|
|
},
|
|
{
|
|
ModelPattern: "claude-opus-4-20250514",
|
|
InputPerMTok: 15.0,
|
|
OutputPerMTok: 75.0,
|
|
CacheCreationPerMTok: 18.75,
|
|
CacheReadPerMTok: 1.50,
|
|
},
|
|
}), "seed pricing")
|
|
|
|
for _, spec := range goldenFixtureSessions {
|
|
seedGoldenSession(t, d, spec)
|
|
}
|
|
}
|
|
|
|
// goldenSessionSpec fully describes a fixture session. The slice
|
|
// goldenFixtureSessions is the single source of truth for the golden
|
|
// file; edit carefully, then regenerate with -update.
|
|
type goldenSessionSpec struct {
|
|
id string
|
|
project string
|
|
agent string
|
|
model string // empty -> no assistant model/token_usage seeded
|
|
startedAt string // RFC3339 UTC
|
|
durationMin int // minutes; adds to startedAt to compute ended_at
|
|
userMsgs int // user-message rows seeded under this session
|
|
peakContext int // peak_context_tokens (0 -> not set)
|
|
outcome string // sessions.outcome column
|
|
healthGrade string // sessions.health_grade column
|
|
toolCategory string // tool_calls.category (empty -> no tool calls)
|
|
toolName string // tool_calls.tool_name (empty -> "Read")
|
|
toolCount int // number of tool_calls rows to insert
|
|
skillName string // populated for Skill tool_calls
|
|
retryCount int // sessions.tool_retry_count
|
|
editChurn int // sessions.edit_churn_count
|
|
compactions int // sessions.compaction_count
|
|
}
|
|
|
|
// goldenFixtureSessions is deliberately small (11 rows) to keep the
|
|
// golden JSON under ~5 KB while still covering every section. Session
|
|
// IDs are deterministic and grouped by agent for readability.
|
|
var goldenFixtureSessions = []goldenSessionSpec{
|
|
{
|
|
id: "c-auto-01", project: "proj-alpha", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-05T10:00:00Z", durationMin: 5,
|
|
userMsgs: 1, outcome: "completed", healthGrade: "A",
|
|
},
|
|
{
|
|
id: "c-auto-02", project: "proj-alpha", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-05T11:00:00Z", durationMin: 4,
|
|
userMsgs: 1, outcome: "completed", healthGrade: "A",
|
|
},
|
|
{
|
|
id: "c-quick-01", project: "proj-alpha", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-06T10:00:00Z", durationMin: 15,
|
|
userMsgs: 3, peakContext: 20000,
|
|
outcome: "completed", healthGrade: "A",
|
|
toolCategory: "file", toolName: "Read", toolCount: 4,
|
|
},
|
|
{
|
|
id: "c-quick-02", project: "proj-beta", agent: "claude",
|
|
model: "claude-opus-4-20250514",
|
|
startedAt: "2026-04-07T10:00:00Z", durationMin: 20,
|
|
userMsgs: 3, outcome: "completed", healthGrade: "B",
|
|
toolCategory: "shell", toolName: "Bash", toolCount: 3,
|
|
},
|
|
{
|
|
id: "c-std-01", project: "proj-beta", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-08T10:00:00Z", durationMin: 45,
|
|
userMsgs: 10, peakContext: 55000,
|
|
outcome: "completed", healthGrade: "B",
|
|
toolCategory: "file", toolName: "Edit", toolCount: 6,
|
|
retryCount: 1, editChurn: 2,
|
|
},
|
|
{
|
|
id: "c-deep-01", project: "proj-beta", agent: "claude",
|
|
model: "claude-opus-4-20250514",
|
|
startedAt: "2026-04-09T10:00:00Z", durationMin: 120,
|
|
userMsgs: 30, peakContext: 95000,
|
|
outcome: "completed", healthGrade: "A",
|
|
toolCategory: "search", toolName: "Grep", toolCount: 8,
|
|
retryCount: 2, compactions: 1,
|
|
},
|
|
{
|
|
id: "c-deep-02", project: "proj-gamma", agent: "claude",
|
|
model: "claude-opus-4-20250514",
|
|
startedAt: "2026-04-10T10:00:00Z", durationMin: 150,
|
|
userMsgs: 25, peakContext: 75000,
|
|
outcome: "errored", healthGrade: "C",
|
|
toolCategory: "other", toolName: "ExitPlanMode", toolCount: 1,
|
|
},
|
|
{
|
|
id: "c-marathon-01", project: "proj-gamma", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-11T10:00:00Z", durationMin: 240,
|
|
userMsgs: 80, peakContext: 130000,
|
|
outcome: "completed", healthGrade: "A",
|
|
toolCategory: "other", toolName: "Task", toolCount: 2,
|
|
retryCount: 3, compactions: 2, editChurn: 5,
|
|
},
|
|
{
|
|
id: "cx-std-01", project: "proj-alpha", agent: "codex",
|
|
startedAt: "2026-04-08T14:00:00Z", durationMin: 30,
|
|
userMsgs: 10,
|
|
},
|
|
{
|
|
id: "cu-quick-01", project: "proj-beta", agent: "cursor",
|
|
startedAt: "2026-04-09T14:00:00Z", durationMin: 10,
|
|
userMsgs: 3,
|
|
},
|
|
{
|
|
id: "c-skill-01", project: "proj-alpha", agent: "claude",
|
|
model: "claude-sonnet-4-20250514",
|
|
startedAt: "2026-04-12T10:00:00Z", durationMin: 25,
|
|
userMsgs: 8, outcome: "completed", healthGrade: "B",
|
|
toolCategory: "other", toolName: "Skill", toolCount: 2,
|
|
skillName: "summarize",
|
|
},
|
|
}
|
|
|
|
// goldenOutputTokens returns the assistant output_tokens for the i-th
|
|
// (zero-based) user/assistant turn in a fixture session. seedGoldenSession
|
|
// and buildGoldenMessages both call this so the precomputed session total
|
|
// and the per-message token_usage never drift apart.
|
|
func goldenOutputTokens(i int) int {
|
|
return 200 + 30*i
|
|
}
|
|
|
|
// seedGoldenSession persists one goldenSessionSpec: the session row,
|
|
// N user messages + N assistant messages at 1-minute spacing, optional
|
|
// token_usage on assistant messages, and optional tool_calls rows.
|
|
// All timestamps are derived from spec.startedAt so the fixture is
|
|
// trivially regenerable.
|
|
func seedGoldenSession(
|
|
t *testing.T, d *db.DB, spec goldenSessionSpec,
|
|
) {
|
|
t.Helper()
|
|
|
|
startedAt := spec.startedAt
|
|
endedAt := addMinutes(startedAt, spec.durationMin)
|
|
// Pre-compute total_output_tokens by summing the per-assistant
|
|
// output_tokens the message builder will stamp. Kept in sync with
|
|
// buildGoldenMessages via goldenOutputTokens so agent_portfolio.by_tokens
|
|
// has meaningful non-zero values.
|
|
totalOutput := 0
|
|
if spec.model != "" {
|
|
for i := 0; i < spec.userMsgs; i++ {
|
|
totalOutput += goldenOutputTokens(i)
|
|
}
|
|
}
|
|
session := db.Session{
|
|
ID: spec.id,
|
|
Project: spec.project,
|
|
Machine: "golden-host",
|
|
Agent: spec.agent,
|
|
StartedAt: &startedAt,
|
|
EndedAt: &endedAt,
|
|
UserMessageCount: spec.userMsgs,
|
|
// UserMsgs x 2 gives one assistant per user; ensures
|
|
// message_count > 0 even for 1-user "automation" rows.
|
|
MessageCount: spec.userMsgs * 2,
|
|
PeakContextTokens: spec.peakContext,
|
|
HasPeakContextTokens: spec.peakContext > 0,
|
|
TotalOutputTokens: totalOutput,
|
|
HasTotalOutputTokens: totalOutput > 0,
|
|
}
|
|
require.NoError(t, d.UpsertSession(session), "upsert %s", spec.id)
|
|
|
|
if spec.outcome != "" || spec.healthGrade != "" ||
|
|
spec.retryCount > 0 || spec.editChurn > 0 ||
|
|
spec.compactions > 0 {
|
|
var grade *string
|
|
if spec.healthGrade != "" {
|
|
g := spec.healthGrade
|
|
grade = &g
|
|
}
|
|
require.NoError(t, d.UpdateSessionSignals(spec.id, db.SessionSignalUpdate{
|
|
Outcome: spec.outcome,
|
|
HealthGrade: grade,
|
|
ToolRetryCount: spec.retryCount,
|
|
EditChurnCount: spec.editChurn,
|
|
CompactionCount: spec.compactions,
|
|
}), "update signals %s", spec.id)
|
|
}
|
|
|
|
msgs := buildGoldenMessages(spec)
|
|
if len(msgs) > 0 {
|
|
require.NoError(t, d.InsertMessages(msgs),
|
|
"insert messages %s", spec.id)
|
|
}
|
|
}
|
|
|
|
// buildGoldenMessages returns interleaved user/assistant messages.
|
|
// Assistant messages carry model + token_usage when spec.model is
|
|
// non-empty so cache_economics + model_mix pick them up. When the
|
|
// spec requests tool calls, they attach to the first N assistant
|
|
// messages (round-robin when toolCount exceeds userMsgs) so every
|
|
// tool_call resolves to a real message_id via InsertMessages.
|
|
func buildGoldenMessages(spec goldenSessionSpec) []db.Message {
|
|
out := make([]db.Message, 0, spec.userMsgs*2)
|
|
toolsBuilt := 0
|
|
toolName := spec.toolName
|
|
if toolName == "" && spec.toolCount > 0 {
|
|
toolName = "Read"
|
|
}
|
|
for i := 0; i < spec.userMsgs; i++ {
|
|
ts := addMinutes(spec.startedAt, i)
|
|
out = append(out, db.Message{
|
|
SessionID: spec.id,
|
|
Ordinal: i * 2,
|
|
Role: "user",
|
|
Content: "u",
|
|
ContentLength: 1,
|
|
Timestamp: ts,
|
|
})
|
|
// Offset the assistant reply by a fixed 10 seconds so
|
|
// velocity.first_response_seconds and turn_cycle_seconds
|
|
// have non-zero distributions in the golden output.
|
|
asst := db.Message{
|
|
SessionID: spec.id,
|
|
Ordinal: i*2 + 1,
|
|
Role: "assistant",
|
|
Content: "a",
|
|
ContentLength: 1,
|
|
Timestamp: addSeconds(ts, 10),
|
|
}
|
|
if spec.model != "" {
|
|
asst.Model = spec.model
|
|
// Stable, small token counts so cache_economics numbers
|
|
// are deterministic. Vary per-message by ordinal so
|
|
// different sessions accumulate differently.
|
|
input := 400 + 50*i
|
|
output := goldenOutputTokens(i)
|
|
cacheCr := 100 + 20*i
|
|
cacheRd := 600 + 100*i
|
|
asst.TokenUsage = fmt.Appendf(nil,
|
|
`{"input_tokens":%d,"output_tokens":%d,`+
|
|
`"cache_creation_input_tokens":%d,`+
|
|
`"cache_read_input_tokens":%d}`,
|
|
input, output, cacheCr, cacheRd,
|
|
)
|
|
asst.OutputTokens = output
|
|
asst.HasOutputTokens = true
|
|
}
|
|
out = append(out, asst)
|
|
}
|
|
// Distribute tool_calls round-robin across the assistant
|
|
// messages just appended so each call has a valid host, even
|
|
// when toolCount > userMsgs.
|
|
for toolsBuilt < spec.toolCount {
|
|
asstIdx := 1 + 2*(toolsBuilt%spec.userMsgs) // ordinal of nth asst
|
|
for j := range out {
|
|
if out[j].Ordinal != asstIdx {
|
|
continue
|
|
}
|
|
out[j].HasToolUse = true
|
|
out[j].ToolCalls = append(out[j].ToolCalls, db.ToolCall{
|
|
ToolName: toolName,
|
|
Category: spec.toolCategory,
|
|
SkillName: spec.skillName,
|
|
ToolUseID: fmt.Sprintf("%s-tc-%d",
|
|
spec.id, toolsBuilt),
|
|
})
|
|
break
|
|
}
|
|
toolsBuilt++
|
|
}
|
|
return out
|
|
}
|
|
|
|
// addMinutes parses an RFC3339 timestamp and returns it + n minutes,
|
|
// formatted back to RFC3339 UTC. All fixture timestamps are UTC with
|
|
// a "Z" suffix, so time.Parse is exact round-trip.
|
|
func addMinutes(ts string, n int) string {
|
|
return addDuration(ts, time.Duration(n)*time.Minute)
|
|
}
|
|
|
|
// addSeconds is the second-granularity sibling of addMinutes, used by
|
|
// the message builder to offset assistant replies from their user
|
|
// prompts so velocity percentiles exercise non-zero values.
|
|
func addSeconds(ts string, n int) string {
|
|
return addDuration(ts, time.Duration(n)*time.Second)
|
|
}
|
|
|
|
func addDuration(ts string, d time.Duration) string {
|
|
parsed, err := time.Parse(time.RFC3339, ts)
|
|
if err != nil {
|
|
// Only called with literals we control, so this is a
|
|
// programmer error; surface it in the test diff instead of
|
|
// a hidden panic.
|
|
return "INVALID:" + ts
|
|
}
|
|
return parsed.Add(d).UTC().Format(time.RFC3339)
|
|
}
|