Files
wehub-resource-sync f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

2643 lines
90 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// itoa is a thin alias for strconv.Itoa kept short so seedModelMessages'
// inline JSON construction stays readable.
func itoa(n int) string { return strconv.Itoa(n) }
// sessionFixture is a compact description of a seeded session used by
// session-stats tests. Fields mirror the subset of sessions-table
// columns the stats pipeline actually reads; extend in future tasks.
type sessionFixture struct {
id string
project string
agent string
userMsgs int
messageCount int
startedAt string // RFC3339; required to place row in window
endedAt string // RFC3339 or ""
// durationMin, when > 0 and endedAt is empty, derives endedAt as
// startedAt + durationMin minutes. Ignored if endedAt is set.
durationMin float64
peakContext int
hasPeakContext bool
totalOutputTok int
hasTotalOutputToks bool
isAutomated bool
relationshipType string
// totalToolCalls seeds that many rows in the tool_calls table for
// this session, each attached to a synthetic assistant message.
totalToolCalls int
// assistantTurns seeds that many assistant-role messages for this
// session. Set alongside totalToolCalls so tests can control the
// tools_per_turn denominator precisely.
assistantTurns int
// cwd is the working directory recorded on the session. Consumed by
// outcome_stats tests that exercise git-repo discovery.
cwd string
}
// hoursAgo returns an RFC3339 timestamp N hours before now in UTC.
// Used to place fixture rows safely inside the default 28-day window.
func hoursAgo(n int) string {
return time.Now().UTC().Add(-time.Duration(n) * time.Hour).
Format(time.RFC3339)
}
func Test_insertSessionFixture_isAutomated_patch(t *testing.T) {
d := testDB(t)
insertSessionFixture(t, d, sessionFixture{
id: "auto-1", userMsgs: 5, startedAt: hoursAgo(1),
isAutomated: true,
})
insertSessionFixture(t, d, sessionFixture{
id: "human-1", userMsgs: 1, startedAt: hoursAgo(1),
isAutomated: false,
})
var autoFlag, humanFlag int
require.NoError(t, d.getReader().QueryRow(
"SELECT is_automated FROM sessions WHERE id = ?", "auto-1",
).Scan(&autoFlag), "read auto-1")
require.NoError(t, d.getReader().QueryRow(
"SELECT is_automated FROM sessions WHERE id = ?", "human-1",
).Scan(&humanFlag), "read human-1")
require.Equal(t, 1, autoFlag, "auto-1 is_automated")
require.Equal(t, 0, humanFlag, "human-1 is_automated")
}
func Test_loadSessionsInWindow_isAutomated(t *testing.T) {
d := testDB(t)
insertSessionFixture(t, d, sessionFixture{
id: "auto", userMsgs: 5, startedAt: hoursAgo(1),
isAutomated: true,
})
insertSessionFixture(t, d, sessionFixture{
id: "human", userMsgs: 1, startedAt: hoursAgo(1),
isAutomated: false,
})
ctx := t.Context()
from := time.Now().Add(-24 * time.Hour)
to := time.Now().Add(1 * time.Hour)
rows, err := d.loadSessionsInWindow(ctx, StatsFilter{}, from, to, false)
require.NoError(t, err, "loadSessionsInWindow")
byID := map[string]bool{}
for _, r := range rows {
byID[r.id] = r.isAutomated
}
require.Equal(t, true, byID["auto"], "auto.isAutomated")
require.Equal(t, false, byID["human"], "human.isAutomated")
}
// insertSessionFixture inserts a sessionFixture via the standard
// UpsertSession path so triggers and defaults stay authoritative.
// Defaults mirror insertSession in db_test.go (machine=local,
// agent=claude) but let tests override agent/project.
func insertSessionFixture(t *testing.T, d *DB, f sessionFixture) {
t.Helper()
project := f.project
if project == "" {
project = "proj"
}
agent := f.agent
if agent == "" {
agent = defaultAgent
}
// message_count must be > 0 so analytics WHERE clauses don't skip
// the row; default to userMsgs*2 when not set explicitly.
mc := f.messageCount
if mc == 0 {
mc = f.userMsgs * 2
if mc == 0 {
mc = 1
}
}
endedAt := f.endedAt
if endedAt == "" && f.durationMin > 0 && f.startedAt != "" {
start, err := time.Parse(time.RFC3339, f.startedAt)
require.NoError(t, err,
"insertSessionFixture %s: parsing startedAt %q",
f.id, f.startedAt)
dur := time.Duration(f.durationMin * float64(time.Minute))
endedAt = start.Add(dur).UTC().Format(time.RFC3339Nano)
}
insertSession(t, d, f.id, project, func(s *Session) {
s.Agent = agent
s.UserMessageCount = f.userMsgs
s.MessageCount = mc
if f.startedAt != "" {
s.StartedAt = new(f.startedAt)
}
if endedAt != "" {
s.EndedAt = new(endedAt)
}
s.PeakContextTokens = f.peakContext
s.HasPeakContextTokens = f.hasPeakContext
s.TotalOutputTokens = f.totalOutputTok
// Flip has_total_output_tokens whenever the fixture supplies a
// non-zero token count; tests that explicitly want to leave the
// flag false can override via hasTotalOutputToks.
if f.hasTotalOutputToks || f.totalOutputTok > 0 {
s.HasTotalOutputTokens = true
}
s.IsAutomated = f.isAutomated
s.RelationshipType = f.relationshipType
s.Cwd = f.cwd
})
seedAssistantActivity(t, d, f.id, f.assistantTurns, f.totalToolCalls)
// UpsertSession recomputes is_automated from FirstMessage, so a
// fixture's f.isAutomated alone would be silently clobbered when
// no first message is set. Patch the column after the upsert so
// f.isAutomated is the authoritative value the stats pipeline
// reads. Test-only path; production ingest always flows through
// UpsertSession's classifier.
var want int
if f.isAutomated {
want = 1
}
_, err := d.getWriter().Exec(
"UPDATE sessions SET is_automated = ? WHERE id = ?",
want, f.id,
)
require.NoError(t, err,
"insertSessionFixture %s: patch is_automated", f.id)
}
// seedAssistantActivity inserts `turns` assistant messages and
// spreads `toolCalls` rows across them (or across a single synthetic
// message when turns==0 but toolCalls>0). Purpose: let stats tests
// control both the assistant-turn count (denominator of
// tools_per_turn) and the total tool-call count (numerator) without
// reaching into the full parser pipeline.
func seedAssistantActivity(
t *testing.T, d *DB, sessionID string, turns, toolCalls int,
) {
t.Helper()
if turns == 0 && toolCalls == 0 {
return
}
n := turns
if n == 0 {
n = 1 // need at least one host message for tool_calls FK
}
msgs := make([]Message, 0, n)
for i := range n {
msgs = append(msgs, asstMsg(sessionID, i+1, "reply"))
}
require.NoError(t, d.InsertMessages(msgs),
"seedAssistantActivity %s: InsertMessages", sessionID)
if toolCalls == 0 {
return
}
// Distribute tool_calls round-robin across inserted messages so
// they all attach to a real message row. Rely on the router-like
// INSERT ... SELECT ordinal to find the message_id.
for i := range toolCalls {
ord := (i % n) + 1
_, err := d.getWriter().Exec(`
INSERT INTO tool_calls
(message_id, session_id, tool_name, category)
SELECT id, session_id, 'Read', 'file'
FROM messages
WHERE session_id = ? AND ordinal = ?`,
sessionID, ord,
)
require.NoError(t, err,
"seedAssistantActivity %s: tool_call", sessionID)
}
}
// seedToolCallsByCategory inserts one assistant message per entry in
// categories and a matching tool_calls row. Used by tool_mix tests
// that need precise control over category values (unlike
// seedAssistantActivity, which always writes category='file').
func seedToolCallsByCategory(
t *testing.T, d *DB, sessionID string, categories []string,
) {
t.Helper()
if len(categories) == 0 {
return
}
msgs := make([]Message, 0, len(categories))
for i, cat := range categories {
msgs = append(msgs, asstMsg(sessionID, i+1, "reply-"+cat))
}
require.NoError(t, d.InsertMessages(msgs),
"seedToolCallsByCategory %s: InsertMessages", sessionID)
for i, cat := range categories {
ord := i + 1
_, err := d.getWriter().Exec(`
INSERT INTO tool_calls
(message_id, session_id, tool_name, category)
SELECT id, session_id, ?, ?
FROM messages
WHERE session_id = ? AND ordinal = ?`,
cat, cat, sessionID, ord,
)
require.NoError(t, err,
"seedToolCallsByCategory %s: %q", sessionID, cat)
}
}
// seedModelMessages inserts one assistant message per (model, tokens)
// pair so the model_mix query sees a stable per-message row with known
// output_tokens. Ordinals are taken relative to startOrd so callers can
// layer multiple seed passes onto the same session without colliding.
func seedModelMessages(
t *testing.T, d *DB, sessionID string, startOrd int,
pairs []struct {
model string
tokens int
},
) {
t.Helper()
if len(pairs) == 0 {
return
}
msgs := make([]Message, 0, len(pairs))
for i, p := range pairs {
m := asstMsg(sessionID, startOrd+i, "reply")
m.Model = p.model
m.OutputTokens = p.tokens
m.HasOutputTokens = true
// model_mix's eligibility filter (mirrors
// usageMessageEligibility) requires token_usage != ''. Stamp a
// minimal JSON blob so these fixtures qualify; the contents
// don't matter to model_mix, which sums output_tokens.
m.TokenUsage = json.RawMessage(
`{"output_tokens":` + itoa(p.tokens) + `}`,
)
msgs = append(msgs, m)
}
require.NoError(t, d.InsertMessages(msgs),
"seedModelMessages %s: InsertMessages", sessionID)
}
func TestSessionShapeLabel(t *testing.T) {
// Automation is decided upstream via sessions.is_automated; this
// helper classifies only non-automated sessions, so the lower band
// starts at 0 and includes userMsgs=1.
cases := []struct {
userMsgs int
want string
}{
{0, "quick"},
{1, "quick"},
{2, "quick"},
{5, "quick"},
{6, "standard"},
{15, "standard"},
{16, "deep"},
{50, "deep"},
{51, "marathon"},
{1000, "marathon"},
}
for _, c := range cases {
got := sessionShapeLabel(c.userMsgs)
assert.Equal(t, c.want, got,
"sessionShapeLabel(%d)", c.userMsgs)
}
}
func TestPickMaxLabel_TiesBreakByPriority(t *testing.T) {
// automation (2) vs deep (2) — priority says automation wins.
counts := map[string]int{"automation": 2, "deep": 2, "quick": 1}
priority := []string{
"automation", "marathon", "deep", "standard", "quick",
}
assert.Equal(t, "automation", pickMaxLabel(counts, priority),
"tie break")
// PrimaryHuman excludes automation; marathon should win a 1/1/1
// tie over deep/standard/quick.
humanCounts := map[string]int{
"quick": 1, "standard": 1, "deep": 1, "marathon": 1,
}
humanPriority := []string{"marathon", "deep", "standard", "quick"}
assert.Equal(t, "marathon",
pickMaxLabel(humanCounts, humanPriority),
"human tie break")
// Strictly greater wins regardless of priority.
c2 := map[string]int{"quick": 5, "deep": 2}
assert.Equal(t, "quick", pickMaxLabel(c2, priority),
"strict max")
}
func TestGetSessionStats_TotalsAndArchetypes(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// 5 sessions: 2 automation (is_automated=true),
// 2 deep (userMsgs 20, 40),
// 1 marathon (userMsgs 100).
// Automation is now authoritative via sessions.is_automated; the
// two short rows carry the flag so they flow through the automation
// branch regardless of user_message_count.
fixtures := []sessionFixture{
{id: "s1", userMsgs: 0, startedAt: hoursAgo(5), isAutomated: true},
{id: "s2", userMsgs: 1, startedAt: hoursAgo(5), isAutomated: true},
{id: "s3", userMsgs: 20, startedAt: hoursAgo(5)},
{id: "s4", userMsgs: 40, startedAt: hoursAgo(5)},
{id: "s5", userMsgs: 100, startedAt: hoursAgo(5)},
}
for _, f := range fixtures {
insertSessionFixture(t, d, f)
}
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Equal(t, 1, stats.SchemaVersion, "schema_version: got")
assert.Equal(t, 5, stats.Totals.SessionsAll, "sessions_all")
assert.Equal(t, 2, stats.Totals.SessionsAutomation,
"sessions_automation")
assert.Equal(t, 3, stats.Totals.SessionsHuman, "sessions_human")
// Invariant: human + automation + subagent must equal all. This
// fixture has no subagents, so subagent is 0 and the partition still
// reduces to human + automation.
assert.Equal(t, 0, stats.Totals.SessionsSubagent,
"sessions_subagent (no subagents seeded)")
assert.Equal(t, stats.Totals.SessionsAll,
stats.Totals.SessionsHuman+stats.Totals.SessionsAutomation+
stats.Totals.SessionsSubagent,
"invariant: human (%d) + automation (%d) + subagent (%d) != all (%d)",
stats.Totals.SessionsHuman,
stats.Totals.SessionsAutomation,
stats.Totals.SessionsSubagent,
stats.Totals.SessionsAll)
assert.Equal(t, 161, stats.Totals.UserMessagesTotal,
"user_messages_total")
assert.Equal(t, 2, stats.Archetypes.Automation,
"archetypes.automation")
assert.Equal(t, 0, stats.Archetypes.Quick, "archetypes.quick")
assert.Equal(t, 0, stats.Archetypes.Standard,
"archetypes.standard")
assert.Equal(t, 2, stats.Archetypes.Deep, "archetypes.deep")
assert.Equal(t, 1, stats.Archetypes.Marathon,
"archetypes.marathon")
// 2 automation, 2 deep — tie broken by priority: automation first.
assert.Equal(t, "automation", stats.Archetypes.Primary,
"archetypes.primary")
// Human subset: 2 deep, 1 marathon. Deep wins.
assert.Equal(t, "deep", stats.Archetypes.PrimaryHuman,
"archetypes.primary_human")
// Window bookkeeping: Since = now-28d, Until = now, days = 28.
assert.Equal(t, 28, stats.Window.Days, "window.days: got")
assert.NotEmpty(t, stats.Window.Since,
"window.since (until=%q)", stats.Window.Until)
assert.NotEmpty(t, stats.Window.Until,
"window.until (since=%q)", stats.Window.Since)
_, errSince := time.Parse(time.RFC3339, stats.Window.Since)
assert.NoError(t, errSince, "window.since not RFC3339")
_, errUntil := time.Parse(time.RFC3339, stats.Window.Until)
assert.NoError(t, errUntil, "window.until not RFC3339")
// Filters echo the inputs and default Agent to "all".
assert.Equal(t, "all", stats.Filters.Agent, "filters.agent")
assert.Equal(t, "UTC", stats.Filters.Timezone,
"filters.timezone")
assert.NotNil(t, stats.Filters.ProjectsExcluded,
"filters.projects_excluded must be non-nil slice")
assert.NotEmpty(t, stats.GeneratedAt, "generated_at")
}
// TestGetSessionStats_SubagentTotals verifies the two-bucket split in
// the stats pipeline: subagent sessions (e.g. workflow subagents) count
// toward the additive token/session totals, but stay out of the
// distribution and human-vs-automation breakdowns so their short,
// signal-less shape does not skew those. The subagent here is one-shot
// (userMsgs 1) and short, like a real workflow subagent.
func TestGetSessionStats_SubagentTotals(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// One multi-turn root session (10 user msgs, 20 messages, ~100 min,
// 1000 output tokens) and one one-shot subagent (1 user msg, 5
// messages, ~8 min, 400 output tokens). Both agent "claude" so the
// agent-portfolio assertions reconcile against the totals.
insertSessionFixture(t, d, sessionFixture{
id: "root", agent: "claude", userMsgs: 10, messageCount: 20,
startedAt: hoursAgo(5), durationMin: 100,
totalOutputTok: 1000, hasTotalOutputToks: true,
})
insertSessionFixture(t, d, sessionFixture{
id: "agent-x", agent: "claude", userMsgs: 1, messageCount: 5,
startedAt: hoursAgo(5), durationMin: 8,
totalOutputTok: 400, hasTotalOutputToks: true,
relationshipType: "subagent",
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
// Additive totals include the subagent.
assert.Equal(t, 2, stats.Totals.SessionsAll, "sessions_all")
assert.Equal(t, 25, stats.Totals.MessagesTotal, "messages_total")
assert.Equal(t, 11, stats.Totals.UserMessagesTotal,
"user_messages_total")
// SessionsHuman stays root-only: a subagent is not a human session.
assert.Equal(t, 1, stats.Totals.SessionsHuman, "sessions_human")
assert.Equal(t, 0, stats.Totals.SessionsAutomation,
"sessions_automation")
// The subagent lands in its own bucket so the partition holds.
assert.Equal(t, 1, stats.Totals.SessionsSubagent, "sessions_subagent")
assert.Equal(t, stats.Totals.SessionsAll,
stats.Totals.SessionsHuman+stats.Totals.SessionsAutomation+
stats.Totals.SessionsSubagent,
"invariant: all == human + automation + subagent")
// Distributions stay root-only: only the root session is in the
// user-messages histogram, so its bucket counts sum to 1, not 2.
gotN := 0
for _, b := range stats.Distributions.UserMessages.ScopeAll.Buckets {
gotN += b.Count
}
assert.Equal(t, 1, gotN,
"user-messages distribution must exclude the subagent")
// Duration distribution likewise root-only (subagent's 8 min absent).
durN := 0
for _, b := range stats.Distributions.DurationMinutes.ScopeAll.Buckets {
durN += b.Count
}
assert.Equal(t, 1, durN,
"duration distribution must exclude the subagent")
// Agent portfolio all-session maps count the subagent so they
// reconcile with the inclusive totals; the _human maps stay
// root-only (a subagent is not human).
ap := stats.AgentPortfolio
assert.Equal(t, 2, ap.BySessions["claude"], "by_sessions counts subagent")
assert.Equal(t, 25, ap.ByMessages["claude"], "by_messages counts subagent")
assert.Equal(t, int64(1400), ap.ByTokens["claude"],
"by_tokens counts subagent spend (1000 + 400)")
assert.Equal(t, 1, ap.BySessionsHuman["claude"],
"by_sessions_human stays root-only")
assert.Equal(t, int64(1000), ap.ByTokensHuman["claude"],
"by_tokens_human excludes the subagent")
// Reconciliation: the all-session agent-portfolio sums must equal
// the (subagent-inclusive) totals. These would have caught the
// per-panel undercount.
sumSessions, sumMessages := 0, 0
for _, v := range ap.BySessions {
sumSessions += v
}
for _, v := range ap.ByMessages {
sumMessages += v
}
assert.Equal(t, stats.Totals.SessionsAll, sumSessions,
"sum(by_sessions) must equal sessions_all")
assert.Equal(t, stats.Totals.MessagesTotal, sumMessages,
"sum(by_messages) must equal messages_total")
}
func Test_computeTotalsAndArchetypes_flagAuthority(t *testing.T) {
d := testDB(t)
// Short non-automated session — must count as human, bucket as "quick".
insertSessionFixture(t, d, sessionFixture{
id: "short-human", userMsgs: 1, startedAt: hoursAgo(1),
isAutomated: false,
})
// Automated session — bucket as "automation" regardless of its
// userMsgs shape. userMsgs=7 is chosen so that under the old
// heuristic this row would have landed in "standard", making the
// Archetypes.Quick == 1 assertion a real regression guard: old
// code produces Quick=0, new code produces Quick=1 from the
// short-human fixture.
insertSessionFixture(t, d, sessionFixture{
id: "auto", userMsgs: 7, startedAt: hoursAgo(1),
isAutomated: true,
})
got, err := d.GetSessionStats(t.Context(), StatsFilter{Since: "1d"})
require.NoError(t, err, "GetSessionStats")
require.Equal(t, 1, got.Totals.SessionsHuman, "SessionsHuman")
require.Equal(t, 1, got.Totals.SessionsAutomation, "SessionsAutomation")
require.Equal(t, 1, got.Archetypes.Quick, "Archetypes.Quick")
require.Equal(t, 1, got.Archetypes.Automation, "Archetypes.Automation")
}
func TestGetSessionStats_FilterByAgent(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSessionFixture(t, d, sessionFixture{
id: "c1", agent: "claude", userMsgs: 10,
startedAt: hoursAgo(3),
})
insertSessionFixture(t, d, sessionFixture{
id: "x1", agent: "codex", userMsgs: 10,
startedAt: hoursAgo(3),
})
all, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats all")
assert.Equal(t, 2, all.Totals.SessionsAll, "all agents")
onlyClaude, err := d.GetSessionStats(
ctx, StatsFilter{Since: "28d", Agent: "claude"},
)
require.NoError(t, err, "GetSessionStats claude")
assert.Equal(t, 1, onlyClaude.Totals.SessionsAll, "agent=claude")
assert.Equal(t, "claude", onlyClaude.Filters.Agent,
"agent filter echoed")
// Comma-separated agents with surrounding whitespace must match
// every listed agent; the CSV values are trimmed before filtering.
multi, err := d.GetSessionStats(
ctx, StatsFilter{Since: "28d", Agent: "claude, codex"},
)
require.NoError(t, err, "GetSessionStats multi-agent")
assert.Equal(t, 2, multi.Totals.SessionsAll,
"comma-separated agents with whitespace match both")
}
func TestGetSessionStats_FilterByProject(t *testing.T) {
d := testDB(t)
ctx := context.Background()
for i, p := range []string{"alpha", "alpha", "beta", "gamma"} {
insertSessionFixture(t, d, sessionFixture{
id: fmt.Sprintf("p%d", i),
project: p,
userMsgs: 10,
startedAt: hoursAgo(2),
})
}
includeAlpha, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d",
IncludeProjects: []string{"alpha"},
})
require.NoError(t, err, "include alpha")
assert.Equal(t, 2, includeAlpha.Totals.SessionsAll,
"include=alpha")
excludeAlpha, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d",
ExcludeProjects: []string{"alpha"},
})
require.NoError(t, err, "exclude alpha")
assert.Equal(t, 2, excludeAlpha.Totals.SessionsAll,
"exclude=alpha want 2 (beta + gamma)")
}
func TestWindowBounds(t *testing.T) {
// Fixed reference time so the tests are deterministic.
now := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
t.Run("default 28d", func(t *testing.T) {
from, to, days, err := windowBounds(StatsFilter{}, now)
require.NoError(t, err, "windowBounds")
assert.Equal(t, 28, days, "days: got")
assert.True(t, to.Equal(now),
"until: got %v want %v", to, now)
wantFrom := now.Add(-28 * 24 * time.Hour)
assert.True(t, from.Equal(wantFrom),
"since: got %v want %v", from, wantFrom)
})
t.Run("Nd duration", func(t *testing.T) {
_, _, days, err := windowBounds(
StatsFilter{Since: "7d"}, now,
)
require.NoError(t, err, "windowBounds")
assert.Equal(t, 7, days, "days: got")
})
t.Run("Nh duration", func(t *testing.T) {
from, to, _, err := windowBounds(
StatsFilter{Since: "48h"}, now,
)
require.NoError(t, err, "windowBounds")
assert.Equal(t, 48*time.Hour, to.Sub(from), "span")
})
t.Run("bare date", func(t *testing.T) {
from, _, _, err := windowBounds(
StatsFilter{Since: "2026-04-01"}, now,
)
require.NoError(t, err, "windowBounds")
assert.Equal(t, 2026, from.Year(),
"since parsed: got %v want 2026-04-01", from)
assert.Equal(t, time.April, from.Month(),
"since parsed: got %v want 2026-04-01", from)
assert.Equal(t, 1, from.Day(),
"since parsed: got %v want 2026-04-01", from)
})
t.Run("invalid since", func(t *testing.T) {
_, _, _, err := windowBounds(
StatsFilter{Since: "bogus"}, now,
)
assert.Error(t, err, "expected error for invalid Since")
})
}
func TestParseWindowPoint(t *testing.T) {
now := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
in string
want time.Time
wantErrSubstring string
}{
{name: "Nd duration anchors at now", in: "7d",
want: time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC)},
{name: "Nh duration", in: "48h",
want: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)},
{name: "bare date is start of UTC day", in: "2026-04-01",
want: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)},
{name: "garbage is a hard error", in: "7x",
wantErrSubstring: "Nd, Nh, or YYYY-MM-DD"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseWindowPoint(tc.in, now)
if tc.wantErrSubstring != "" {
require.Error(t, err, "expected an error")
assert.Contains(t, err.Error(), tc.wantErrSubstring)
return
}
require.NoError(t, err)
assert.True(t, got.Equal(tc.want), "got %v want %v", got, tc.want)
})
}
}
func TestGetSessionStats_Distributions(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Seven sessions chosen to place rows in each interesting bucket
// for duration and peak_context. is_automated drives the scope_human
// filter: a,b,g → automation (isAutomated=true); c,d,e,f → human.
// f is intentionally a short non-automated session: duration scope_human
// includes it, while user_messages scope_human filters values below 2.
// g is intentionally multi-turn automation so scope_human excludes it.
fixtures := []struct {
id string
userMsgs int
peakCtx int
durMin float64
toolCalls int
assistantTurns int
isAutomated bool
}{
{"a", 0, 2_000, 0.5, 0, 0, true},
{"b", 1, 8_000, 0.9, 1, 1, true},
{"c", 3, 25_000, 10.0, 6, 3, false},
{"d", 10, 60_000, 25.0, 15, 10, false},
{"e", 30, 150_000, 120.0, 30, 30, false},
{"f", 1, 12_000, 3.0, 0, 0, false},
{"g", 4, 75_000, 30.0, 0, 0, true},
}
for _, f := range fixtures {
insertSessionFixture(t, d, sessionFixture{
id: f.id,
agent: "claude",
userMsgs: f.userMsgs,
peakContext: f.peakCtx,
hasPeakContext: true,
durationMin: f.durMin,
startedAt: hoursAgo(10),
totalToolCalls: f.toolCalls,
assistantTurns: f.assistantTurns,
isAutomated: f.isAutomated,
})
}
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
// duration scope_all: 0.5→bucket0, 0.9→bucket0, 3→bucket1,
// 10→bucket2, 25/30→bucket3, 120→bucket5 (top).
gotAll := stats.Distributions.DurationMinutes.ScopeAll.Buckets
wantCountsAll := []int{2, 1, 1, 2, 0, 1}
require.Len(t, gotAll, len(wantCountsAll),
"duration scope_all buckets")
for i, w := range wantCountsAll {
assert.Equal(t, w, gotAll[i].Count,
"duration scope_all bucket %d", i)
}
// duration scope_human (c,d,e,f): bucket1=1, bucket2=1,
// bucket3=1, bucket5=1.
gotHuman := stats.Distributions.DurationMinutes.ScopeHuman.Buckets
wantCountsHuman := []int{0, 1, 1, 1, 0, 1}
require.Len(t, gotHuman, len(wantCountsHuman),
"duration scope_human buckets")
for i, w := range wantCountsHuman {
assert.Equal(t, w, gotHuman[i].Count,
"duration scope_human bucket %d", i)
}
// Means (arithmetic over included sessions).
wantAllMean := (0.5 + 0.9 + 10 + 25 + 120 + 3 + 30) / 7.0
gotAllMean := stats.Distributions.DurationMinutes.ScopeAll.Mean
assert.InDelta(t, wantAllMean, gotAllMean, 0.01,
"duration scope_all mean")
wantHumanMean := (10.0 + 25.0 + 120.0 + 3.0) / 4.0
gotHumanMean := stats.Distributions.DurationMinutes.ScopeHuman.Mean
assert.InDelta(t, wantHumanMean, gotHumanMean, 0.01,
"duration scope_human mean")
// user_messages scope_all uses userMessagesEdgesAll
// ([0,2),[2,6),[6,16),[16,31),[31,51),[51,inf)):
// 0→0, 1→0, 3→1, 10→2, 30→3, 1→0, 4→1.
gotUM := stats.Distributions.UserMessages.ScopeAll.Buckets
wantUM := []int{3, 2, 1, 1, 0, 0}
require.Len(t, gotUM, len(wantUM),
"user_messages scope_all buckets")
for i, w := range wantUM {
assert.Equal(t, w, gotUM[i].Count,
"user_messages scope_all bucket %d", i)
}
// user_messages scope_human uses userMessagesEdgesHuman (5 buckets,
// dropping the automation band): 3→0, 10→1, 30→2. The short
// non-automated session with userMsgs=1 is filtered out before
// mean and bucket accumulation.
gotUMH := stats.Distributions.UserMessages.ScopeHuman.Buckets
wantUMH := []int{1, 1, 1, 0, 0}
require.Len(t, gotUMH, len(wantUMH),
"user_messages scope_human buckets")
for i, w := range wantUMH {
assert.Equal(t, w, gotUMH[i].Count,
"user_messages scope_human bucket %d", i)
}
assert.InDelta(t, (3.0+10.0+30.0)/3.0,
stats.Distributions.UserMessages.ScopeHuman.Mean, 0.01,
"user_messages scope_human mean filters values below 2")
// peak_context scope_all: 2k/8k→0, 12k/25k→1,
// 60k/75k→2, 150k→4.
gotPCAll := stats.Distributions.PeakContextTokens.ScopeAll.Buckets
wantPCAll := []int{2, 2, 2, 0, 1, 0}
for i, w := range wantPCAll {
assert.Equal(t, w, gotPCAll[i].Count,
"peak_context scope_all bucket %d", i)
}
// peak_context scope_human (c,d,e,f): 12k/25k→1,
// 60k→2, 150k→4.
gotPC := stats.Distributions.PeakContextTokens.ScopeHuman.Buckets
assert.Equal(t, 2, gotPC[1].Count,
"peak_context scope_human: %+v", gotPC)
assert.Equal(t, 1, gotPC[2].Count,
"peak_context scope_human: %+v", gotPC)
assert.Equal(t, 1, gotPC[4].Count,
"peak_context scope_human: %+v", gotPC)
assert.False(t, stats.Distributions.PeakContextTokens.ClaudeOnly,
"peak_context.claude_only is always false since #646")
assert.Equal(t, 0,
stats.Distributions.PeakContextTokens.NullCount,
"peak_context.null_count")
// tools_per_turn: a skipped (assistantTurns==0),
// b=1/1=1, c=6/3=2, d=15/10=1.5, e=30/30=1.
// toolsPerTurnEdges = [0,1,2,4,7,11,+Inf].
gotTPT := stats.Distributions.ToolsPerTurn.ScopeAll.Buckets
wantTPT := []int{0, 3, 1, 0, 0, 0}
require.Len(t, gotTPT, len(wantTPT),
"tools_per_turn scope_all buckets")
for i, w := range wantTPT {
assert.Equal(t, w, gotTPT[i].Count,
"tools_per_turn scope_all bucket %d", i)
}
}
func TestGetSessionStats_Distributions_NullPeakContext(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// One Claude session lacks peak-context data; it must land in
// NullCount rather than any peak_context bucket (including bucket 0).
insertSessionFixture(t, d, sessionFixture{
id: "np1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
// peakContext left at zero value AND hasPeakContext=false
})
insertSessionFixture(t, d, sessionFixture{
id: "wp1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
peakContext: 20_000,
hasPeakContext: true,
})
// Session from an agent that never reports peak context in this
// window must NOT increment NullCount: such agents are outside the
// metric entirely, only data-less rows of peak-context-reporting
// agents tally as null (#646).
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
// hasPeakContext left at false
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
pc := stats.Distributions.PeakContextTokens
assert.Equal(t, 1, pc.NullCount,
"null_count want 1 (only np1; codex cx1 must not count)")
total := 0
for _, b := range pc.ScopeAll.Buckets {
total += b.Count
}
assert.Equal(t, 1, total,
"scope_all bucket total want 1 "+
"(the one Claude session with hasPeakContext=true)")
}
func TestGetSessionStats_Distributions_PeakContextNonClaude(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Regression for #646: hermes (and kimi/forge/zed) sessions carry
// peak_context_tokens, but the distribution only counted rows with
// agent == "claude" — an agent-filtered stats run reported all-zero
// buckets despite has_peak_context_tokens being true on every row.
insertSessionFixture(t, d, sessionFixture{
id: "h1", agent: "hermes", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
peakContext: 25_000,
hasPeakContext: true,
})
insertSessionFixture(t, d, sessionFixture{
id: "h2", agent: "hermes", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
peakContext: 120_000,
hasPeakContext: true,
})
// A hermes row without the data lands in NullCount, because hermes
// demonstrably reports the metric in this window.
insertSessionFixture(t, d, sessionFixture{
id: "h3", agent: "hermes", userMsgs: 5,
startedAt: hoursAgo(5),
durationMin: 3.0,
// hasPeakContext left at false
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d", Agent: "hermes"})
require.NoError(t, err, "GetSessionStats")
pc := stats.Distributions.PeakContextTokens
total := 0
for _, b := range pc.ScopeAll.Buckets {
total += b.Count
}
assert.Equal(t, 2, total,
"scope_all bucket total want 2 (both hermes rows with data): %+v",
pc.ScopeAll.Buckets)
// peakContextEdges: 25k → [10k,50k) bucket 1; 120k → [100k,150k) bucket 3.
assert.Equal(t, 1, pc.ScopeAll.Buckets[1].Count,
"25k hermes session in bucket 1: %+v", pc.ScopeAll.Buckets)
assert.Equal(t, 1, pc.ScopeAll.Buckets[3].Count,
"120k hermes session in bucket 3: %+v", pc.ScopeAll.Buckets)
assert.InDelta(t, (25_000.0+120_000.0)/2.0, pc.ScopeAll.Mean, 0.01,
"scope_all mean over the two hermes rows with data")
assert.Equal(t, 1, pc.NullCount,
"null_count want 1 (h3: hermes reports the metric, h3 lacks it)")
assert.False(t, pc.ClaudeOnly, "claude_only must be false")
}
// seedVelocityMessages inserts len(offsetsSec) messages for sessionID,
// alternating user/assistant starting at role[0], with timestamps at
// startedAt+offsetsSec[i]. Used by velocity tests that need precise
// intervals between adjacent messages. Returns nothing; panics via t
// on any insert error.
func seedVelocityMessages(
t *testing.T, d *DB, sessionID, startedAt string,
offsetsSec []int,
) {
t.Helper()
start, err := time.Parse(time.RFC3339, startedAt)
require.NoError(t, err,
"seedVelocityMessages %s: parse startedAt %q",
sessionID, startedAt)
msgs := make([]Message, 0, len(offsetsSec))
for i, off := range offsetsSec {
role := "user"
if i%2 == 1 {
role = "assistant"
}
ts := start.Add(time.Duration(off) * time.Second).
UTC().Format(time.RFC3339)
msgs = append(msgs, Message{
SessionID: sessionID,
Ordinal: i,
Role: role,
Content: fmt.Sprintf("m%d", i),
ContentLength: 5,
Timestamp: ts,
})
}
require.NoError(t, d.InsertMessages(msgs),
"seedVelocityMessages %s: InsertMessages", sessionID)
}
func TestGetSessionStats_Velocity(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Two sessions with carefully chosen per-message gaps so the
// expected percentile/mean/hourly values are determined.
//
// Session v1: 6 msgs at offsets 0,10,20,25,35,50 (seconds).
// Turn cycles (user→assistant): 10, 5, 15.
// First response: 10.
// Adjacent gaps: 10,10,5,10,15 = 50s active.
// Session v2: 4 msgs at offsets 0,30,60,80.
// Turn cycles: 30, 20.
// First response: 30.
// Adjacent gaps: 30,30,20 = 80s active.
//
// Combined: turn cycles=[5,10,15,20,30], first responses=[10,30],
// active seconds=130, messages=10.
start := time.Now().UTC().Add(-5 * time.Hour).
Format(time.RFC3339)
insertSessionFixture(t, d, sessionFixture{
id: "v1", agent: "claude", userMsgs: 3,
messageCount: 6, startedAt: start,
})
seedVelocityMessages(t, d, "v1", start,
[]int{0, 10, 20, 25, 35, 50})
insertSessionFixture(t, d, sessionFixture{
id: "v2", agent: "claude", userMsgs: 2,
messageCount: 4, startedAt: start,
})
seedVelocityMessages(t, d, "v2", start,
[]int{0, 30, 60, 80})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
// Turn cycle seconds, sorted = [5,10,15,20,30].
// percentileFloat: P50 idx=int(5*0.5)=2 → 15, P90 idx=4 → 30.
// Mean = (5+10+15+20+30)/5 = 16.
tc := stats.Velocity.TurnCycleSeconds
assert.Equal(t, 15.0, tc.P50, "TurnCycleSeconds.P50")
assert.Equal(t, 30.0, tc.P90, "TurnCycleSeconds.P90")
assert.InDelta(t, 16.0, tc.Mean, 0.001,
"TurnCycleSeconds.Mean")
// First response seconds, sorted = [10,30].
// percentileFloat: P50 idx=int(2*0.5)=1 → 30, P90 idx=1 → 30.
// Mean = (10+30)/2 = 20.
fr := stats.Velocity.FirstResponseSeconds
assert.Equal(t, 30.0, fr.P50, "FirstResponseSeconds.P50")
assert.Equal(t, 30.0, fr.P90, "FirstResponseSeconds.P90")
assert.InDelta(t, 20.0, fr.Mean, 0.001,
"FirstResponseSeconds.Mean")
// MessagesPerActiveHour: active seconds=130, messages=10.
// activeMinutes = 130/60, per-hour = 10 / (activeMinutes/60)
// = 10 * 60 / (130/60) = 36000/130 ≈ 276.923.
want := 36000.0 / 130.0
assert.InDelta(t, want, stats.Velocity.MessagesPerActiveHour,
0.01, "MessagesPerActiveHour")
}
// Empty case: no sessions at all. The velocity accumulator stays zeroed
// and every output field must read as 0 rather than NaN / unset.
func TestGetSessionStats_Velocity_Empty(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
tc := stats.Velocity.TurnCycleSeconds
assert.Equal(t, 0.0, tc.P50, "TurnCycleSeconds.P50 want zero: %+v", tc)
assert.Equal(t, 0.0, tc.P90, "TurnCycleSeconds.P90 want zero: %+v", tc)
assert.Equal(t, 0.0, tc.Mean, "TurnCycleSeconds.Mean want zero: %+v", tc)
fr := stats.Velocity.FirstResponseSeconds
assert.Equal(t, 0.0, fr.P50, "FirstResponseSeconds.P50 want zero: %+v", fr)
assert.Equal(t, 0.0, fr.P90, "FirstResponseSeconds.P90 want zero: %+v", fr)
assert.Equal(t, 0.0, fr.Mean, "FirstResponseSeconds.Mean want zero: %+v", fr)
assert.Equal(t, 0.0, stats.Velocity.MessagesPerActiveHour,
"MessagesPerActiveHour")
}
// Single session with one user→assistant turn. One sample point feeds
// both the turn-cycle and first-response series, so P50 / P90 / Mean
// must all collapse to the same value.
func TestGetSessionStats_Velocity_SingleTurn(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// 2 msgs at offsets 0,60 (seconds): user→assistant delta = 60s.
// Adjacent gap = 60s → activeMinutes = 1, totalMsgs = 2,
// MessagesPerActiveHour = 2 / (1/60) = 120.
start := time.Now().UTC().Add(-3 * time.Hour).
Format(time.RFC3339)
insertSessionFixture(t, d, sessionFixture{
id: "s1", agent: "claude", userMsgs: 1,
messageCount: 2, startedAt: start,
})
seedVelocityMessages(t, d, "s1", start, []int{0, 60})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
tc := stats.Velocity.TurnCycleSeconds
assert.Equal(t, 60.0, tc.P50, "TurnCycleSeconds.P50")
assert.Equal(t, 60.0, tc.P90, "TurnCycleSeconds.P90")
assert.InDelta(t, 60.0, tc.Mean, 0.001,
"TurnCycleSeconds.Mean")
fr := stats.Velocity.FirstResponseSeconds
assert.Equal(t, 60.0, fr.P50, "FirstResponseSeconds.P50")
assert.Equal(t, 60.0, fr.P90, "FirstResponseSeconds.P90")
assert.InDelta(t, 60.0, fr.Mean, 0.001,
"FirstResponseSeconds.Mean")
assert.Greater(t, stats.Velocity.MessagesPerActiveHour, 0.0,
"MessagesPerActiveHour want > 0")
want := 120.0
assert.InDelta(t, want, stats.Velocity.MessagesPerActiveHour,
0.001, "MessagesPerActiveHour")
}
// Zero-active-minutes boundary: two messages share a timestamp so the
// only adjacent gap is 0 (failing the gap > 0 guard). activeMinutes
// stays 0, totalMsgs is never bumped, and MessagesPerActiveHour must
// remain 0 even though the session survived the len(msgs) >= 2 filter.
func TestGetSessionStats_Velocity_ZeroActive(t *testing.T) {
d := testDB(t)
ctx := context.Background()
start := time.Now().UTC().Add(-2 * time.Hour).
Format(time.RFC3339)
insertSessionFixture(t, d, sessionFixture{
id: "z1", agent: "claude", userMsgs: 1,
messageCount: 2, startedAt: start,
})
seedVelocityMessages(t, d, "z1", start, []int{0, 0})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Equal(t, 0.0, stats.Velocity.MessagesPerActiveHour,
"MessagesPerActiveHour")
}
func TestGetSessionStats_ToolMixAndModelMix(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Session tm1: 4 tool_calls across 3 categories (Bash×2, Edit, Read).
insertSessionFixture(t, d, sessionFixture{
id: "tm1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(4),
})
seedToolCallsByCategory(t, d, "tm1",
[]string{"Bash", "Bash", "Edit", "Read"})
// Session tm2: 2 tool_calls (Grep, Bash).
insertSessionFixture(t, d, sessionFixture{
id: "tm2", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(3),
})
seedToolCallsByCategory(t, d, "tm2",
[]string{"Grep", "Bash"})
// Session mm1: 2 claude-opus-4-7 assistant messages (1000 + 2000).
insertSessionFixture(t, d, sessionFixture{
id: "mm1", agent: "claude", userMsgs: 2,
startedAt: hoursAgo(2),
})
seedModelMessages(t, d, "mm1", 1, []struct {
model string
tokens int
}{
{"claude-opus-4-7", 1000},
{"claude-opus-4-7", 2000},
})
// Session mm2: 1 claude-sonnet-4-6 assistant message (500 tokens).
insertSessionFixture(t, d, sessionFixture{
id: "mm2", agent: "claude", userMsgs: 2,
startedAt: hoursAgo(2),
})
seedModelMessages(t, d, "mm2", 1, []struct {
model string
tokens int
}{
{"claude-sonnet-4-6", 500},
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
wantCats := map[string]int{
"Bash": 3,
"Edit": 1,
"Read": 1,
"Grep": 1,
}
gotCats := stats.ToolMix.ByCategory
assert.Len(t, gotCats, len(wantCats),
"ToolMix.ByCategory len (got=%v)", gotCats)
for cat, want := range wantCats {
assert.Equal(t, want, gotCats[cat],
"ToolMix.ByCategory[%q]", cat)
}
assert.Equal(t, 6, stats.ToolMix.TotalCalls, "ToolMix.TotalCalls")
wantTokens := map[string]int64{
"claude-opus-4-7": 3000,
"claude-sonnet-4-6": 500,
}
gotTokens := stats.ModelMix.ByTokens
assert.Len(t, gotTokens, len(wantTokens),
"ModelMix.ByTokens len (got=%v)", gotTokens)
for model, want := range wantTokens {
assert.Equal(t, want, gotTokens[model],
"ModelMix.ByTokens[%q]", model)
}
}
// Window and agent filters must gate both mixes: tool_calls and
// messages attached to sessions outside the window or not matching
// the agent filter must not appear in ToolMix or ModelMix.
func TestGetSessionStats_ToolMixAndModelMix_Filters(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// In-window claude session: should contribute to both mixes.
// seedToolCallsByCategory uses ordinals 1..2; seedModelMessages
// starts at 3 to avoid the UNIQUE(session_id, ordinal) collision.
insertSessionFixture(t, d, sessionFixture{
id: "in1", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedToolCallsByCategory(t, d, "in1", []string{"Bash", "Read"})
seedModelMessages(t, d, "in1", 3, []struct {
model string
tokens int
}{
{"claude-opus-4-7", 800},
})
// Out-of-window session (50 days old): must be excluded entirely.
oldStart := time.Now().UTC().Add(-50 * 24 * time.Hour).
Format(time.RFC3339)
insertSessionFixture(t, d, sessionFixture{
id: "old1", agent: "claude", userMsgs: 3,
startedAt: oldStart,
})
seedToolCallsByCategory(t, d, "old1", []string{"Edit", "Edit"})
seedModelMessages(t, d, "old1", 3, []struct {
model string
tokens int
}{
{"claude-opus-4-7", 9000},
})
// Wrong-agent session inside the window: excluded by Agent=claude.
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedToolCallsByCategory(t, d, "cx1", []string{"Grep"})
seedModelMessages(t, d, "cx1", 2, []struct {
model string
tokens int
}{
{"codex-gpt-5", 7000},
})
stats, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d", Agent: "claude",
})
require.NoError(t, err, "GetSessionStats")
// Only in1's 2 tool_calls survive.
assert.Equal(t, 2, stats.ToolMix.TotalCalls, "ToolMix.TotalCalls")
assert.Equal(t, 1, stats.ToolMix.ByCategory["Bash"],
"ToolMix.ByCategory: want Bash=1 Read=1, got %v",
stats.ToolMix.ByCategory)
assert.Equal(t, 1, stats.ToolMix.ByCategory["Read"],
"ToolMix.ByCategory: want Bash=1 Read=1, got %v",
stats.ToolMix.ByCategory)
assert.Equal(t, 0, stats.ToolMix.ByCategory["Edit"],
"out-of-window Edit leaked")
assert.Equal(t, 0, stats.ToolMix.ByCategory["Grep"],
"wrong-agent Grep leaked")
// Only in1's 800 tokens survive.
assert.Equal(t, int64(800),
stats.ModelMix.ByTokens["claude-opus-4-7"],
"ModelMix.ByTokens[claude-opus-4-7]")
assert.NotContains(t, stats.ModelMix.ByTokens, "codex-gpt-5",
"wrong-agent model leaked")
}
// Empty-window case: no sessions → both mixes must serialize as empty
// maps (not nil) so the JSON output keeps stable keys.
func TestGetSessionStats_ToolMixAndModelMix_Empty(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.NotNil(t, stats.ToolMix.ByCategory,
"ToolMix.ByCategory: want non-nil map")
assert.Equal(t, 0, stats.ToolMix.TotalCalls,
"ToolMix.TotalCalls")
assert.NotNil(t, stats.ModelMix.ByTokens,
"ModelMix.ByTokens: want non-nil map")
}
// AgentPortfolio aggregates session, message, and output-token counts
// per agent across the window. Primary names the agent with the most
// sessions, with alphabetical tie-breaking for determinism.
func TestGetSessionStats_AgentPortfolio(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// 3 claude sessions: messages 5,7,10 → 22; tokens 100,200,300 → 600.
claude := []struct {
id string
msgs int
tokens int
}{
{"cl1", 5, 100},
{"cl2", 7, 200},
{"cl3", 10, 300},
}
for _, c := range claude {
insertSessionFixture(t, d, sessionFixture{
id: c.id, agent: "claude", userMsgs: 3,
messageCount: c.msgs,
totalOutputTok: c.tokens,
startedAt: hoursAgo(5),
})
}
// 2 codex sessions: messages 3,6 → 9; tokens 50,100 → 150.
codex := []struct {
id string
msgs int
tokens int
}{
{"cx1", 3, 50},
{"cx2", 6, 100},
}
for _, c := range codex {
insertSessionFixture(t, d, sessionFixture{
id: c.id, agent: "codex", userMsgs: 3,
messageCount: c.msgs,
totalOutputTok: c.tokens,
startedAt: hoursAgo(5),
})
}
// 1 cursor session: messages 4; tokens 80.
insertSessionFixture(t, d, sessionFixture{
id: "cu1", agent: "cursor", userMsgs: 3,
messageCount: 4,
totalOutputTok: 80,
startedAt: hoursAgo(5),
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ap := stats.AgentPortfolio
wantSessions := map[string]int{"claude": 3, "codex": 2, "cursor": 1}
assert.Len(t, ap.BySessions, len(wantSessions),
"BySessions len (got=%v)", ap.BySessions)
for k, v := range wantSessions {
assert.Equal(t, v, ap.BySessions[k],
"BySessions[%q]", k)
}
wantMessages := map[string]int{"claude": 22, "codex": 9, "cursor": 4}
for k, v := range wantMessages {
assert.Equal(t, v, ap.ByMessages[k],
"ByMessages[%q]", k)
}
wantTokens := map[string]int64{"claude": 600, "codex": 150, "cursor": 80}
for k, v := range wantTokens {
assert.Equal(t, v, ap.ByTokens[k],
"ByTokens[%q]", k)
}
assert.Equal(t, "claude", ap.Primary, "Primary")
}
// Tie-break: two agents at equal session counts must resolve to the
// lexicographically smallest agent name. claude vs codex both at 2 →
// claude wins because "claude" < "codex".
func TestGetSessionStats_AgentPortfolio_TieBreak(t *testing.T) {
d := testDB(t)
ctx := context.Background()
for _, id := range []string{"cl1", "cl2"} {
insertSessionFixture(t, d, sessionFixture{
id: id, agent: "claude", userMsgs: 3,
messageCount: 4, totalOutputTok: 100,
startedAt: hoursAgo(5),
})
}
for _, id := range []string{"cx1", "cx2"} {
insertSessionFixture(t, d, sessionFixture{
id: id, agent: "codex", userMsgs: 3,
messageCount: 4, totalOutputTok: 100,
startedAt: hoursAgo(5),
})
}
insertSessionFixture(t, d, sessionFixture{
id: "cu1", agent: "cursor", userMsgs: 3,
messageCount: 4, totalOutputTok: 100,
startedAt: hoursAgo(5),
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
require.Equal(t, 2, stats.AgentPortfolio.BySessions["claude"],
"precondition: claude/codex must tie at 2 (got %v)",
stats.AgentPortfolio.BySessions)
require.Equal(t, 2, stats.AgentPortfolio.BySessions["codex"],
"precondition: claude/codex must tie at 2 (got %v)",
stats.AgentPortfolio.BySessions)
assert.Equal(t, "claude", stats.AgentPortfolio.Primary,
"Primary under tie want claude (alphabetical tie-break)")
}
// Empty-window case: AgentPortfolio maps must be non-nil (JSON encodes
// {} not null) and Primary must be empty without crashing.
func TestGetSessionStats_AgentPortfolio_Empty(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ap := stats.AgentPortfolio
assert.NotNil(t, ap.BySessions,
"BySessions: want non-nil map")
assert.NotNil(t, ap.ByMessages,
"ByMessages: want non-nil map")
assert.NotNil(t, ap.ByTokens,
"ByTokens: want non-nil map")
assert.NotNil(t, ap.BySessionsHuman,
"BySessionsHuman: want non-nil map")
assert.NotNil(t, ap.ByMessagesHuman,
"ByMessagesHuman: want non-nil map")
assert.NotNil(t, ap.ByTokensHuman,
"ByTokensHuman: want non-nil map")
assert.Empty(t, ap.BySessions,
"empty window: got non-empty maps %+v", ap)
assert.Empty(t, ap.ByMessages,
"empty window: got non-empty maps %+v", ap)
assert.Empty(t, ap.ByTokens,
"empty window: got non-empty maps %+v", ap)
assert.Empty(t, ap.BySessionsHuman,
"empty window: got non-empty human maps %+v", ap)
assert.Empty(t, ap.ByMessagesHuman,
"empty window: got non-empty human maps %+v", ap)
assert.Empty(t, ap.ByTokensHuman,
"empty window: got non-empty human maps %+v", ap)
assert.Empty(t, ap.Primary, "Primary")
assert.Empty(t, ap.PrimaryHuman, "PrimaryHuman")
}
func Test_computeAgentPortfolio_humanScoped(t *testing.T) {
d := testDB(t)
insertSessionFixture(t, d, sessionFixture{
id: "claude-human", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(1), totalOutputTok: 100,
isAutomated: false,
})
insertSessionFixture(t, d, sessionFixture{
id: "codex-auto", agent: "codex", userMsgs: 1,
startedAt: hoursAgo(1), totalOutputTok: 50,
isAutomated: true,
})
insertSessionFixture(t, d, sessionFixture{
id: "gemini-auto", agent: "gemini", userMsgs: 1,
startedAt: hoursAgo(1), totalOutputTok: 25,
isAutomated: true,
})
got, err := d.GetSessionStats(t.Context(), StatsFilter{Since: "1d"})
require.NoError(t, err, "GetSessionStats")
ap := got.AgentPortfolio
// All-sessions view: every agent present.
require.Equal(t, 1, ap.BySessions["claude"],
"BySessions = %v, want claude=1,codex=1,gemini=1", ap.BySessions)
require.Equal(t, 1, ap.BySessions["codex"],
"BySessions = %v, want claude=1,codex=1,gemini=1", ap.BySessions)
require.Equal(t, 1, ap.BySessions["gemini"],
"BySessions = %v, want claude=1,codex=1,gemini=1", ap.BySessions)
// primary ties on count; lexicographic min wins → claude.
require.Equal(t, "claude", ap.Primary, "Primary")
// Human-scoped view: only claude.
require.NotContains(t, ap.BySessionsHuman, "codex",
"BySessionsHuman must exclude codex: %v",
ap.BySessionsHuman)
require.NotContains(t, ap.BySessionsHuman, "gemini",
"BySessionsHuman must exclude gemini: %v",
ap.BySessionsHuman)
require.Equal(t, 1, ap.BySessionsHuman["claude"], "BySessionsHuman[claude]")
require.Equal(t, int64(100), ap.ByTokensHuman["claude"], "ByTokensHuman[claude]")
require.Equal(t, "claude", ap.PrimaryHuman, "PrimaryHuman")
}
// cacheTokenBreakdown names the four token dimensions the cache
// economics section reads per assistant message.
type cacheTokenBreakdown struct {
input int
output int
cacheCreation int
cacheRead int
}
// seedCacheEconomicsMessage inserts one assistant message whose
// token_usage JSON carries a full input/output/cache_* breakdown.
// The helper lives next to seedModelMessages so cache-economics tests
// can set the four cache-dimension counts directly without teaching
// seedModelMessages about fields it doesn't use.
func seedCacheEconomicsMessage(
t *testing.T, d *DB, sessionID string, ordinal int,
model string, b cacheTokenBreakdown,
) {
t.Helper()
payload := fmt.Sprintf(
`{"input_tokens":%d,"output_tokens":%d,`+
`"cache_creation_input_tokens":%d,`+
`"cache_read_input_tokens":%d}`,
b.input, b.output, b.cacheCreation, b.cacheRead,
)
m := asstMsg(sessionID, ordinal, "reply")
m.Model = model
m.OutputTokens = b.output
m.HasOutputTokens = true
m.TokenUsage = json.RawMessage(payload)
require.NoError(t, d.InsertMessages([]Message{m}),
"seedCacheEconomicsMessage %s ord=%d", sessionID, ordinal)
}
// TestGetSessionStats_CacheEconomics exercises the cache-economics
// computation end-to-end: three Claude sessions with known token
// mixes across two models, one codex session that must NOT affect
// the output (cache economics is Claude-only). The test locks the
// weighted-mean overall rule, the bucket assignment, and the two
// dollar calculations against hand-computed values.
func TestGetSessionStats_CacheEconomics(t *testing.T) {
d := testDB(t)
ctx := context.Background()
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
{
ModelPattern: "claude-opus-4-7",
InputPerMTok: 15.0,
OutputPerMTok: 75.0,
CacheCreationPerMTok: 18.75,
CacheReadPerMTok: 1.5,
},
{
ModelPattern: "claude-sonnet-4-6",
InputPerMTok: 3.0,
OutputPerMTok: 15.0,
CacheCreationPerMTok: 3.75,
CacheReadPerMTok: 0.3,
},
}), "UpsertModelPricing")
// ce1 (opus): ratio = 9000 / (1000+9000+100) = 9000/10100 ≈ 0.8911
// → cacheHitRatioEdges bucket 3 ([0.75, 0.95)).
insertSessionFixture(t, d, sessionFixture{
id: "ce1", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(5),
})
seedCacheEconomicsMessage(t, d, "ce1", 1, "claude-opus-4-7",
cacheTokenBreakdown{
input: 1000, output: 500,
cacheCreation: 100, cacheRead: 9000,
})
// ce2 (sonnet): ratio = 3000/(500+3000+50) = 3000/3550 ≈ 0.8451
// → bucket 3.
insertSessionFixture(t, d, sessionFixture{
id: "ce2", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(4),
})
seedCacheEconomicsMessage(t, d, "ce2", 1, "claude-sonnet-4-6",
cacheTokenBreakdown{
input: 500, output: 200,
cacheCreation: 50, cacheRead: 3000,
})
// ce3 (opus): ratio = 100/(100+100+0) = 0.5
// → bucket 2 ([0.5, 0.75)).
insertSessionFixture(t, d, sessionFixture{
id: "ce3", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(3),
})
seedCacheEconomicsMessage(t, d, "ce3", 1, "claude-opus-4-7",
cacheTokenBreakdown{
input: 100, output: 50,
cacheCreation: 0, cacheRead: 100,
})
// Codex session with non-empty token_usage: must NOT influence
// any cache_economics number (section is Claude-only).
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedCacheEconomicsMessage(t, d, "cx1", 1, "gpt-5",
cacheTokenBreakdown{
input: 100000, output: 50000,
cacheCreation: 0, cacheRead: 0,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ce := stats.CacheEconomics
require.NotNil(t, ce, "CacheEconomics: want populated")
assert.True(t, ce.ClaudeOnly, "ClaudeOnly")
// Overall = sum(cache_read) / sum(denominator) (weighted mean).
// = (9000 + 3000 + 100) / (10100 + 3550 + 200)
// = 12100 / 13850 ≈ 0.873646.
wantOverall := 12100.0 / 13850.0
assert.InDelta(t, wantOverall, ce.CacheHitRatio.Overall, 1e-6,
"CacheHitRatio.Overall")
wantBuckets := []int{0, 0, 1, 2, 0}
require.Len(t, ce.CacheHitRatio.Buckets, len(wantBuckets),
"CacheHitRatio.Buckets")
for i, w := range wantBuckets {
assert.Equal(t, w, ce.CacheHitRatio.Buckets[i].Count,
"CacheHitRatio.Buckets[%d]", i)
}
// Per-message cost (rates in $/MTok, so divide by 1e6):
// ce1 opus = (1000*15 + 500*75 + 100*18.75 + 9000*1.5)/1e6
// = (15000 + 37500 + 1875 + 13500)/1e6 = 0.067875
// ce2 sonn = (500*3 + 200*15 + 50*3.75 + 3000*0.3)/1e6
// = (1500 + 3000 + 187.5 + 900)/1e6 = 0.0055875
// ce3 opus = (100*15 + 50*75 + 0 + 100*1.5)/1e6
// = (1500 + 3750 + 150)/1e6 = 0.0054
wantSpent := 0.067875 + 0.0055875 + 0.0054
assert.InDelta(t, wantSpent, ce.DollarsSpent, 1e-9, "DollarsSpent")
// cost_without_cache reprices input + cache_creation + cache_read
// at the input rate, keeping output unchanged. cache_creation
// tokens would still have been sent as ordinary input in the
// counterfactual, so they are not zeroed (matches usage.go and
// frontend/src/lib/utils/usageSavings.ts).
// ce1 opus = (15*(1000+100+9000) + 75*500)/1e6
// = (15*10100 + 37500)/1e6 = 0.189
// ce2 sonn = (3*(500+50+3000) + 15*200)/1e6
// = (3*3550 + 3000)/1e6 = 0.01365
// ce3 opus = (15*(100+0+100) + 75*50)/1e6
// = (3000 + 3750)/1e6 = 0.00675
wantWithoutCache := 0.189 + 0.01365 + 0.00675
wantSavings := wantWithoutCache - wantSpent
assert.InDelta(t, wantSavings, ce.DollarsSavedVsUncached, 1e-9,
"DollarsSavedVsUncached")
}
func TestGetSessionStats_CacheEconomicsClampsRawTokenUsage(t *testing.T) {
d := testDB(t)
ctx := context.Background()
const maxTokens = MaxPlausibleTokens
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
ModelPattern: "claude-sonnet-4-6",
InputPerMTok: 1.0,
OutputPerMTok: 2.0,
CacheCreationPerMTok: 3.0,
CacheReadPerMTok: 4.0,
}}), "UpsertModelPricing")
insertSessionFixture(t, d, sessionFixture{
id: "ce-clamp", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedCacheEconomicsMessage(t, d, "ce-clamp", 1, "claude-sonnet-4-6",
cacheTokenBreakdown{
input: 9_999_999_999,
output: 9_999_999_999,
cacheCreation: 9_999_999_999,
cacheRead: 9_999_999_999,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ce := stats.CacheEconomics
require.NotNil(t, ce, "CacheEconomics: want populated")
assert.InDelta(t, 1.0/3.0, ce.CacheHitRatio.Overall, 1e-9,
"CacheHitRatio.Overall")
assert.Equal(t, 1, ce.CacheHitRatio.Buckets[1].Count,
"clamped ratio 1/3 should land in bucket [0.25,0.5)")
wantSpent := float64(maxTokens) * (1.0 + 2.0 + 3.0 + 4.0) / 1_000_000
assert.InDelta(t, wantSpent, ce.DollarsSpent, 1e-9, "DollarsSpent")
wantWithoutCache := float64(maxTokens) * (1.0 + 2.0 + 1.0 + 1.0) / 1_000_000
assert.InDelta(t, wantWithoutCache, ce.DollarsSavedVsUncached+ce.DollarsSpent,
1e-9, "DollarsWithoutCache")
assert.InDelta(t, wantWithoutCache-wantSpent, ce.DollarsSavedVsUncached,
1e-9, "DollarsSavedVsUncached")
}
// TestGetSessionStats_CacheEconomics_NoClaude verifies that the
// pointer remains nil when the window contains zero Claude sessions.
// A zero-valued *StatsCacheEconomics would emit "claude_only":false,
// "overall":0, etc. — false negatives the profile page would render
// as a legitimate empty cache-economics section.
func TestGetSessionStats_CacheEconomics_NoClaude(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Pricing is present so the nil result isn't an artifact of a
// missing pricing map.
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
ModelPattern: "claude-sonnet-4-6",
InputPerMTok: 3.0, OutputPerMTok: 15.0,
CacheCreationPerMTok: 3.75, CacheReadPerMTok: 0.3,
}}), "UpsertModelPricing")
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedCacheEconomicsMessage(t, d, "cx1", 1, "gpt-5",
cacheTokenBreakdown{input: 1000, output: 500})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Nil(t, stats.CacheEconomics, "CacheEconomics")
}
// TestGetSessionStats_CacheEconomics_ZeroDenominatorSkipped seeds one
// Claude session whose only message has zero input/cache tokens. The
// per-session ratio denominator is zero so the session must be
// skipped from both the histogram and the overall weighted mean,
// without tripping the nil-vs-populated rule.
func TestGetSessionStats_CacheEconomics_ZeroDenominatorSkipped(t *testing.T) {
d := testDB(t)
ctx := context.Background()
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
ModelPattern: "claude-opus-4-7",
InputPerMTok: 15.0, OutputPerMTok: 75.0,
CacheCreationPerMTok: 18.75, CacheReadPerMTok: 1.5,
}}), "UpsertModelPricing")
// Session with a contributing denominator.
insertSessionFixture(t, d, sessionFixture{
id: "ok", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(3),
})
seedCacheEconomicsMessage(t, d, "ok", 1, "claude-opus-4-7",
cacheTokenBreakdown{
input: 100, output: 50, cacheRead: 300,
})
// Session with zero denominator — must be skipped from the
// histogram, not crash on divide-by-zero.
insertSessionFixture(t, d, sessionFixture{
id: "z", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedCacheEconomicsMessage(t, d, "z", 1, "claude-opus-4-7",
cacheTokenBreakdown{input: 0, output: 10, cacheRead: 0})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ce := stats.CacheEconomics
require.NotNil(t, ce, "CacheEconomics: want populated")
// Only "ok" contributes: ratio = 300 / (100+300) = 0.75 → bucket 3.
total := 0
for _, b := range ce.CacheHitRatio.Buckets {
total += b.Count
}
assert.Equal(t, 1, total,
"histogram total want 1 (zero-denom skipped)")
assert.Equal(t, 1, ce.CacheHitRatio.Buckets[3].Count,
"bucket 3 [0.75,0.95)")
// Overall = 300/400 = 0.75 exactly (zero-denom session excluded
// from both numerator and denominator).
assert.InDelta(t, 0.75, ce.CacheHitRatio.Overall, 1e-9, "Overall")
}
func TestPickPrimaryAgent(t *testing.T) {
cases := []struct {
name string
input map[string]int
want string
}{
{
name: "empty map",
input: map[string]int{},
want: "",
},
{
name: "single agent",
input: map[string]int{"claude": 5},
want: "claude",
},
{
name: "strict max wins",
input: map[string]int{"claude": 3, "codex": 2, "cursor": 1},
want: "claude",
},
{
name: "alphabetical tie-break across two",
input: map[string]int{"codex": 2, "claude": 2},
want: "claude",
},
{
name: "alphabetical tie-break across three",
input: map[string]int{
"codex": 2, "claude": 2, "cursor": 2,
},
want: "claude",
},
{
name: "tie below max ignored",
input: map[string]int{"codex": 3, "claude": 2, "cursor": 2},
want: "codex",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, pickPrimaryAgent(c.input),
"pickPrimaryAgent(%v)", c.input)
})
}
}
// hoursAgoAt returns an RFC3339 timestamp for (now - hours, shifted to
// the given minute and second of that hour). Used by temporal tests
// that need to place messages inside a specific UTC hour boundary.
func hoursAgoAt(hours, minute, second int) string {
t := time.Now().UTC().
Add(-time.Duration(hours) * time.Hour).
Truncate(time.Hour).
Add(time.Duration(minute) * time.Minute).
Add(time.Duration(second) * time.Second)
return t.Format(time.RFC3339)
}
// utcHourBoundary returns the UTC-hour-boundary TS string (what
// Temporal.HourlyUTC entries use) for (now - hours).
func utcHourBoundary(hours int) string {
t := time.Now().UTC().
Add(-time.Duration(hours) * time.Hour).
Truncate(time.Hour)
return t.Format("2006-01-02T15:00:00Z")
}
// findHourlyUTC returns the entry matching ts, or nil when absent.
// Shared by every temporal test that checks per-hour numbers.
func findHourlyUTC(
entries []TemporalHourlyUTCEntry, ts string,
) *TemporalHourlyUTCEntry {
for i := range entries {
if entries[i].TS == ts {
return &entries[i]
}
}
return nil
}
func TestGetSessionStats_Temporal_HourlyGrouping(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Three hours of activity: H-5 (2 user msgs in one session),
// H-4 (1 user msg in a different session), H-3 (2 user msgs
// across two sessions). Assistant messages are ignored.
insertSessionFixture(t, d, sessionFixture{
id: "s1", userMsgs: 2, startedAt: hoursAgo(6),
})
insertSessionFixture(t, d, sessionFixture{
id: "s2", userMsgs: 1, startedAt: hoursAgo(5),
})
insertSessionFixture(t, d, sessionFixture{
id: "s3", userMsgs: 1, startedAt: hoursAgo(4),
})
insertSessionFixture(t, d, sessionFixture{
id: "s4", userMsgs: 1, startedAt: hoursAgo(4),
})
insertMessages(t, d,
userMsgAt("s1", 0, "a", hoursAgoAt(5, 5, 0)),
userMsgAt("s1", 1, "b", hoursAgoAt(5, 45, 0)),
asstMsgAt("s1", 2, "ignored", hoursAgoAt(5, 50, 0)),
userMsgAt("s2", 0, "c", hoursAgoAt(4, 10, 0)),
userMsgAt("s3", 0, "d", hoursAgoAt(3, 30, 0)),
userMsgAt("s4", 0, "e", hoursAgoAt(3, 45, 0)),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
hours := stats.Temporal.HourlyUTC
require.Len(t, hours, 3,
"hourly_utc want 3 entries: %+v", hours)
// Entries must be sorted by TS ascending.
for i := 1; i < len(hours); i++ {
assert.Less(t, hours[i-1].TS, hours[i].TS,
"hourly_utc not ascending")
}
// H-5: 2 user messages, 1 distinct session.
if e := findHourlyUTC(hours, utcHourBoundary(5)); assert.NotNilf(t, e,
"missing hour entry %q", utcHourBoundary(5)) {
assert.Equal(t, 2, e.UserMessages, "H-5 user_messages")
assert.Equal(t, 1, e.Sessions, "H-5 sessions: got")
}
// H-4: 1 user message, 1 session.
if e := findHourlyUTC(hours, utcHourBoundary(4)); assert.NotNilf(t, e,
"missing hour entry %q", utcHourBoundary(4)) {
assert.Equal(t, 1, e.UserMessages, "H-4 user_messages")
assert.Equal(t, 1, e.Sessions, "H-4 sessions: got")
}
// H-3: 2 user messages from 2 distinct sessions.
if e := findHourlyUTC(hours, utcHourBoundary(3)); assert.NotNilf(t, e,
"missing hour entry %q", utcHourBoundary(3)) {
assert.Equal(t, 2, e.UserMessages, "H-3 user_messages")
assert.Equal(t, 2, e.Sessions, "H-3 sessions: got")
}
}
func TestGetSessionStats_Temporal_MidnightBoundary(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Pick a fixed day inside the default 28d window and seed one
// message 1 second before midnight UTC and one 1 second after.
// They MUST land in different hour entries.
day := time.Now().UTC().
Add(-5 * 24 * time.Hour).Truncate(24 * time.Hour)
before := day.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
after := day.Add(24 * time.Hour).Add(1 * time.Second)
insertSessionFixture(t, d, sessionFixture{
id: "s1", userMsgs: 2,
startedAt: before.Format(time.RFC3339),
})
insertMessages(t, d,
userMsgAt("s1", 0, "late", before.Format(time.RFC3339)),
userMsgAt("s1", 1, "early", after.Format(time.RFC3339)),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
beforeTS := before.Truncate(time.Hour).
Format("2006-01-02T15:00:00Z")
afterTS := after.Truncate(time.Hour).
Format("2006-01-02T15:00:00Z")
require.NotEqual(t, afterTS, beforeTS,
"test setup: before %q and after %q collapsed to "+
"the same hour", beforeTS, afterTS)
if e := findHourlyUTC(stats.Temporal.HourlyUTC, beforeTS); assert.NotNilf(t, e,
"missing before-midnight hour %q", beforeTS) {
assert.Equal(t, 1, e.UserMessages,
"before-midnight user_messages")
}
if e := findHourlyUTC(stats.Temporal.HourlyUTC, afterTS); assert.NotNilf(t, e,
"missing after-midnight hour %q", afterTS) {
assert.Equal(t, 1, e.UserMessages,
"after-midnight user_messages")
}
}
func TestGetSessionStats_Temporal_OutOfWindowExcluded(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// One session inside the window, one outside. With Since=2d, the
// out-of-window session should not contribute sessionIDs, so its
// messages are absent from hourly_utc even if their timestamps
// fall within the window-viewed hours.
insertSessionFixture(t, d, sessionFixture{
id: "in", userMsgs: 1, startedAt: hoursAgo(10),
})
insertSessionFixture(t, d, sessionFixture{
id: "out", userMsgs: 1,
// 40 days ago, well past Since=2d.
startedAt: time.Now().UTC().
Add(-40 * 24 * time.Hour).Format(time.RFC3339),
})
insertMessages(t, d,
userMsgAt("in", 0, "ok", hoursAgoAt(10, 0, 0)),
userMsgAt("out", 0, "nope",
hoursAgoAt(10, 30, 0)), // same hour as "in"
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "2d"})
require.NoError(t, err, "GetSessionStats")
// Exactly one hour bucket, with a single user message (from "in").
require.Len(t, stats.Temporal.HourlyUTC, 1,
"hourly_utc want 1 entry: %+v", stats.Temporal.HourlyUTC)
got := stats.Temporal.HourlyUTC[0]
assert.Equal(t, 1, got.UserMessages, "in-window user_messages")
assert.Equal(t, 1, got.Sessions, "in-window sessions: got")
}
func TestGetSessionStats_Temporal_SessionsDistinctPerHour(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Same session sends 3 user messages in H-6 (counts as 1 session,
// 3 user_messages) and 1 user message in H-5 (counts as 1 session,
// 1 user_message). The session appears in both hour entries.
insertSessionFixture(t, d, sessionFixture{
id: "s1", userMsgs: 4, startedAt: hoursAgo(7),
})
insertMessages(t, d,
userMsgAt("s1", 0, "a", hoursAgoAt(6, 5, 0)),
userMsgAt("s1", 1, "b", hoursAgoAt(6, 25, 0)),
userMsgAt("s1", 2, "c", hoursAgoAt(6, 55, 0)),
userMsgAt("s1", 3, "d", hoursAgoAt(5, 15, 0)),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
if e := findHourlyUTC(
stats.Temporal.HourlyUTC, utcHourBoundary(6),
); assert.NotNil(t, e, "missing H-6 entry") {
assert.Equal(t, 3, e.UserMessages, "H-6 user_messages")
assert.Equal(t, 1, e.Sessions,
"H-6 sessions (same session 3 msgs)")
}
if e := findHourlyUTC(
stats.Temporal.HourlyUTC, utcHourBoundary(5),
); assert.NotNil(t, e, "missing H-5 entry") {
assert.Equal(t, 1, e.UserMessages, "H-5 user_messages")
assert.Equal(t, 1, e.Sessions, "H-5 sessions: got")
}
}
func TestGetSessionStats_Temporal_EmptyWindowEmptySlice(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.NotNil(t, stats.Temporal.HourlyUTC,
"hourly_utc must be a non-nil empty slice, got nil")
assert.Len(t, stats.Temporal.HourlyUTC, 0, "hourly_utc: got len")
// Reporter timezone may now be empty when the host only exposes the
// Local sentinel; otherwise it must still be a loadable IANA name.
if stats.Temporal.ReporterTimezone != "" {
_, tzErr := time.LoadLocation(stats.Temporal.ReporterTimezone)
assert.NoError(t, tzErr,
"reporter_timezone must stay loadable when populated")
}
// JSON encoding must emit [] not null.
raw, err := json.Marshal(stats.Temporal.HourlyUTC)
require.NoError(t, err, "json.Marshal")
assert.Equal(t, "[]", string(raw), "hourly_utc JSON")
}
func TestGetSessionStats_Temporal_ReporterTimezone_FilterWins(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d",
Timezone: "America/New_York",
})
require.NoError(t, err, "GetSessionStats")
assert.Equal(t, "America/New_York", stats.Temporal.ReporterTimezone,
"reporter_timezone")
}
func TestReporterTimezone_Precedence(t *testing.T) {
prev, hadTZ := os.LookupEnv("TZ")
t.Cleanup(func() {
if hadTZ {
_ = os.Setenv("TZ", prev)
} else {
_ = os.Unsetenv("TZ")
}
})
oldLocal := time.Local
t.Cleanup(func() { time.Local = oldLocal })
// Filter wins over env.
err := os.Setenv("TZ", "Europe/Berlin")
require.NoError(t, err, "set TZ")
assert.Equal(t, "Asia/Tokyo",
reporterTimezone(StatsFilter{Timezone: "Asia/Tokyo"}),
"filter wins")
// No filter → env wins.
assert.Equal(t, "Europe/Berlin",
reporterTimezone(StatsFilter{}), "env wins")
// No filter, no env, valid local name → local wins.
err = os.Unsetenv("TZ")
require.NoError(t, err, "unset TZ")
time.Local = time.FixedZone("America/New_York", -5*60*60)
assert.Equal(t, "America/New_York",
reporterTimezone(StatsFilter{}),
"valid local name should pass through")
// No filter, no env, Local sentinel → emit empty fallback.
time.Local = time.FixedZone("Local", 0)
assert.Equal(t, "", reporterTimezone(StatsFilter{}),
"Local sentinel should not be published")
}
func TestGetSessionStats_Temporal_FilterByAgentFlowsThrough(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Two sessions, same hour, different agents. Filter=claude must
// leave the codex session's messages out of hourly_utc.
insertSessionFixture(t, d, sessionFixture{
id: "c1", agent: "claude", userMsgs: 1,
startedAt: hoursAgo(4),
})
insertSessionFixture(t, d, sessionFixture{
id: "x1", agent: "codex", userMsgs: 1,
startedAt: hoursAgo(4),
})
insertMessages(t, d,
userMsgAt("c1", 0, "hi", hoursAgoAt(3, 10, 0)),
userMsgAt("x1", 0, "hi", hoursAgoAt(3, 20, 0)),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{
Since: "28d", Agent: "claude",
})
require.NoError(t, err, "GetSessionStats")
if e := findHourlyUTC(
stats.Temporal.HourlyUTC, utcHourBoundary(3),
); assert.NotNil(t, e, "missing H-3 entry") {
assert.Equal(t, 1, e.UserMessages,
"filter=claude user_messages")
assert.Equal(t, 1, e.Sessions, "filter=claude sessions")
}
}
func TestGetSessionStats_Temporal_IgnoresAssistantMessages(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Only assistant messages in a session — hourly_utc must be empty.
insertSessionFixture(t, d, sessionFixture{
id: "only-asst", userMsgs: 0, messageCount: 2,
startedAt: hoursAgo(3),
})
insertMessages(t, d,
asstMsgAt("only-asst", 0, "a", hoursAgoAt(2, 5, 0)),
asstMsgAt("only-asst", 1, "b", hoursAgoAt(2, 10, 0)),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Empty(t, stats.Temporal.HourlyUTC,
"hourly_utc should be empty when only assistant msgs")
}
func TestGetSessionStats_Temporal_SkipsEmptyTimestamps(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// User message with empty timestamp must not bucket to epoch or
// anywhere else — strftime returns NULL for empty strings and we
// drop the row.
insertSessionFixture(t, d, sessionFixture{
id: "s1", userMsgs: 2, startedAt: hoursAgo(3),
})
insertMessages(t, d,
userMsgAt("s1", 0, "good", hoursAgoAt(2, 15, 0)),
userMsgAt("s1", 1, "blank", ""),
)
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
require.Len(t, stats.Temporal.HourlyUTC, 1,
"hourly_utc want 1 entry: %+v", stats.Temporal.HourlyUTC)
assert.Equal(t, 1, stats.Temporal.HourlyUTC[0].UserMessages,
"user_messages want 1 (blank skipped)")
}
// TestGetSessionStats_Outcomes_Happy seeds five Claude sessions spanning
// the full outcome vocabulary that agentsview actually stores
// ("completed", "abandoned", "errored", "unknown" — see
// internal/signals/outcome.go) and asserts every field on StatsOutcomes.
// Per-row tool/retry/compaction/churn values are deliberately asymmetric
// (distinct sums: retries=7, compactions=10, churn=15) so a field-swap
// regression in the loader or aggregator would be caught.
func TestGetSessionStats_Outcomes_Happy(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// s-a: completed / grade A / 2 tools / 1 retry / 3 compactions / 5 churn
insertSessionFixture(t, d, sessionFixture{
id: "s-a", userMsgs: 4, startedAt: hoursAgo(6),
totalToolCalls: 2, assistantTurns: 2,
})
updateSignals(t, d, "s-a", SessionSignalUpdate{
Outcome: "completed",
HealthGrade: new("A"),
ToolRetryCount: 1,
CompactionCount: 3,
EditChurnCount: 5,
})
// s-b: completed / grade B / 4 tools / 0 retries / 1 compaction / 0 churn
insertSessionFixture(t, d, sessionFixture{
id: "s-b", userMsgs: 3, startedAt: hoursAgo(5),
totalToolCalls: 4, assistantTurns: 3,
})
updateSignals(t, d, "s-b", SessionSignalUpdate{
Outcome: "completed",
HealthGrade: new("B"),
ToolRetryCount: 0,
CompactionCount: 1,
EditChurnCount: 0,
})
// s-c: abandoned / grade C / 6 tools / 3 retries / 0 compactions / 4 churn
insertSessionFixture(t, d, sessionFixture{
id: "s-c", userMsgs: 6, startedAt: hoursAgo(4),
totalToolCalls: 6, assistantTurns: 4,
})
updateSignals(t, d, "s-c", SessionSignalUpdate{
Outcome: "abandoned",
HealthGrade: new("C"),
ToolRetryCount: 3,
CompactionCount: 0,
EditChurnCount: 4,
})
// s-d: errored / grade D / 8 tools / 2 retries / 2 compactions / 6 churn
insertSessionFixture(t, d, sessionFixture{
id: "s-d", userMsgs: 5, startedAt: hoursAgo(3),
totalToolCalls: 8, assistantTurns: 5,
})
updateSignals(t, d, "s-d", SessionSignalUpdate{
Outcome: "errored",
HealthGrade: new("D"),
ToolRetryCount: 2,
CompactionCount: 2,
EditChurnCount: 6,
})
// s-e: unknown / no grade / 5 tools / 1 retry / 4 compactions / 0 churn.
// Explicit "unknown" exercises the default branch; the nil HealthGrade
// pointer must NOT add an entry to GradeDistribution.
insertSessionFixture(t, d, sessionFixture{
id: "s-e", userMsgs: 2, startedAt: hoursAgo(2),
totalToolCalls: 5, assistantTurns: 2,
})
updateSignals(t, d, "s-e", SessionSignalUpdate{
Outcome: "unknown",
HealthGrade: nil,
ToolRetryCount: 1,
CompactionCount: 4,
EditChurnCount: 0,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
out := stats.Outcomes
require.NotNil(t, out, "Outcomes: want populated")
assert.True(t, out.ClaudeOnly, "ClaudeOnly")
// Two "completed" -> Success.
assert.Equal(t, 2, out.Success, "Success: got")
// One "abandoned" + one "errored" -> Failure.
assert.Equal(t, 2, out.Failure, "Failure: got")
// One explicit "unknown" -> Unknown.
assert.Equal(t, 1, out.Unknown, "Unknown: got")
require.NotNil(t, out.GradeDistribution,
"GradeDistribution: want non-nil")
wantGrades := map[string]int{"A": 1, "B": 1, "C": 1, "D": 1}
assert.Len(t, out.GradeDistribution, len(wantGrades),
"GradeDistribution size (%+v)", out.GradeDistribution)
for grade, want := range wantGrades {
assert.Equal(t, want, out.GradeDistribution[grade],
"GradeDistribution[%q]", grade)
}
assert.NotContains(t, out.GradeDistribution, "",
"GradeDistribution: empty-string key present (%+v)",
out.GradeDistribution)
// ToolRetryRate = (1+0+3+2+1) / (2+4+6+8+5) = 7/25 = 0.28
assert.InDelta(t, 0.28, out.ToolRetryRate, 1e-9,
"ToolRetryRate")
// CompactionsPerSession = (3+1+0+2+4) / 5 = 10/5 = 2.0
assert.InDelta(t, 2.0, out.CompactionsPerSession, 1e-9,
"CompactionsPerSession")
// AvgEditChurn = (5+0+4+6+0) / 5 = 15/5 = 3.0
assert.InDelta(t, 3.0, out.AvgEditChurn, 1e-9, "AvgEditChurn")
}
// TestGetSessionStats_Outcomes_NoClaude verifies that Outcomes stays
// nil — NOT zero-valued — when the window contains zero Claude
// sessions. A *StatsOutcomes with ClaudeOnly=false would misrepresent
// a pure codex workload as having an outcome signal of all-zeroes.
func TestGetSessionStats_Outcomes_NoClaude(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3, startedAt: hoursAgo(2),
})
updateSignals(t, d, "cx1", SessionSignalUpdate{
Outcome: "completed",
HealthGrade: new("A"),
ToolRetryCount: 5,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Nil(t, stats.Outcomes, "Outcomes")
}
// TestGetSessionStats_Outcomes_NoGrade verifies that a Claude session
// with an empty health_grade still produces a populated Outcomes
// pointer, with a non-nil empty GradeDistribution map (not nil) and
// zeroed rates when no tools were recorded.
func TestGetSessionStats_Outcomes_NoGrade(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSessionFixture(t, d, sessionFixture{
id: "ng", userMsgs: 2, startedAt: hoursAgo(2),
})
updateSignals(t, d, "ng", SessionSignalUpdate{
Outcome: "completed",
// HealthGrade left nil — stored as NULL, loaded as "".
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
out := stats.Outcomes
require.NotNil(t, out, "Outcomes: want populated")
assert.NotNil(t, out.GradeDistribution,
"GradeDistribution: want empty map")
assert.Empty(t, out.GradeDistribution, "GradeDistribution")
assert.Equal(t, 0.0, out.ToolRetryRate,
"ToolRetryRate want 0 (no tools)")
assert.Equal(t, 1, out.Success, "Success: got")
}
// seedToolCallsByName inserts one assistant message per entry in calls and
// a matching tool_calls row with the requested tool_name and skill_name.
// Used by the adoption tests, which key off tool_name (not category) and
// need to populate skill_name for the Skill tool.
func seedToolCallsByName(
t *testing.T, d *DB, sessionID string, calls []toolCallSeed,
) {
t.Helper()
if len(calls) == 0 {
return
}
msgs := make([]Message, 0, len(calls))
for i, c := range calls {
msgs = append(msgs, asstMsg(sessionID, i+1, "reply-"+c.toolName))
}
require.NoError(t, d.InsertMessages(msgs),
"seedToolCallsByName %s: InsertMessages", sessionID)
for i, c := range calls {
ord := i + 1
var skill any
if c.skillName != "" {
skill = c.skillName
}
_, err := d.getWriter().Exec(`
INSERT INTO tool_calls
(message_id, session_id, tool_name, category, skill_name)
SELECT id, session_id, ?, ?, ?
FROM messages
WHERE session_id = ? AND ordinal = ?`,
c.toolName, c.toolName, skill, sessionID, ord,
)
require.NoError(t, err,
"seedToolCallsByName %s: %q", sessionID, c.toolName)
}
}
// toolCallSeed describes one tool_calls row for seedToolCallsByName.
// skillName is written only for tool_name = "Skill"; other rows leave
// the column NULL.
type toolCallSeed struct {
toolName string
skillName string
}
// TestGetSessionStats_Adoption_Happy seeds four Claude sessions with
// deliberately asymmetric adoption signals and asserts every field on
// StatsAdoption. A fifth codex session must not influence any number
// (adoption is Claude-only).
//
// Rates are hand-computed against the seed:
// - PlanModeRate: 2 of 4 Claude sessions have >=1 ExitPlanMode -> 0.5
// - SubagentsPerSession: 3 Task calls across 4 sessions -> 0.75
// - DistinctSkills: {"brainstorm", "writing-plans", "brainstorm"}
// -> 2 distinct names
func TestGetSessionStats_Adoption_Happy(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// ad1: one ExitPlanMode, zero Task, one Skill("brainstorm").
insertSessionFixture(t, d, sessionFixture{
id: "ad1", agent: "claude", userMsgs: 4,
startedAt: hoursAgo(6),
})
seedToolCallsByName(t, d, "ad1", []toolCallSeed{
{toolName: "ExitPlanMode"},
{toolName: "Skill", skillName: "brainstorm"},
})
// ad2: zero ExitPlanMode, two Task calls, one Skill("writing-plans").
insertSessionFixture(t, d, sessionFixture{
id: "ad2", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5),
})
seedToolCallsByName(t, d, "ad2", []toolCallSeed{
{toolName: "Task"},
{toolName: "Task"},
{toolName: "Skill", skillName: "writing-plans"},
})
// ad3: one ExitPlanMode, one Task, one Skill("brainstorm") —
// duplicate skill name must collapse in DistinctSkills.
insertSessionFixture(t, d, sessionFixture{
id: "ad3", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(4),
})
seedToolCallsByName(t, d, "ad3", []toolCallSeed{
{toolName: "ExitPlanMode"},
{toolName: "Task"},
{toolName: "Skill", skillName: "brainstorm"},
})
// ad4: nothing interesting — exercises the denominator.
insertSessionFixture(t, d, sessionFixture{
id: "ad4", agent: "claude", userMsgs: 3,
startedAt: hoursAgo(3),
})
seedToolCallsByName(t, d, "ad4", []toolCallSeed{
{toolName: "Read"},
})
// cx1 (codex) with matching tool names — must be excluded.
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedToolCallsByName(t, d, "cx1", []toolCallSeed{
{toolName: "ExitPlanMode"},
{toolName: "Task"},
{toolName: "Skill", skillName: "codex-only"},
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
ad := stats.Adoption
require.NotNil(t, ad, "Adoption: want populated")
assert.True(t, ad.ClaudeOnly, "ClaudeOnly")
// 2 of 4 Claude sessions have ExitPlanMode -> 0.5.
assert.InDelta(t, 0.5, ad.PlanModeRate, 1e-9, "PlanModeRate")
assert.GreaterOrEqual(t, ad.PlanModeRate, 0.0,
"PlanModeRate out of [0,1]")
assert.LessOrEqual(t, ad.PlanModeRate, 1.0,
"PlanModeRate out of [0,1]")
// 3 Task calls across 4 Claude sessions -> 0.75.
assert.InDelta(t, 0.75, ad.SubagentsPerSession, 1e-9,
"SubagentsPerSession")
// {"brainstorm","writing-plans","brainstorm"} -> 2 distinct.
assert.Equal(t, 2, ad.DistinctSkills, "DistinctSkills: got")
}
// TestGetSessionStats_Adoption_NoClaude verifies that Adoption stays
// nil — NOT zero-valued — when the window has no Claude sessions. A
// *StatsAdoption with ClaudeOnly=false would misrepresent a pure codex
// workload as having legitimate all-zero adoption signal.
func TestGetSessionStats_Adoption_NoClaude(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSessionFixture(t, d, sessionFixture{
id: "cx1", agent: "codex", userMsgs: 3,
startedAt: hoursAgo(2),
})
seedToolCallsByName(t, d, "cx1", []toolCallSeed{
{toolName: "ExitPlanMode"},
{toolName: "Task"},
{toolName: "Skill", skillName: "brainstorm"},
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Nil(t, stats.Adoption, "Adoption")
}
// skipIfNoGit lets CI environments without git on PATH pass cleanly
// instead of failing the outcome_stats suite. The stats pipeline
// tolerates missing git (computeOutcomeStats silently leaves the field
// nil when the exec fails), but tests that seed a real fixture repo
// require the binary.
func skipIfNoGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git not available on PATH: %v", err)
}
}
// statsRunGit executes a git subcommand inside repo and fails the test
// on error. Kept local to the stats_test file because the git package
// test helpers are unexported.
func statsRunGit(t *testing.T, repo string, env []string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(), env...)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %s\n%s",
strings.Join(args, " "), out)
}
func statsInitRepoAt(t *testing.T, repo string) {
t.Helper()
require.NoError(t, os.MkdirAll(repo, 0o755), "mkdir repo")
statsRunGit(t, repo, nil, "init", "-q", "-b", "main")
statsRunGit(t, repo, nil, "config", "user.email", "test@example.com")
statsRunGit(t, repo, nil, "config", "user.name", "Test User")
statsRunGit(t, repo, nil, "config", "commit.gpgsign", "false")
}
// statsCommitFile writes content into repo/relpath, stages it, and
// commits as test@example.com with message. Used by outcome_stats tests
// that need a handful of commits with known LOC footprints.
func statsCommitFile(
t *testing.T, repo, relpath, content, message string,
) {
t.Helper()
p := filepath.Join(repo, relpath)
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755),
"mkdir %s", filepath.Dir(p))
require.NoError(t, os.WriteFile(p, []byte(content), 0o644),
"write %s", p)
env := []string{
"GIT_AUTHOR_NAME=Test User",
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test User",
"GIT_COMMITTER_EMAIL=test@example.com",
}
statsRunGit(t, repo, nil, "add", "-A")
statsRunGit(t, repo, env, "commit", "-q", "-m", message)
}
var (
statsOutcomeRepoOnce sync.Once
statsOutcomeRepoDir string
statsOutcomeRepoPath string
)
func statsOutcomeRepo(t *testing.T) string {
t.Helper()
statsOutcomeRepoOnce.Do(func() {
dir, err := os.MkdirTemp("", "agentsview-stats-outcome-*")
require.NoError(t, err, "create stats outcome repo dir")
statsOutcomeRepoDir = dir
statsOutcomeRepoPath = filepath.Join(dir, "repo")
statsInitRepoAt(t, statsOutcomeRepoPath)
// Three commits by test@example.com with known LOC counts.
// c1 a.txt: +3 -0 (new file, 3 lines)
// c2 a.txt: +2 -0 (append 2 lines)
// c3 b.txt: +4 -0 (new file, 4 lines)
statsCommitFile(t, statsOutcomeRepoPath,
"a.txt", "a1\na2\na3\n", "c1")
statsCommitFile(t, statsOutcomeRepoPath,
"a.txt", "a1\na2\na3\na4\na5\n", "c2")
statsCommitFile(t, statsOutcomeRepoPath,
"b.txt", "b1\nb2\nb3\nb4\n", "c3")
require.NoError(t, os.MkdirAll(
filepath.Join(statsOutcomeRepoPath, "subdir"), 0o755,
), "mkdir stats outcome subdir")
})
return statsOutcomeRepoPath
}
// TestGetSessionStats_OutcomeStats_Happy seeds sessions whose cwd
// points inside a real fixture repo, verifies outcome_stats is off by
// default, and asserts that enabling it surfaces the author-filtered
// commit totals. PRsOpened /
// PRsMerged must stay nil because no GHToken is supplied — the JSON
// contract distinguishes "gh not configured" (nil) from "gh configured,
// zero PRs" (pointer to 0).
func TestGetSessionStats_OutcomeStats_Happy(t *testing.T) {
skipIfNoGit(t)
d := testDB(t)
ctx := context.Background()
repo := statsOutcomeRepo(t)
// Two Claude sessions with cwds inside the repo — one at the root,
// one in a subdirectory. Both should collapse to the same repo and
// counted once in ReposActive.
sub := filepath.Join(repo, "subdir")
insertSessionFixture(t, d, sessionFixture{
id: "os1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(5), cwd: repo,
})
insertSessionFixture(t, d, sessionFixture{
id: "os2", agent: "claude", userMsgs: 4,
startedAt: hoursAgo(4), cwd: sub,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats without git outcomes")
require.Nil(t, stats.OutcomeStats, "OutcomeStats")
stats, err = d.GetSessionStats(ctx, StatsFilter{
Since: "28d", IncludeGitOutcomes: true,
})
require.NoError(t, err, "GetSessionStats")
out := stats.OutcomeStats
require.NotNil(t, out, "OutcomeStats: want populated")
assert.Equal(t, 1, out.ReposActive, "ReposActive: got")
assert.Equal(t, 3, out.Commits, "Commits: got")
assert.Equal(t, 9, out.LOCAdded, "LOCAdded: got")
assert.Equal(t, 0, out.LOCRemoved, "LOCRemoved: got")
// Each commit touches one file: c1 a.txt, c2 a.txt, c3 b.txt -> 3.
assert.Equal(t, 3, out.FilesChanged, "FilesChanged: got")
assert.Nil(t, out.PRsOpened, "PRsOpened want nil (no GHToken)")
assert.Nil(t, out.PRsMerged, "PRsMerged want nil (no GHToken)")
}
// TestGetSessionStats_OutcomeStats_NoCwd verifies that sessions without
// a recorded cwd leave OutcomeStats nil — a pure non-git workload must
// not surface a fabricated all-zero outcome row. The JSON contract uses
// omitempty + nil pointer so the section is absent entirely rather than
// serialising as {"repos_active":0,...}.
func TestGetSessionStats_OutcomeStats_NoCwd(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSessionFixture(t, d, sessionFixture{
id: "nc1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(3),
// cwd intentionally empty
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Nil(t, stats.OutcomeStats, "OutcomeStats")
}
// TestGetSessionStats_OutcomeStats_CwdOutsideRepo verifies that a cwd
// pointing at a non-git directory (no .git anywhere up the tree) is
// treated the same as an empty cwd: DiscoverRepos returns nothing and
// OutcomeStats stays nil. Guards against silently reporting zeros for
// non-git workflows that happen to record a cwd.
func TestGetSessionStats_OutcomeStats_CwdOutsideRepo(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// t.TempDir() is nested under Go's test temp root, which is not
// itself inside a git repo on any supported platform.
nonRepo := t.TempDir()
insertSessionFixture(t, d, sessionFixture{
id: "nr1", agent: "claude", userMsgs: 5,
startedAt: hoursAgo(3), cwd: nonRepo,
})
stats, err := d.GetSessionStats(ctx, StatsFilter{Since: "28d"})
require.NoError(t, err, "GetSessionStats")
assert.Nil(t, stats.OutcomeStats, "OutcomeStats")
}