项目文件夹

文件
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 行
90 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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")
}