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

5004 lines
151 KiB
Go

package db
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type seedStats struct {
TotalSessions int
TotalMessages int
TotalUserMessages int
TotalAssistantMessages int
ActiveProjects int
ActiveDays int
}
func seedAnalyticsData(t *testing.T, d *DB) seedStats {
t.Helper()
type sessionData struct {
id string
project string
start string
end string
msgs int
agent string
model string
}
sessions := []sessionData{
// Project A: 3 sessions across 2 days, mixed agents
{"a1", "project-alpha", "2024-06-01T09:00:00Z", tsMidYear, 10, "claude", "claude-3-5-sonnet"},
{"a2", "project-alpha", "2024-06-01T14:00:00Z", "2024-06-01T15:00:00Z", 20, "codex", "gpt-4o"},
{"a3", "project-alpha", "2024-06-03T09:00:00Z", "2024-06-03T10:00:00Z", 5, "claude", "claude-3-5-sonnet"},
// Project B: 2 sessions on 1 day
{"b1", "project-beta", "2024-06-02T10:00:00Z", "2024-06-02T11:00:00Z", 30, "claude", "gpt-4o-mini"},
{"b2", "project-beta", "2024-06-02T15:00:00Z", "2024-06-02T16:00:00Z", 15, "claude", "gpt-4o"},
}
stats := seedStats{}
projects := make(map[string]bool)
days := make(map[string]bool)
for _, sess := range sessions {
stats.TotalSessions++
stats.TotalMessages += sess.msgs
for i := 0; i < sess.msgs; i++ {
if i%2 == 1 {
stats.TotalAssistantMessages++
} else {
stats.TotalUserMessages++
}
}
projects[sess.project] = true
if len(sess.start) >= 10 {
days[sess.start[:10]] = true
}
insertSession(t, d, sess.id, sess.project, func(s *Session) {
s.StartedAt = new(sess.start)
s.EndedAt = new(sess.end)
s.MessageCount = sess.msgs
s.Agent = sess.agent
})
msgs := make([]Message, sess.msgs)
for i := 0; i < sess.msgs; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs[i] = Message{
SessionID: sess.id,
Ordinal: i,
Role: role,
Content: fmt.Sprintf("msg %d", i),
ContentLength: 5,
Timestamp: tsMidYear,
Model: sess.model,
}
}
insertMessages(t, d, msgs...)
}
stats.ActiveProjects = len(projects)
stats.ActiveDays = len(days)
return stats
}
func baseFilter() AnalyticsFilter {
return AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-03",
Timezone: "UTC",
}
}
func emptyFilter() AnalyticsFilter {
return AnalyticsFilter{
From: "2020-01-01",
To: "2020-01-02",
Timezone: "UTC",
}
}
func mustSummary(
t *testing.T, d *DB, ctx context.Context, f AnalyticsFilter,
) AnalyticsSummary {
t.Helper()
s, err := d.GetAnalyticsSummary(ctx, f)
require.NoError(t, err, "GetAnalyticsSummary")
return s
}
func mustActivity(
t *testing.T, d *DB, ctx context.Context,
f AnalyticsFilter, gran string,
) ActivityResponse {
t.Helper()
r, err := d.GetAnalyticsActivity(ctx, f, gran)
require.NoError(t, err, "GetAnalyticsActivity")
return r
}
func mustHeatmap(
t *testing.T, d *DB, ctx context.Context,
f AnalyticsFilter, metric string,
) HeatmapResponse {
t.Helper()
r, err := d.GetAnalyticsHeatmap(ctx, f, metric)
require.NoError(t, err, "GetAnalyticsHeatmap")
return r
}
func mustProjects(
t *testing.T, d *DB, ctx context.Context,
f AnalyticsFilter,
) ProjectsAnalyticsResponse {
t.Helper()
r, err := d.GetAnalyticsProjects(ctx, f)
require.NoError(t, err, "GetAnalyticsProjects")
return r
}
func TestGetAnalyticsSummary(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
s := mustSummary(t, d, ctx, baseFilter())
assert.Equal(t, 0, s.TotalSessions, "TotalSessions")
})
stats := seedAnalyticsData(t, d)
t.Run("FullRange", func(t *testing.T) {
s := mustSummary(t, d, ctx, baseFilter())
assert.Equal(t, stats.TotalSessions, s.TotalSessions, "TotalSessions")
assert.Equal(t, stats.TotalMessages, s.TotalMessages, "TotalMessages")
assert.Equal(t, stats.ActiveProjects, s.ActiveProjects, "ActiveProjects")
assert.Equal(t, stats.ActiveDays, s.ActiveDays, "ActiveDays")
assert.Equal(t, "project-beta", s.MostActive, "MostActive")
// 2 projects, both in top 3 → concentration = 1.0
assert.Equal(t, 1.0, s.Concentration, "Concentration")
// Sorted message counts: [5, 10, 15, 20, 30]
assert.Equal(t, 15, s.MedianMessages, "MedianMessages")
// P90 index = int(5*0.9) = 4 → value 30
assert.Equal(t, 30, s.P90Messages, "P90Messages")
assert.Equal(t,
[]string{
"claude-3-5-sonnet",
"gpt-4o",
"gpt-4o-mini",
},
s.Models,
"Models",
)
require.NotNil(t, s.Agents["claude"], "expected claude agent entry")
assert.Equal(t, 4, s.Agents["claude"].Sessions, "claude sessions")
require.NotNil(t, s.Agents["codex"], "expected codex agent entry")
assert.Equal(t, 1, s.Agents["codex"].Sessions, "codex sessions")
})
t.Run("DateSubset", func(t *testing.T) {
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
t.Run("MachineFilter", func(t *testing.T) {
f := baseFilter()
f.Machine = "nonexistent"
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 0, s.TotalSessions, "TotalSessions")
})
t.Run("EmptyDateRange", func(t *testing.T) {
s := mustSummary(t, d, ctx, emptyFilter())
assert.Equal(t, 0, s.TotalSessions, "TotalSessions")
assert.Empty(t, s.Models, "Models")
})
}
// TestRelationshipExclusionSQL covers the shared predicate helper that
// the analytics builders and the stats pipeline both use, including the
// column qualifier the DuckDB builder relies on.
func TestRelationshipExclusionSQL(t *testing.T) {
cases := []struct {
includeSubagents bool
includeForks bool
colPrefix string
want string
}{
{false, false, "", "relationship_type NOT IN ('subagent', 'fork')"},
{true, false, "", "relationship_type NOT IN ('fork')"},
{false, true, "", "relationship_type NOT IN ('subagent')"},
{true, true, "", "1=1"},
{false, false, "s.", "s.relationship_type NOT IN ('subagent', 'fork')"},
{true, false, "s.", "s.relationship_type NOT IN ('fork')"},
{false, true, "s.", "s.relationship_type NOT IN ('subagent')"},
{true, true, "s.", "1=1"},
}
for _, c := range cases {
got := RelationshipExclusionSQL(c.includeSubagents, c.includeForks, c.colPrefix)
assert.Equal(t, c.want, got,
"includeSubagents=%v includeForks=%v colPrefix=%q",
c.includeSubagents, c.includeForks, c.colPrefix)
}
// The method form delegates to the unqualified helper.
assert.Equal(t,
RelationshipExclusionSQL(true, false, ""),
AnalyticsFilter{IncludeSubagents: true}.RelationshipExclusionSQL(),
"method must match the free function")
assert.Equal(t,
RelationshipExclusionSQL(true, true, ""),
AnalyticsFilter{IncludeSubagents: true, IncludeForks: true}.RelationshipExclusionSQL(),
"method must match the free function with forks included")
}
// TestAnalyticsSubagentScope verifies the two-bucket rule for subagent
// sessions (e.g. workflow subagents):
// - Sum/count surfaces (summary) COUNT subagents: their output tokens
// and messages are real, independent spend in separate transcripts.
// - Distribution surfaces (session-shape) stay ROOT-ONLY so the many
// short subagent sessions do not skew length/duration distributions.
//
// Fork rows stay excluded on both surfaces because their tokens overlap
// their root session and would double-count.
func TestAnalyticsSubagentScope(t *testing.T) {
d := testDB(t)
ctx := context.Background()
withTokens := func(rel string, msgs, userMsgs, tokens int) func(*Session) {
return func(s *Session) {
s.StartedAt = new("2024-06-02T10:00:00Z")
s.EndedAt = new("2024-06-02T11:00:00Z")
s.MessageCount = msgs
s.UserMessageCount = userMsgs
s.RelationshipType = rel
s.TotalOutputTokens = tokens
s.HasTotalOutputTokens = true
}
}
// Root session: 10 msgs, multi-turn, 1000 output tokens.
insertSession(t, d, "root", "project-alpha", withTokens("", 10, 3, 1000))
// Subagent: ONE-SHOT (1 user msg), 400 tokens. Workflow subagents
// are always one-shot; it must still be counted in aggregates.
insertSession(t, d, "agent-x", "project-alpha",
withTokens("subagent", 4, 1, 400))
// Fork: shares the root conversation — excluded everywhere.
insertSession(t, d, "root-fork", "project-alpha",
withTokens("fork", 6, 1, 600))
// Default filter excludes one-shot sessions, matching the summary
// endpoint default. The one-shot subagent must survive this.
f := baseFilter()
f.ExcludeOneShot = true
s := mustSummary(t, d, ctx, f)
// Summary counts the one-shot subagent; fork stays excluded.
assert.Equal(t, 1400, s.TotalOutputTokens, "TotalOutputTokens")
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
assert.Equal(t, 14, s.TotalMessages, "TotalMessages")
// Session-shape is a distribution surface: root only. The subagent
// is excluded, so the count is 1 (just the root; fork also excluded).
shape, err := d.GetAnalyticsSessionShape(ctx, f)
require.NoError(t, err, "GetAnalyticsSessionShape")
assert.Equal(t, 1, shape.Count, "session-shape stays root-only")
}
func TestAnalyticsModelFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "model-a", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "model-a", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "model-a", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T09:06:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "model-b", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T11:00:00Z")
s.EndedAt = new("2024-06-01T12:00:00Z")
s.MessageCount = 3
s.Agent = "codex"
})
insertMessages(t, d,
Message{
SessionID: "model-b", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T11:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "model-b", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T11:06:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "model-b", Ordinal: 2, Role: "user",
Content: "follow-up", ContentLength: 9,
Timestamp: "2024-06-01T11:07:00Z",
Model: "gpt-4o",
},
)
f := AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
}
t.Run("SingleModel", func(t *testing.T) {
ff := f
ff.Model = "gpt-4o"
s := mustSummary(t, d, ctx, ff)
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
assert.Equal(t, 3, s.TotalMessages, "TotalMessages")
assert.Equal(t, []string{"gpt-4o"}, s.Models, "Models")
})
t.Run("MultiModel", func(t *testing.T) {
ff := f
ff.Model = "gpt-4o, claude-3-5-sonnet"
s := mustSummary(t, d, ctx, ff)
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
assert.Equal(t,
[]string{"claude-3-5-sonnet", "gpt-4o"},
s.Models,
"Models",
)
})
}
func TestAnalyticsModelFilterGoTimePath(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "dst-claude", "proj", func(s *Session) {
s.StartedAt = new("2026-03-10T14:00:00Z")
s.EndedAt = new("2026-03-10T14:30:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "dst-claude", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2026-03-10T14:05:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "dst-claude", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2026-03-10T14:06:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "dst-gpt", "proj", func(s *Session) {
s.StartedAt = new("2026-03-05T15:00:00Z")
s.EndedAt = new("2026-03-05T15:30:00Z")
s.MessageCount = 2
s.Agent = "codex"
})
insertMessages(t, d,
Message{
SessionID: "dst-gpt", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2026-03-05T15:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "dst-gpt", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2026-03-05T15:06:00Z",
Model: "gpt-4o",
},
)
hour := 10
s, err := d.GetAnalyticsSummary(ctx, AnalyticsFilter{
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
Model: "gpt-4o",
Hour: &hour,
})
require.NoError(t, err, "GetAnalyticsSummary")
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
assert.Equal(t, []string{"gpt-4o"}, s.Models, "Models")
}
func TestAnalyticsSummaryModelFilterCountsOnlyMatchingMessages(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "summary-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "summary-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "summary-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:06:00Z",
Model: "claude-3-5-sonnet",
},
)
resp := mustSummary(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
assert.Equal(t, 1, resp.TotalSessions, "TotalSessions")
assert.Equal(t, 1, resp.TotalMessages, "TotalMessages")
assert.Equal(t, []string{"gpt-4o"}, resp.Models, "Models")
assert.Equal(t, 1.0, resp.AvgMessages, "AvgMessages")
assert.Equal(t, 1, resp.MedianMessages, "MedianMessages")
assert.Equal(t, 1, resp.P90Messages, "P90Messages")
require.Contains(t, resp.Agents, "mixed")
assert.Equal(t, 1, resp.Agents["mixed"].Messages, "AgentMessages")
}
func TestAnalyticsSummaryModelsRespectHourFilterSQLiteSQL(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "hour-a", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "hour-a", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "hour-b", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 1
s.Agent = "codex"
})
insertMessages(t, d,
Message{
SessionID: "hour-b", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T10:05:00Z",
Model: "gpt-4o",
},
)
hour := 9
s := mustSummary(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Hour: &hour,
})
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
assert.Equal(t,
[]string{"claude-3-5-sonnet"},
s.Models,
"Models",
)
}
func TestAnalyticsSummaryModelsRespectHourFilterGoPath(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "ktm-a", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T04:15:00Z")
s.EndedAt = new("2024-06-01T04:45:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "ktm-a", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T04:15:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "ktm-b", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T05:15:00Z")
s.EndedAt = new("2024-06-01T05:45:00Z")
s.MessageCount = 1
s.Agent = "codex"
})
insertMessages(t, d,
Message{
SessionID: "ktm-b", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T05:15:00Z",
Model: "gpt-4o",
},
)
hour := 10
s := mustSummary(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01",
Timezone: "Asia/Kathmandu",
Hour: &hour,
})
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
assert.Equal(t,
[]string{"claude-3-5-sonnet"},
s.Models,
"Models",
)
}
func TestAnalyticsSummaryModelsUseMatchingHourRowsOnly(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "summary-hour-mixed", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "summary-hour-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "summary-hour-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T10:05:00Z",
Model: "claude-3-5-sonnet",
},
)
hour := 9
resp := mustSummary(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Hour: &hour,
})
assert.Equal(t, 1, resp.TotalSessions, "TotalSessions")
assert.Equal(t, []string{"gpt-4o"}, resp.Models, "Models")
}
func TestAnalyticsFilterMachineMultiSelect(t *testing.T) {
d := testDB(t)
ctx := context.Background()
for _, sess := range []struct {
id string
machine string
}{
{"machine-a", "laptop"},
{"machine-b", "server"},
{"machine-c", "desktop"},
} {
insertSession(t, d, sess.id, "project", func(s *Session) {
s.Machine = sess.machine
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 4
})
}
f := baseFilter()
f.Machine = "laptop,server"
s := mustSummary(t, d, ctx, f)
require.Equal(t, 2, s.TotalSessions, "TotalSessions")
}
// TestAnalyticsFilterAgentMultiSelectTrimsWhitespace guards backend
// parity: a comma-separated agent filter with surrounding spaces must
// match every listed agent, the same way the PostgreSQL and DuckDB
// analytics paths trim CSV values. Before the fix the SQLite path split
// on "," without trimming, so "claude, codex" matched only claude.
func TestAnalyticsFilterAgentMultiSelectTrimsWhitespace(t *testing.T) {
d := testDB(t)
ctx := context.Background()
for _, sess := range []struct {
id string
agent string
}{
{"agent-a", "claude"},
{"agent-b", "codex"},
{"agent-c", "gemini"},
} {
insertSession(t, d, sess.id, "project", func(s *Session) {
s.Agent = sess.agent
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 4
})
}
f := baseFilter()
f.Agent = "claude, codex"
s := mustSummary(t, d, ctx, f)
require.Equal(t, 2, s.TotalSessions, "TotalSessions")
}
func TestGetAnalyticsActivity(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats := seedAnalyticsData(t, d)
t.Run("DayGranularity", func(t *testing.T) {
resp := mustActivity(t, d, ctx, baseFilter(), "day")
assert.Equal(t, "day", resp.Granularity, "Granularity")
require.Len(t, resp.Series, stats.ActiveDays, "len(Series)")
// Day 1: 2 sessions (a1, a2)
assert.Equal(t, 2, resp.Series[0].Sessions, "Day1 sessions")
})
t.Run("WeekGranularity", func(t *testing.T) {
resp := mustActivity(t, d, ctx, baseFilter(), "week")
// 2024-06-01 is Saturday, 2024-06-03 is Monday
// So we expect 2 weeks: week of May 27 and week of Jun 3
assert.Equal(t, 2, len(resp.Series), "len(Series)")
})
t.Run("MonthGranularity", func(t *testing.T) {
resp := mustActivity(t, d, ctx, baseFilter(), "month")
assert.Equal(t, 1, len(resp.Series), "len(Series)")
assert.Equal(t, stats.TotalSessions, resp.Series[0].Sessions, "month sessions")
})
t.Run("HasRoleCounts", func(t *testing.T) {
resp := mustActivity(t, d, ctx, baseFilter(), "day")
totalUser := 0
totalAsst := 0
for _, e := range resp.Series {
totalUser += e.UserMessages
totalAsst += e.AssistantMessages
}
assert.Equal(t, stats.TotalMessages, totalUser+totalAsst, "total messages")
assert.Equal(t, stats.TotalUserMessages, totalUser, "total user messages")
assert.Equal(t, stats.TotalAssistantMessages, totalAsst, "total assistant messages")
})
}
func TestGetAnalyticsActivityModelFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-a", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "activity-a", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "activity-a", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T09:01:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "activity-b", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "codex"
})
insertMessages(t, d,
Message{
SessionID: "activity-b", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "activity-b", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:01:00Z",
Model: "gpt-4o",
},
)
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Sessions, "Sessions")
assert.Equal(t, 2, resp.Series[0].Messages, "Messages")
}
func TestGetAnalyticsActivityModelFilterCountsOnlyMatchingMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-mixed", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "activity-mixed", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "activity-mixed", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
},
)
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Sessions, "Sessions")
assert.Equal(t, 1, resp.Series[0].Messages, "Messages")
assert.Equal(t, 1, resp.Series[0].UserMessages, "UserMessages")
assert.Equal(t, 0, resp.Series[0].AssistantMessages,
"AssistantMessages")
}
func TestGetAnalyticsActivityModelFilterKeepsNullTimestampSessionsWithoutTimeFilter(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-null-ts", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "gpt"
})
insertMessages(t, d,
Message{
SessionID: "activity-null-ts", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "",
},
Message{
SessionID: "activity-null-ts", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Model: "gpt-4o",
},
)
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Sessions, "Sessions")
assert.Equal(t, 2, resp.Series[0].Messages, "Messages")
assert.Equal(t, 1, resp.Series[0].UserMessages, "UserMessages")
assert.Equal(t, 1, resp.Series[0].AssistantMessages,
"AssistantMessages")
}
func TestGetAnalyticsActivityModelAndHourFilterUseSameMessage(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-time", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "activity-time", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "activity-time", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "claude-3-5-sonnet",
},
)
hour := 10
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "day")
assert.Empty(t, resp.Series, "Series")
}
func TestGetAnalyticsActivityModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-paired-hour", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "activity-paired-hour", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
// Empty model: paired with the gpt-4o assistant at 10:00. Filtering
// by hour 9 must keep the session via this paired user turn, even
// though the model-bearing assistant sits in hour 10.
},
Message{
SessionID: "activity-paired-hour", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
)
hour := 9
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Sessions, "Sessions")
assert.Equal(t, 1, resp.Series[0].Messages, "Messages")
assert.Equal(t, 1, resp.Series[0].UserMessages, "UserMessages")
assert.Equal(t, 0, resp.Series[0].AssistantMessages,
"AssistantMessages")
}
func TestGetAnalyticsHeatmapSessionsModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "heatmap-sessions-paired", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "heatmap-sessions-paired", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
},
Message{
SessionID: "heatmap-sessions-paired", Ordinal: 1,
Role: "assistant", Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
)
// Empty-model user turn at hour 9 paired with a gpt-4o assistant at hour
// 10. The sessions heatmap must keep the session via the paired user turn.
hour := 9
resp := mustHeatmap(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "sessions")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 1, resp.Entries[0].Value, "Value")
}
func TestGetAnalyticsTopSessionsDurationModelAndHourFilterKeepsPairedUserTurn(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "top-duration-paired", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "top-duration-paired", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
},
Message{
SessionID: "top-duration-paired", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
)
// Empty-model user turn at hour 9 paired with a gpt-4o assistant at hour
// 10. Ranking by duration under the gpt-4o + hour-9 filter must keep the
// session via the paired user turn.
hour := 9
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 1, "len(Sessions)")
assert.Equal(t, "top-duration-paired", resp.Sessions[0].ID, "ID")
}
func TestGetAnalyticsActivityModelAndHourFilterCountsOnlyMatchingHourRows(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "activity-hour-gpt", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 0
s.Agent = "claude"
})
m1 := asstMsgAt("activity-hour-gpt", 0, "[Read: a.go]", "2024-06-01T09:00:00Z")
m1.Model = "gpt-4o"
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{SessionID: "activity-hour-gpt", ToolName: "Read", Category: "Read"},
{SessionID: "activity-hour-gpt", ToolName: "Bash", Category: "Bash"},
}
m2 := asstMsgAt("activity-hour-gpt", 1, "[Grep: b.go]", "2024-06-01T10:00:00Z")
m2.Model = "gpt-4o"
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{SessionID: "activity-hour-gpt", ToolName: "Grep", Category: "Grep"},
}
insertMessages(t, d, m1, m2)
hour := 10
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Sessions, "Sessions")
assert.Equal(t, 1, resp.Series[0].Messages, "Messages")
assert.Equal(t, 1, resp.Series[0].AssistantMessages,
"AssistantMessages")
assert.Equal(t, 1, resp.Series[0].ToolCalls, "ToolCalls")
}
func TestGetAnalyticsHeatmap(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats := seedAnalyticsData(t, d)
t.Run("MessageMetric", func(t *testing.T) {
resp := mustHeatmap(t, d, ctx, baseFilter(), "messages")
assert.Equal(t, "messages", resp.Metric, "Metric")
// 3 days in range: Jun 1, 2, 3
require.Len(t, resp.Entries, stats.ActiveDays, "len(Entries)")
totalMessages := 0
for _, e := range resp.Entries {
totalMessages += e.Value
}
assert.Equal(t, stats.TotalMessages, totalMessages, "total messages across heatmap")
// Jun 1: 10+20=30, Jun 2: 30+15=45, Jun 3: 5
assert.Equal(t, 30, resp.Entries[0].Value, "Jun1 value")
assert.Equal(t, 45, resp.Entries[1].Value, "Jun2 value")
assert.Equal(t, 5, resp.Entries[2].Value, "Jun3 value")
})
t.Run("SessionMetric", func(t *testing.T) {
resp := mustHeatmap(t, d, ctx, baseFilter(), "sessions")
assert.Equal(t, "sessions", resp.Metric, "Metric")
totalSessions := 0
for _, e := range resp.Entries {
totalSessions += e.Value
}
assert.Equal(t, stats.TotalSessions, totalSessions, "total sessions across heatmap")
// Jun 1: 2, Jun 2: 2, Jun 3: 1
assert.Equal(t, 2, resp.Entries[0].Value, "Jun1 sessions")
})
t.Run("LevelsAssigned", func(t *testing.T) {
resp := mustHeatmap(t, d, ctx, baseFilter(), "messages")
// All entries should have levels 0-4
for _, e := range resp.Entries {
assert.GreaterOrEqual(t, e.Level, 0,
"date %s level", e.Date)
assert.LessOrEqual(t, e.Level, 4,
"date %s level", e.Date)
}
})
t.Run("OutputTokensNoReporting", func(t *testing.T) {
// When no sessions report token coverage, the
// output_tokens heatmap must return empty entries
// rather than a zero-filled date grid.
resp := mustHeatmap(
t, d, ctx, baseFilter(), "output_tokens",
)
assert.Equal(t, "output_tokens", resp.Metric, "Metric")
assert.Empty(t, resp.Entries,
"len(Entries) want 0 (no sessions report token coverage)")
})
t.Run("EmptyRange", func(t *testing.T) {
f := emptyFilter()
f.To = "2020-01-03"
resp := mustHeatmap(t, d, ctx, f, "messages")
require.Len(t, resp.Entries, 3, "len(Entries) =")
for _, e := range resp.Entries {
assert.Equal(t, 0, e.Value, "date %s value", e.Date)
assert.Equal(t, 0, e.Level, "date %s level", e.Date)
}
})
}
func TestGetAnalyticsSummaryModelFilterUsesFilteredOutputTokens(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "summary-output-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 111
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "summary-output-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o", OutputTokens: 11, HasOutputTokens: true,
},
Message{
SessionID: "summary-output-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T10:05:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 100, HasOutputTokens: true,
},
)
insertSession(t, d, "summary-output-uncovered", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T10:40:00Z")
s.EndedAt = new("2024-06-01T11:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 90
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "summary-output-uncovered", Ordinal: 0,
Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T10:40:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "summary-output-uncovered", Ordinal: 1,
Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T10:45:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 90, HasOutputTokens: true,
},
)
hour := 10
resp := mustSummary(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
})
assert.Equal(t, 2, resp.TotalSessions, "TotalSessions")
assert.Equal(t, 2, resp.TotalMessages, "TotalMessages")
assert.Equal(t, []string{"gpt-4o"}, resp.Models, "Models")
assert.Equal(t, 11, resp.TotalOutputTokens, "TotalOutputTokens")
assert.Equal(t, 1, resp.TokenReportingSessions, "TokenReportingSessions")
}
func TestGetAnalyticsHeatmapModelFilterUsesFilteredOutputTokens(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "heatmap-output-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 111
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "heatmap-output-mixed", Ordinal: 0,
Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o", OutputTokens: 11, HasOutputTokens: true,
},
Message{
SessionID: "heatmap-output-mixed", Ordinal: 1,
Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T10:05:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 100, HasOutputTokens: true,
},
)
insertSession(t, d, "heatmap-output-uncovered", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T10:40:00Z")
s.EndedAt = new("2024-06-01T11:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 90
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "heatmap-output-uncovered", Ordinal: 0,
Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T10:40:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "heatmap-output-uncovered", Ordinal: 1,
Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T10:45:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 90, HasOutputTokens: true,
},
)
hour := 10
resp := mustHeatmap(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
}, "output_tokens")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 11, resp.Entries[0].Value, "Value")
}
func TestGetAnalyticsProjects(t *testing.T) {
d := testDB(t)
ctx := context.Background()
stats := seedAnalyticsData(t, d)
t.Run("FullRange", func(t *testing.T) {
resp := mustProjects(t, d, ctx, baseFilter())
require.Len(t, resp.Projects, stats.ActiveProjects, "len(Projects)")
totalMessages := 0
for _, p := range resp.Projects {
totalMessages += p.Messages
}
assert.Equal(t, stats.TotalMessages, totalMessages, "total messages across projects")
// Sorted by message count desc: beta (45) > alpha (35)
assert.Equal(t, "project-beta", resp.Projects[0].Name, "first project")
assert.Equal(t, 45, resp.Projects[0].Messages, "beta messages")
assert.Equal(t, "project-alpha", resp.Projects[1].Name, "second project")
assert.Equal(t, 3, resp.Projects[1].Sessions, "alpha sessions")
})
t.Run("AgentBreakdown", func(t *testing.T) {
resp := mustProjects(t, d, ctx, baseFilter())
alpha := resp.Projects[1]
assert.Equal(t, 2, alpha.Agents["claude"], "alpha claude")
assert.Equal(t, 1, alpha.Agents["codex"], "alpha codex")
})
t.Run("MedianMessages", func(t *testing.T) {
resp := mustProjects(t, d, ctx, baseFilter())
// Alpha counts sorted: [5, 10, 20], median = 10
alpha := resp.Projects[1]
assert.Equal(t, 10, alpha.MedianMessages, "alpha median")
})
t.Run("EmptyRange", func(t *testing.T) {
resp := mustProjects(t, d, ctx, emptyFilter())
assert.Equal(t, 0, len(resp.Projects), "len(Projects)")
})
}
func TestGetAnalyticsProjectsModelFilterCountsOnlyMatchingMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "projects-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "projects-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "projects-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:06:00Z",
Model: "claude-3-5-sonnet",
},
)
resp, err := d.GetAnalyticsProjects(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsProjects")
require.Len(t, resp.Projects, 1, "len(Projects)")
assert.Equal(t, 1, resp.Projects[0].Messages, "Messages")
assert.Equal(t, 1.0, resp.Projects[0].AvgMessages, "AvgMessages")
assert.Equal(t, 1, resp.Projects[0].MedianMessages, "MedianMessages")
assert.Equal(t, 1.0, resp.Projects[0].DailyTrend, "DailyTrend")
}
func TestGetAnalyticsHeatmapModelFilterCountsOnlyMatchingMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "heatmap-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "heatmap-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:05:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "heatmap-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:06:00Z",
Model: "claude-3-5-sonnet",
},
)
resp := mustHeatmap(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "messages")
require.Len(t, resp.Entries, 1, "len(Entries)")
assert.Equal(t, 1, resp.Entries[0].Value, "Value")
}
func TestMedianInt(t *testing.T) {
tests := []struct {
name string
sorted []int
want int
}{
{"Empty", []int{}, 0},
{"Single", []int{5}, 5},
{"OddCount", []int{1, 3, 7}, 3},
{"EvenCount", []int{1, 3, 7, 9}, 5},
{"EvenCountTwo", []int{10, 20}, 15},
{"EvenCountFour", []int{2, 4, 6, 8}, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := medianInt(tt.sorted, len(tt.sorted))
assert.Equal(t, tt.want, got,
"medianInt(%v)", tt.sorted)
})
}
}
func TestLocalDate(t *testing.T) {
utc := time.UTC
tests := []struct {
name string
ts string
want string
}{
{"RFC3339", "2024-06-01T15:00:00Z", "2024-06-01"},
{"RFC3339Nano", "2024-06-01T15:00:00.123Z", "2024-06-01"},
{"NoFraction", "2024-06-01T15:00:00Z", "2024-06-01"},
{"Fallback10Char", "2024-06-01", "2024-06-01"},
{"Short", "2024", ""},
{"Empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := localDate(tt.ts, utc)
assert.Equal(t, tt.want, got,
"localDate(%q)", tt.ts)
})
}
}
func TestMostActiveTieBreak(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Two projects with equal message counts
insertSession(t, d, "t1", "zebra", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 20
s.Agent = "claude"
})
insertSession(t, d, "t2", "alpha", func(s *Session) {
s.StartedAt = new(tsMidYear)
s.MessageCount = 20
s.Agent = "claude"
})
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
// Alphabetically, "alpha" < "zebra"
assert.Equal(t, "alpha", s.MostActive,
"MostActive want alphabetically first (alpha)")
}
func TestEvenCountMedianInSummary(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// 4 sessions: message counts [5, 10, 20, 30]
for i, mc := range []int{10, 30, 5, 20} {
id := fmt.Sprintf("e%d", i)
insertSession(t, d, id, "proj", func(s *Session) {
ts := fmt.Sprintf("2024-06-01T%02d:00:00Z", i+9)
s.StartedAt = &ts
s.MessageCount = mc
s.Agent = "claude"
})
}
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
// Sorted: [5, 10, 20, 30] → median = (10+20)/2 = 15
assert.Equal(t, 15, s.MedianMessages, "MedianMessages")
}
func TestAnalyticsTimezone(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Session at 2024-06-01T23:00:00Z = 2024-06-02 in UTC+5
insertSession(t, d, "tz1", "tz-project", func(s *Session) {
s.StartedAt = new("2024-06-01T23:00:00Z")
s.MessageCount = 10
s.Agent = "claude"
})
insertMessages(t, d, userMsg("tz1", 0, "late night"))
t.Run("UTCBucket", func(t *testing.T) {
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-02",
Timezone: "UTC",
}
resp := mustHeatmap(t, d, ctx, f, "messages")
// In UTC, this is Jun 1
assert.Equal(t, 10, resp.Entries[0].Value, "Jun1 UTC value")
assert.Equal(t, 0, resp.Entries[1].Value, "Jun2 UTC value")
})
t.Run("PlusFiveBucket", func(t *testing.T) {
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-02",
Timezone: "Asia/Karachi", // UTC+5
}
resp := mustHeatmap(t, d, ctx, f, "messages")
// In UTC+5, 23:00Z = 04:00 Jun 2
assert.Equal(t, 0, resp.Entries[0].Value, "Jun1 PKT value")
assert.Equal(t, 10, resp.Entries[1].Value, "Jun2 PKT value")
})
}
func TestAnalyticsCanceledContext(t *testing.T) {
d := testDB(t)
ctx := canceledCtx()
f := baseFilter()
tests := []struct {
name string
fn func() error
}{
{"Summary", func() error {
_, err := d.GetAnalyticsSummary(ctx, f)
return err
}},
{"Activity", func() error {
_, err := d.GetAnalyticsActivity(ctx, f, "day")
return err
}},
{"Heatmap", func() error {
_, err := d.GetAnalyticsHeatmap(ctx, f, "messages")
return err
}},
{"Projects", func() error {
_, err := d.GetAnalyticsProjects(ctx, f)
return err
}},
{"HourOfWeek", func() error {
_, err := d.GetAnalyticsHourOfWeek(ctx, f)
return err
}},
{"SessionShape", func() error {
_, err := d.GetAnalyticsSessionShape(ctx, f)
return err
}},
{"Velocity", func() error {
_, err := d.GetAnalyticsVelocity(ctx, f)
return err
}},
{"Tools", func() error {
_, err := d.GetAnalyticsTools(ctx, f)
return err
}},
{"TopSessions", func() error {
_, err := d.GetAnalyticsTopSessions(
ctx, f, "messages",
)
return err
}},
{"Signals", func() error {
_, err := d.GetAnalyticsSignals(ctx, f)
return err
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requireCanceledErr(t, tt.fn())
})
}
}
func TestConcentrationTopThree(t *testing.T) {
ctx := context.Background()
t.Run("OneProject", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "c1", "solo", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 50
s.Agent = "claude"
})
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 1.0, s.Concentration, "Concentration")
})
t.Run("TwoProjects", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "c1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 35
s.Agent = "claude"
})
insertSession(t, d, "c2", "beta", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 45
s.Agent = "claude"
})
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
// Both in top 3 → concentration = 1.0
assert.Equal(t, 1.0, s.Concentration, "Concentration")
})
t.Run("FourProjects", func(t *testing.T) {
d := testDB(t)
for i, tc := range []struct {
proj string
msgs int
}{
{"p1", 40}, {"p2", 30}, {"p3", 20}, {"p4", 10},
} {
id := fmt.Sprintf("c%d", i)
insertSession(t, d, id, tc.proj, func(s *Session) {
ts := fmt.Sprintf(
"2024-06-01T%02d:00:00Z", i+9,
)
s.StartedAt = &ts
s.MessageCount = tc.msgs
s.Agent = "claude"
})
}
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
}
s := mustSummary(t, d, ctx, f)
// Top 3: 40+30+20 = 90, total = 100
assert.Equal(t, 0.9, s.Concentration, "Concentration")
})
}
func TestGetAnalyticsHourOfWeek(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsHourOfWeek(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Len(t, resp.Cells, 168, "len(Cells)")
for _, c := range resp.Cells {
assert.Equal(t, 0, c.Messages,
"day=%d hour=%d messages",
c.DayOfWeek, c.Hour)
}
})
// Seed sessions with known UTC times:
// 2024-06-01 is Saturday, 09:00 UTC
insertSession(t, d, "hw1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "hw1", Ordinal: 0, Role: "user",
Content: "hi", ContentLength: 2,
Timestamp: "2024-06-01T09:00:00Z",
},
Message{
SessionID: "hw1", Ordinal: 1, Role: "assistant",
Content: "hello", ContentLength: 5,
Timestamp: "2024-06-01T09:30:00Z",
},
)
// 23:00 UTC on a Saturday
insertSession(t, d, "hw2", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T23:00:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d, Message{
SessionID: "hw2", Ordinal: 0, Role: "user",
Content: "late", ContentLength: 4,
Timestamp: "2024-06-01T23:00:00Z",
})
t.Run("UTCBucketing", func(t *testing.T) {
resp, err := d.GetAnalyticsHourOfWeek(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsHourOfWeek")
// Saturday = ISO day 5 (Mon=0)
// hour 9: 2 messages (user@09:00 + assistant@09:30)
satH9 := findHOWCell(resp.Cells, 5, 9)
assert.Equal(t, 2, satH9, "Sat 09:xx")
satH23 := findHOWCell(resp.Cells, 5, 23)
assert.Equal(t, 1, satH23, "Sat 23:00")
})
t.Run("TimezoneShift", func(t *testing.T) {
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-03",
Timezone: "Asia/Karachi", // UTC+5
}
resp, err := d.GetAnalyticsHourOfWeek(ctx, f)
require.NoError(t, err, "GetAnalyticsHourOfWeek")
// 23:00 UTC Sat → 04:00 Sun in UTC+5
// Sunday = ISO day 6
sunH4 := findHOWCell(resp.Cells, 6, 4)
assert.Equal(t, 1, sunH4, "Sun 04:00 PKT")
// 09:00 UTC Sat → 14:00 Sat in UTC+5
// 09:30 UTC Sat → 14:30 Sat in UTC+5
// Both fall in hour 14
satH14 := findHOWCell(resp.Cells, 5, 14)
assert.Equal(t, 2, satH14, "Sat 14:xx PKT")
})
}
func TestGetAnalyticsHourOfWeekModelFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "how-a", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 1
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "how-a", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "how-b", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 1
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "how-b", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
)
resp, err := d.GetAnalyticsHourOfWeek(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Equal(t, 0, findHOWCell(resp.Cells, 5, 9), "Sat 09:00")
assert.Equal(t, 1, findHOWCell(resp.Cells, 5, 10), "Sat 10:00")
}
func TestGetAnalyticsHourOfWeekModelFilterCountsOnlyMatchingMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "how-mixed", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "how-mixed", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "how-mixed", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "claude-3-5-sonnet",
},
)
resp, err := d.GetAnalyticsHourOfWeek(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Equal(t, 1, findHOWCell(resp.Cells, 5, 9), "Sat 09:00")
assert.Equal(t, 0, findHOWCell(resp.Cells, 5, 10), "Sat 10:00")
}
func TestGetAnalyticsHourOfWeekModelFilterIncludesPairedUserTurns(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "how-paired", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
})
insertMessages(t, d,
Message{
SessionID: "how-paired", Ordinal: 0, Role: "user",
Content: "q", ContentLength: 1,
Timestamp: "2024-06-01T09:00:00Z",
// Empty model: this user turn is paired with the selected-model
// assistant below, so the heatmap must count it like the summary,
// activity, velocity, and trends panels do.
},
Message{
SessionID: "how-paired", Ordinal: 1, Role: "assistant",
Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:00Z",
Model: "gpt-4o",
},
)
resp, err := d.GetAnalyticsHourOfWeek(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsHourOfWeek")
assert.Equal(t, 1, findHOWCell(resp.Cells, 5, 9),
"paired empty-model user turn at Sat 09:00")
assert.Equal(t, 1, findHOWCell(resp.Cells, 5, 10),
"selected-model assistant at Sat 10:00")
}
func findHOWCell(cells []HourOfWeekCell, dow, hour int) int {
for _, c := range cells {
if c.DayOfWeek == dow && c.Hour == hour {
return c.Messages
}
}
return -1
}
func TestGetAnalyticsSessionShape(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsSessionShape(
ctx, baseFilter(),
)
require.NoError(t, err, "GetAnalyticsSessionShape")
assert.Equal(t, 0, resp.Count, "Count")
})
// Session with 10 messages, 1h duration
insertSession(t, d, "ss1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 10
s.Agent = "claude"
})
// 5 user + 5 assistant, assistant has tool_use
for i := range 10 {
role := "user"
hasTool := false
if i%2 == 1 {
role = "assistant"
hasTool = true
}
insertMessages(t, d, Message{
SessionID: "ss1", Ordinal: i, Role: role,
Content: fmt.Sprintf("msg %d", i),
ContentLength: 10, HasToolUse: hasTool,
Timestamp: "2024-06-01T09:00:00Z",
})
}
// Session with 25 messages, no ended_at
insertSession(t, d, "ss2", "proj", func(s *Session) {
s.StartedAt = new("2024-06-02T10:00:00Z")
s.MessageCount = 25
s.Agent = "claude"
})
for i := range 25 {
role := "user"
if i%2 == 1 {
role = "assistant"
}
insertMessages(t, d, Message{
SessionID: "ss2", Ordinal: i, Role: role,
Content: fmt.Sprintf("msg %d", i),
ContentLength: 10,
Timestamp: "2024-06-02T10:00:00Z",
})
}
t.Run("FullRange", func(t *testing.T) {
resp, err := d.GetAnalyticsSessionShape(
ctx, baseFilter(),
)
require.NoError(t, err, "GetAnalyticsSessionShape")
assert.Equal(t, 2, resp.Count, "Count")
// Length: 10 → "6-15", 25 → "16-30"
lenMap := bucketMap(resp.LengthDistribution)
assert.Equal(t, 1, lenMap["6-15"], "6-15")
assert.Equal(t, 1, lenMap["16-30"], "16-30")
// Duration: only ss1 has both start/end (60m → "1-2h")
durMap := bucketMap(resp.DurationDistribution)
assert.Equal(t, 1, durMap["1-2h"], "1-2h")
totalDur := 0
for _, b := range resp.DurationDistribution {
totalDur += b.Count
}
assert.Equal(t, 1, totalDur, "total duration entries")
})
t.Run("Autonomy", func(t *testing.T) {
resp, err := d.GetAnalyticsSessionShape(
ctx, baseFilter(),
)
require.NoError(t, err, "GetAnalyticsSessionShape")
// ss1: 5 user, 5 assistant w/ tool → ratio 5/5=1.0 → "1-2"
// ss2: 13 user, 0 tool → ratio 0/13=0 → "<0.5"
autoMap := bucketMap(resp.AutonomyDistribution)
assert.Equal(t, 1, autoMap["1-2"], "1-2")
assert.Equal(t, 1, autoMap["<0.5"], "<0.5")
})
t.Run("EmptyRange", func(t *testing.T) {
resp, err := d.GetAnalyticsSessionShape(
ctx, emptyFilter(),
)
require.NoError(t, err, "GetAnalyticsSessionShape")
assert.Equal(t, 0, resp.Count, "Count")
})
}
func TestGetAnalyticsSessionShapeModelFilterUsesMatchingRowsOnly(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "shape-model-filter", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:30:00Z")
s.MessageCount = 6
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "shape-model-filter", Ordinal: 0, Role: "user",
Content: "gpt q", ContentLength: 5,
Timestamp: "2024-06-01T09:00:00Z",
},
Message{
SessionID: "shape-model-filter", Ordinal: 1, Role: "assistant",
Content: "gpt tool", ContentLength: 8,
Timestamp: "2024-06-01T09:01:00Z",
Model: "gpt-4o",
HasToolUse: true,
ToolCalls: []ToolCall{
{SessionID: "shape-model-filter", ToolName: "Read", Category: "Read"},
},
},
Message{
SessionID: "shape-model-filter", Ordinal: 2, Role: "user",
Content: "claude q1", ContentLength: 9,
Timestamp: "2024-06-01T09:02:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "shape-model-filter", Ordinal: 3, Role: "user",
Content: "claude q2", ContentLength: 9,
Timestamp: "2024-06-01T09:03:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "shape-model-filter", Ordinal: 4, Role: "user",
Content: "claude q3", ContentLength: 9,
Timestamp: "2024-06-01T09:04:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "shape-model-filter", Ordinal: 5, Role: "assistant",
Content: "claude reply", ContentLength: 12,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
},
)
resp, err := d.GetAnalyticsSessionShape(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsSessionShape")
assert.Equal(t, 1, resp.Count, "Count")
lenMap := bucketMap(resp.LengthDistribution)
assert.Equal(t, 1, lenMap["1-5"], "filtered 2-message session stays in 1-5")
assert.Equal(t, 0, lenMap["6-15"], "full-session count must not leak")
autoMap := bucketMap(resp.AutonomyDistribution)
assert.Equal(t, 1, autoMap["1-2"], "filtered autonomy bucket")
assert.Equal(t, 0, autoMap["<0.5"], "off-model user turns must not leak")
}
func bucketMap(
buckets []DistributionBucket,
) map[string]int {
m := make(map[string]int)
for _, b := range buckets {
m[b.Label] = b.Count
}
return m
}
func assertEq[T comparable](t *testing.T, name string, got, want T) {
t.Helper()
assert.Equal(t, want, got, name)
}
type testClock struct {
curr time.Time
}
func newTestClock(start string) *testClock {
t, _ := time.Parse(time.RFC3339, start)
return &testClock{curr: t}
}
func (c *testClock) Now() string {
return c.curr.Format(time.RFC3339)
}
func (c *testClock) Next(d time.Duration) string {
c.curr = c.curr.Add(d)
return c.Now()
}
func insertConversation(t *testing.T, d *DB, id, proj, agent, start string, delays []time.Duration) {
t.Helper()
clock := newTestClock(start)
insertSession(t, d, id, proj, func(s *Session) {
s.StartedAt = new(start)
s.MessageCount = len(delays)
s.Agent = agent
if len(delays) > 0 {
endClock := newTestClock(start)
for _, delay := range delays {
endClock.Next(delay)
}
s.EndedAt = new(endClock.Now())
}
})
var msgs []Message
for i, delay := range delays {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs = append(msgs, Message{
SessionID: id,
Ordinal: i,
Role: role,
Content: fmt.Sprintf("msg %d", i),
ContentLength: 5,
Timestamp: clock.Next(delay),
})
}
if len(msgs) > 0 {
insertMessages(t, d, msgs...)
}
}
func TestGetAnalyticsVelocity_Metrics(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "len(ByAgent)", len(resp.ByAgent), 0)
})
// Session with messages at precise timestamps (10s apart)
insertConversation(t, d, "v1", "proj", "claude", "2024-06-01T09:00:00Z", []time.Duration{
0, 10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second, 10 * time.Second,
})
t.Run("TurnCycle", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "TurnCycle P50", resp.Overall.TurnCycleSec.P50, 10.0)
})
t.Run("FirstResponse", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "FirstResponse P50", resp.Overall.FirstResponseSec.P50, 10.0)
})
t.Run("Throughput", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
// Active time: 5 gaps of 10s = 50s ≈ 0.833 min
// 6 msgs / 0.833 = ~7.2 msgs/min
assert.InDelta(t, 7.2, resp.Overall.MsgsPerActiveMin, 0.3,
"MsgsPerActiveMin want ~7.2")
})
t.Run("ByAgent", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "len(ByAgent)", len(resp.ByAgent), 1)
assertEq(t, "ByAgent[0].Label", resp.ByAgent[0].Label, "claude")
assertEq(t, "ByAgent[0].Sessions", resp.ByAgent[0].Sessions, 1)
})
t.Run("ByComplexity", func(t *testing.T) {
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "len(ByComplexity)", len(resp.ByComplexity), 1)
assertEq(t, "ByComplexity[0].Label", resp.ByComplexity[0].Label, "1-15")
})
}
func TestGetAnalyticsVelocity_EdgeCases(t *testing.T) {
ctx := context.Background()
t.Run("LargeCycleExcluded", func(t *testing.T) {
d := testDB(t)
insertConversation(t, d, "v2", "proj", "claude", "2024-06-01T09:00:00Z", []time.Duration{
0, 45 * time.Minute,
})
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "TurnCycle P50", resp.Overall.TurnCycleSec.P50, 0.0)
})
t.Run("EmptyTimestampsSkipped", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "v3", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "v3", Ordinal: 0, Role: "user", Content: "q", ContentLength: 1, Timestamp: ""},
Message{SessionID: "v3", Ordinal: 1, Role: "assistant", Content: "a", ContentLength: 1, Timestamp: ""},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "TurnCycle P50", resp.Overall.TurnCycleSec.P50, 0.0)
})
t.Run("AssistantBeforeUser", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "v4", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "v4", Ordinal: 0, Role: "assistant", Content: "system greeting", ContentLength: 15, Timestamp: "2024-06-01T09:00:00Z"},
Message{SessionID: "v4", Ordinal: 1, Role: "user", Content: "hi", ContentLength: 2, Timestamp: "2024-06-01T09:00:10Z"},
Message{SessionID: "v4", Ordinal: 2, Role: "assistant", Content: "hello", ContentLength: 5, Timestamp: "2024-06-01T09:00:20Z"},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "FirstResponse P50", resp.Overall.FirstResponseSec.P50, 10.0)
})
t.Run("OrdinalVsTimestampSkew", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "v5", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "v5", Ordinal: 0, Role: "user", Content: "setup", ContentLength: 5, Timestamp: "2024-06-01T09:00:00Z"},
Message{SessionID: "v5", Ordinal: 1, Role: "user", Content: "real question", ContentLength: 13, Timestamp: "2024-06-01T09:00:30Z"},
Message{SessionID: "v5", Ordinal: 2, Role: "assistant", Content: "answer", ContentLength: 6, Timestamp: "2024-06-01T09:00:20Z"},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "FirstResponse P50", resp.Overall.FirstResponseSec.P50, 20.0)
})
t.Run("NegativeDeltaClampsToZero", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "v6", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "v6", Ordinal: 0, Role: "user", Content: "hello", ContentLength: 5, Timestamp: "2024-06-01T09:00:30Z"},
Message{SessionID: "v6", Ordinal: 1, Role: "assistant", Content: "hi", ContentLength: 2, Timestamp: "2024-06-01T09:00:10Z"},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "FirstResponse P50", resp.Overall.FirstResponseSec.P50, 0.0)
})
}
func TestGetAnalyticsVelocity_ToolUsage(t *testing.T) {
ctx := context.Background()
t.Run("ToolCallsPerActiveMin", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "vt1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 4
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "vt1", Ordinal: 0, Role: "user", Content: "hi", ContentLength: 2, Timestamp: "2024-06-01T09:00:00Z"},
Message{SessionID: "vt1", Ordinal: 1, Role: "assistant", Content: "hello", ContentLength: 5, Timestamp: "2024-06-01T09:00:10Z"},
Message{SessionID: "vt1", Ordinal: 2, Role: "user", Content: "do X", ContentLength: 4, Timestamp: "2024-06-01T09:00:20Z"},
Message{
SessionID: "vt1", Ordinal: 3, Role: "assistant", Content: "done", ContentLength: 4,
Timestamp: "2024-06-01T09:00:30Z", HasToolUse: true,
ToolCalls: []ToolCall{
{SessionID: "vt1", ToolName: "Read", Category: "Read"},
{SessionID: "vt1", ToolName: "Bash", Category: "Bash"},
{SessionID: "vt1", ToolName: "Edit", Category: "Edit"},
},
},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "ToolCallsPerActiveMin", resp.Overall.ToolCallsPerActiveMin, 6.0)
})
t.Run("ToolCallsByAgentBreakdown", func(t *testing.T) {
d := testDB(t)
insertSession(t, d, "vta1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{SessionID: "vta1", Ordinal: 0, Role: "user", Content: "q", ContentLength: 1, Timestamp: "2024-06-01T09:00:00Z"},
Message{
SessionID: "vta1", Ordinal: 1, Role: "assistant", Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T09:00:30Z", HasToolUse: true,
ToolCalls: []ToolCall{{SessionID: "vta1", ToolName: "Read", Category: "Read"}},
},
)
insertSession(t, d, "vta2", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "codex"
})
insertMessages(t, d,
Message{SessionID: "vta2", Ordinal: 0, Role: "user", Content: "q", ContentLength: 1, Timestamp: "2024-06-01T10:00:00Z"},
Message{
SessionID: "vta2", Ordinal: 1, Role: "assistant", Content: "a", ContentLength: 1,
Timestamp: "2024-06-01T10:00:30Z", HasToolUse: true,
ToolCalls: []ToolCall{
{SessionID: "vta2", ToolName: "Bash", Category: "Bash"},
{SessionID: "vta2", ToolName: "Edit", Category: "Edit"},
},
},
)
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
require.GreaterOrEqual(t, len(resp.ByAgent), 2,
"ByAgent entries want >= 2")
agentMap := make(map[string]VelocityBreakdown)
for _, b := range resp.ByAgent {
agentMap[b.Label] = b
}
assertEq(t, "claude ToolCallsPerActiveMin", agentMap["claude"].Overview.ToolCallsPerActiveMin, 2.0)
assertEq(t, "codex ToolCallsPerActiveMin", agentMap["codex"].Overview.ToolCallsPerActiveMin, 4.0)
})
t.Run("ToolCallsPerActiveMinZero", func(t *testing.T) {
d := testDB(t)
insertConversation(t, d, "vt2", "proj", "claude", "2024-06-01T09:00:00Z", []time.Duration{
0, 10 * time.Second,
})
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity")
assertEq(t, "ToolCallsPerActiveMin", resp.Overall.ToolCallsPerActiveMin, 0.0)
})
}
func TestGetAnalyticsVelocity_ModelFilterUsesMatchingRowsOnly(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "velocity-model", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:11:00Z")
s.MessageCount = 4
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "velocity-model", Ordinal: 0, Role: "user",
Content: "claude q", ContentLength: 8,
Timestamp: "2024-06-01T09:00:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "velocity-model", Ordinal: 1, Role: "assistant",
Content: "offscope-offscope-xx", ContentLength: 20,
Timestamp: "2024-06-01T09:00:10Z",
Model: "claude-3-5-sonnet",
HasToolUse: true,
ToolCalls: []ToolCall{
{SessionID: "velocity-model", ToolName: "Read", Category: "Read"},
{SessionID: "velocity-model", ToolName: "Bash", Category: "Bash"},
{SessionID: "velocity-model", ToolName: "Grep", Category: "Grep"},
},
},
Message{
SessionID: "velocity-model", Ordinal: 2, Role: "user",
Content: "gpt q", ContentLength: 5,
Timestamp: "2024-06-01T09:10:00Z",
},
Message{
SessionID: "velocity-model", Ordinal: 3, Role: "assistant",
Content: "reply", ContentLength: 5,
Timestamp: "2024-06-01T09:11:00Z",
Model: "gpt-4o",
HasToolUse: true,
ToolCalls: []ToolCall{
{SessionID: "velocity-model", ToolName: "Edit", Category: "Edit"},
{SessionID: "velocity-model", ToolName: "Write", Category: "Write"},
},
},
)
resp, err := d.GetAnalyticsVelocity(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsVelocity")
assert.Equal(t, 60.0, resp.Overall.FirstResponseSec.P50,
"FirstResponse P50")
assert.Equal(t, 2.0, resp.Overall.MsgsPerActiveMin,
"MsgsPerActiveMin")
assert.Equal(t, 5.0, resp.Overall.CharsPerActiveMin,
"CharsPerActiveMin")
assert.Equal(t, 2.0, resp.Overall.ToolCallsPerActiveMin,
"ToolCallsPerActiveMin")
}
func TestGetAnalyticsVelocityModelFilterUsesMatchingComplexityBucket(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "velocity-model-complexity", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:15:00Z")
s.MessageCount = 16
s.Agent = "mixed"
})
msgs := []Message{
{
SessionID: "velocity-model-complexity", Ordinal: 0, Role: "user",
Content: "gpt q", ContentLength: 5,
Timestamp: "2024-06-01T09:00:00Z",
},
{
SessionID: "velocity-model-complexity", Ordinal: 1, Role: "assistant",
Content: "reply", ContentLength: 5,
Timestamp: "2024-06-01T09:01:00Z",
Model: "gpt-4o",
},
}
for i := 2; i < 16; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs = append(msgs, Message{
SessionID: "velocity-model-complexity",
Ordinal: i,
Role: role,
Content: "claude",
ContentLength: 6,
Timestamp: fmt.Sprintf("2024-06-01T09:%02d:00Z", i),
Model: "claude-3-5-sonnet",
})
}
insertMessages(t, d, msgs...)
resp, err := d.GetAnalyticsVelocity(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsVelocity")
require.Len(t, resp.ByComplexity, 1, "len(ByComplexity)")
assert.Equal(t, "1-15", resp.ByComplexity[0].Label,
"complexity bucket should use filtered message count")
assert.Equal(t, 1, resp.ByComplexity[0].Sessions, "Sessions")
}
func TestVelocityChunkedQuery(t *testing.T) {
d := openChunkedAnalyticsFixtureDB(t)
ctx := context.Background()
// Velocity must not fail even with >500 sessions
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err,
"GetAnalyticsVelocity with %d sessions",
chunkedAnalyticsFixtureSessionCount)
assert.Equal(t, chunkedAnalyticsFixtureSessionCount,
resp.ByComplexity[0].Sessions, "sessions")
// SessionShape must not fail either
shape, err := d.GetAnalyticsSessionShape(ctx, baseFilter())
require.NoError(t, err,
"GetAnalyticsSessionShape with %d sessions",
chunkedAnalyticsFixtureSessionCount)
assert.Equal(t, chunkedAnalyticsFixtureSessionCount, shape.Count, "Count")
}
// TestGetAnalyticsVelocity_NullTimestamp guards against the velocity scan
// crashing on a NULL messages.timestamp. NULLs only occur on imported or
// migrated archives (fresh inserts always bind a Go string), so reach past
// InsertMessages with raw SQL to plant one. Before the COALESCE fix this
// failed with "converting NULL to string is unsupported"; now the NULL row
// is treated as an invalid timestamp and excluded while the rest of the
// session's messages still drive velocity metrics.
func TestGetAnalyticsVelocity_NullTimestamp(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertConversation(t, d, "v-null", "proj", "claude", "2024-06-01T09:00:00Z",
[]time.Duration{
0, 10 * time.Second, 10 * time.Second,
10 * time.Second, 10 * time.Second, 10 * time.Second,
})
require.NoError(t, d.Update(func(tx *sql.Tx) error {
_, err := tx.Exec(
"UPDATE messages SET timestamp = NULL"+
" WHERE session_id = ? AND ordinal = ?", "v-null", 5)
return err
}), "null the stored timestamp")
resp, err := d.GetAnalyticsVelocity(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsVelocity over NULL timestamp")
// The session is still processed and its remaining timestamped
// messages still produce velocity metrics; the NULL row is simply
// excluded.
require.Len(t, resp.ByAgent, 1)
assert.Equal(t, "claude", resp.ByAgent[0].Label)
assert.Equal(t, 1, resp.ByAgent[0].Sessions, "session counted")
assert.Equal(t, 10.0, resp.Overall.TurnCycleSec.P50,
"valid-timestamped turns still drive velocity")
}
func TestPercentileFloat(t *testing.T) {
tests := []struct {
name string
sorted []float64
pct float64
want float64
}{
{"Empty", []float64{}, 0.5, 0},
{"Single", []float64{5.0}, 0.5, 5.0},
{"P50Odd", []float64{1, 3, 7}, 0.5, 3.0},
{"P90", []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.9, 10.0},
{"P50Even", []float64{1, 2, 3, 4}, 0.5, 3.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := percentileFloat(tt.sorted, tt.pct)
assert.Equal(t, tt.want, got,
"percentileFloat(%v, %f)",
tt.sorted, tt.pct)
})
}
}
func TestGetAnalyticsTools(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
assert.Equal(t, 0, resp.TotalCalls, "TotalCalls")
assert.Len(t, resp.ByCategory, 0, "len(ByCategory)")
assert.Len(t, resp.ByTool, 0, "len(ByTool)")
})
// Seed sessions with tool_calls.
insertSession(t, d, "t1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
})
m1 := asstMsgAt("t1", 0, "[Read: a.go]",
"2024-06-01T09:00:00Z")
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{SessionID: "t1", ToolName: "Read", Category: "Read"},
{SessionID: "t1", ToolName: "Read", Category: "Read"},
}
m2 := asstMsgAt("t1", 1, "[Bash: ls]",
"2024-06-01T09:05:00Z")
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{SessionID: "t1", ToolName: "Bash", Category: "Bash"},
}
m3 := asstMsgAt("t1", 2, "[Edit: b.go]",
"2024-06-01T09:10:00Z")
m3.HasToolUse = true
m3.ToolCalls = []ToolCall{
{SessionID: "t1", ToolName: "Edit", Category: "Edit"},
}
insertMessages(t, d, m1, m2, m3)
insertSession(t, d, "t2", "beta", func(s *Session) {
s.StartedAt = new("2024-06-02T10:00:00Z")
s.MessageCount = 1
s.Agent = "codex"
})
m4 := asstMsgAt("t2", 0, "[Read: c.go]",
"2024-06-02T10:00:00Z")
m4.HasToolUse = true
m4.ToolCalls = []ToolCall{
{SessionID: "t2", ToolName: "Read", Category: "Read"},
{SessionID: "t2", ToolName: "Grep", Category: "Grep"},
}
insertMessages(t, d, m4)
t.Run("TotalCalls", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
// 2 Read + 1 Bash + 1 Edit + 1 Read + 1 Grep = 6
assert.Equal(t, 6, resp.TotalCalls, "TotalCalls")
})
t.Run("ByCategory", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
catMap := make(map[string]int)
for _, c := range resp.ByCategory {
catMap[c.Category] = c.Count
}
assert.Equal(t, 3, catMap["Read"], "Read")
assert.Equal(t, 1, catMap["Bash"], "Bash")
assert.Equal(t, 1, catMap["Edit"], "Edit")
assert.Equal(t, 1, catMap["Grep"], "Grep")
// Sorted by count desc: Read first
assert.Equal(t, "Read", resp.ByCategory[0].Category, "first category")
})
t.Run("ByCategoryPct", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
// Read: 3/6 = 50%
assert.Equal(t, 50.0, resp.ByCategory[0].Pct, "Read pct")
})
t.Run("ByToolAnalysis", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
require.Len(t, resp.ByTool, 4, "len(ByTool)")
read := resp.ByTool[0]
assert.Equal(t, "Read", read.ToolName, "tool name")
assert.Equal(t, "Read", read.Category, "category")
assert.Equal(t, 3, read.CallCount, "call count")
assert.Equal(t, 2, read.SessionCount, "session count")
assert.Equal(t, 50.0, read.Pct, "pct")
assert.Equal(t, "Bash", resp.ByTool[1].ToolName, "tie sort")
assert.Equal(t, 1, resp.ByTool[1].SessionCount, "Bash sessions")
assert.Equal(t, 16.7, resp.ByTool[1].Pct, "Bash pct")
})
t.Run("ByAgent", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
require.Len(t, resp.ByAgent, 2, "len(ByAgent)")
// Alphabetical: claude, codex
assert.Equal(t, "claude", resp.ByAgent[0].Agent, "first agent")
assert.Equal(t, 4, resp.ByAgent[0].Total, "claude total")
assert.Equal(t, "codex", resp.ByAgent[1].Agent, "second agent")
assert.Equal(t, 2, resp.ByAgent[1].Total, "codex total")
})
t.Run("Trend", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsTools")
// 2024-06-01 is Saturday, 2024-06-02 is Sunday.
// Both in same ISO week (May 27 week start).
// But 2024-06-03 is Monday, different week.
// So trend should have 1 entry (week of May 27).
require.Len(t, resp.Trend, 1, "len(Trend)")
total := 0
for _, v := range resp.Trend[0].ByCat {
total += v
}
assert.Equal(t, 6, total, "week total")
})
t.Run("ProjectFilter", func(t *testing.T) {
f := baseFilter()
f.Project = "alpha"
resp, err := d.GetAnalyticsTools(ctx, f)
require.NoError(t, err, "GetAnalyticsTools")
assert.Equal(t, 4, resp.TotalCalls, "TotalCalls")
require.Len(t, resp.ByTool, 3, "len(ByTool)")
assert.Equal(t, "Read", resp.ByTool[0].ToolName, "first tool")
assert.Equal(t, 2, resp.ByTool[0].CallCount, "Read calls")
assert.Equal(t, 1, resp.ByTool[0].SessionCount, "Read sessions")
})
t.Run("EmptyDateRange", func(t *testing.T) {
resp, err := d.GetAnalyticsTools(
ctx, emptyFilter(),
)
require.NoError(t, err, "GetAnalyticsTools")
assert.Equal(t, 0, resp.TotalCalls, "TotalCalls")
})
}
func TestAnalyticsToolsToolCallsQueryAggregatesInSQL(t *testing.T) {
q := analyticsToolsQuery("(?,?)", "", false)
normalized := strings.Join(strings.Fields(strings.ToLower(q)), " ")
assert.Contains(t, normalized,
"select tc.session_id, tc.category, trim(coalesce(tc.tool_name, '')), count(*)")
assert.Contains(t, normalized,
"group by tc.session_id, tc.category, trim(coalesce(tc.tool_name, ''))")
}
func TestGetAnalyticsToolsModelFilterCountsOnlyMatchingToolCalls(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "tool-model-1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
m1 := asstMsgAt("tool-model-1", 0, "[Read: a.go]", "2024-06-01T09:00:00Z")
m1.Model = "gpt-4o"
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{SessionID: "tool-model-1", ToolName: "Read", Category: "Read"},
{SessionID: "tool-model-1", ToolName: "Bash", Category: "Bash"},
}
m2 := asstMsgAt("tool-model-1", 1, "[Grep: b.go]", "2024-06-01T09:05:00Z")
m2.Model = "claude-3-5-sonnet"
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{SessionID: "tool-model-1", ToolName: "Grep", Category: "Grep"},
}
insertMessages(t, d, m1, m2)
resp, err := d.GetAnalyticsTools(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
})
require.NoError(t, err, "GetAnalyticsTools")
assert.Equal(t, 2, resp.TotalCalls, "TotalCalls")
require.Len(t, resp.ByCategory, 2, "len(ByCategory)")
catMap := make(map[string]int)
for _, c := range resp.ByCategory {
catMap[c.Category] = c.Count
}
assert.Equal(t, 1, catMap["Read"], "Read")
assert.Equal(t, 1, catMap["Bash"], "Bash")
assert.Zero(t, catMap["Grep"], "Grep")
toolMap := make(map[string]int)
for _, tool := range resp.ByTool {
toolMap[tool.ToolName] = tool.CallCount
}
assert.Equal(t, 1, toolMap["Read"], "Read tool")
assert.Equal(t, 1, toolMap["Bash"], "Bash tool")
assert.Zero(t, toolMap["Grep"], "Grep tool")
}
func TestGetAnalyticsToolsModelAndHourFilterCountsOnlyMatchingHourToolCalls(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "tool-model-hour", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:30:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
m1 := asstMsgAt("tool-model-hour", 0, "[Read: a.go]", "2024-06-01T09:00:00Z")
m1.Model = "gpt-4o"
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{SessionID: "tool-model-hour", ToolName: "Read", Category: "Read"},
{SessionID: "tool-model-hour", ToolName: "Bash", Category: "Bash"},
}
m2 := asstMsgAt("tool-model-hour", 1, "[Grep: b.go]", "2024-06-01T10:00:00Z")
m2.Model = "gpt-4o"
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{SessionID: "tool-model-hour", ToolName: "Grep", Category: "Grep"},
}
insertMessages(t, d, m1, m2)
hour := 10
resp, err := d.GetAnalyticsTools(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o", Hour: &hour,
})
require.NoError(t, err, "GetAnalyticsTools")
assert.Equal(t, 1, resp.TotalCalls, "TotalCalls")
require.Len(t, resp.ByCategory, 1, "len(ByCategory)")
assert.Equal(t, "Grep", resp.ByCategory[0].Category, "Category")
assert.Equal(t, 1, resp.ByCategory[0].Count, "Count")
}
func TestGetAnalyticsSkills(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsSkills(ctx, baseFilter(), "week")
require.NoError(t, err, "GetAnalyticsSkills")
assert.Equal(t, 0, resp.TotalSkillCalls, "TotalSkillCalls")
assert.Equal(t, 0, resp.DistinctSkills, "DistinctSkills")
assert.Empty(t, resp.BySkill, "BySkill")
assert.Empty(t, resp.Trend, "Trend")
})
insertSession(t, d, "sk1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
s.Machine = "mac"
})
sk1m1 := asstMsgAt("sk1", 0, "skill calls", "2024-06-01T09:00:00Z")
sk1m1.HasToolUse = true
sk1m1.ToolCalls = []ToolCall{
{SessionID: "sk1", ToolName: "Skill", Category: "Skill", SkillName: "review-code"},
{SessionID: "sk1", ToolName: "Skill", Category: "Skill", SkillName: "review-code"},
{SessionID: "sk1", ToolName: "Skill", Category: "Skill", SkillName: " write-tests "},
{SessionID: "sk1", ToolName: "Skill", Category: "Skill"},
{SessionID: "sk1", ToolName: "Skill", Category: "Skill", SkillName: " "},
}
insertMessages(t, d, sk1m1)
insertSession(t, d, "sk2", "beta", func(s *Session) {
s.StartedAt = new("2024-06-02T10:00:00Z")
s.MessageCount = 1
s.Agent = "codex"
s.Machine = "linux"
})
sk2m1 := asstMsgAt("sk2", 0, "skill call", "2024-06-02T10:00:00Z")
sk2m1.HasToolUse = true
sk2m1.ToolCalls = []ToolCall{
{SessionID: "sk2", ToolName: "Skill", Category: "Skill", SkillName: "review-code"},
}
insertMessages(t, d, sk2m1)
insertSession(t, d, "sk3", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-03T11:00:00Z")
s.MessageCount = 1
s.Agent = "codex"
s.Machine = "mac"
})
_, err := d.getWriter().Exec(
`UPDATE sessions SET is_automated = 1 WHERE id = 'sk3'`,
)
require.NoError(t, err, "mark sk3 automated")
sk3m1 := asstMsgAt("sk3", 0, "automated skill call", "2024-06-03T11:00:00Z")
sk3m1.HasToolUse = true
sk3m1.ToolCalls = []ToolCall{
{SessionID: "sk3", ToolName: "Skill", Category: "Skill", SkillName: "write-tests"},
}
insertMessages(t, d, sk3m1)
t.Run("Aggregates", func(t *testing.T) {
resp, err := d.GetAnalyticsSkills(ctx, baseFilter(), "week")
require.NoError(t, err, "GetAnalyticsSkills")
assert.Equal(t, 5, resp.TotalSkillCalls, "TotalSkillCalls")
assert.Equal(t, 2, resp.DistinctSkills, "DistinctSkills")
require.Len(t, resp.BySkill, 2, "BySkill")
review := resp.BySkill[0]
assert.Equal(t, "review-code", review.SkillName, "SkillName")
assert.Equal(t, 3, review.CallCount, "CallCount")
assert.Equal(t, 2, review.SessionCount, "SessionCount")
assert.Equal(t, 60.0, review.Pct, "Pct")
assert.Equal(t, "2024-06-02T10:00:00Z", review.LastUsedAt, "LastUsedAt")
assert.Equal(t, []SkillAgentBreakdown{
{Agent: "claude", Count: 2},
{Agent: "codex", Count: 1},
}, review.AgentBreakdown, "AgentBreakdown")
assert.Equal(t, []SkillProjectBreakdown{
{Project: "alpha", Count: 2},
{Project: "beta", Count: 1},
}, review.ProjectBreakdown, "ProjectBreakdown")
write := resp.BySkill[1]
assert.Equal(t, "write-tests", write.SkillName, "trimmed SkillName")
assert.Equal(t, 2, write.CallCount, "write CallCount")
assert.Equal(t, 2, write.SessionCount, "write SessionCount")
assert.Equal(t, 40.0, write.Pct, "write Pct")
assert.Equal(t, "2024-06-03T11:00:00Z", write.LastUsedAt, "write LastUsedAt")
})
t.Run("Trend", func(t *testing.T) {
resp, err := d.GetAnalyticsSkills(ctx, baseFilter(), "week")
require.NoError(t, err, "GetAnalyticsSkills")
require.Len(t, resp.Trend, 2, "Trend")
assert.Equal(t, "2024-05-27", resp.Trend[0].Date, "first week")
assert.Equal(t, 3, resp.Trend[0].BySkill["review-code"], "week 1 review")
assert.Equal(t, 1, resp.Trend[0].BySkill["write-tests"], "week 1 write")
assert.Equal(t, "2024-06-03", resp.Trend[1].Date, "second week")
assert.Equal(t, 1, resp.Trend[1].BySkill["write-tests"], "week 2 write")
})
t.Run("Filters", func(t *testing.T) {
f := baseFilter()
f.Project = "alpha"
resp, err := d.GetAnalyticsSkills(ctx, f, "week")
require.NoError(t, err, "project GetAnalyticsSkills")
assert.Equal(t, 4, resp.TotalSkillCalls, "project TotalSkillCalls")
f = baseFilter()
f.Agent = "claude"
resp, err = d.GetAnalyticsSkills(ctx, f, "week")
require.NoError(t, err, "agent GetAnalyticsSkills")
assert.Equal(t, 3, resp.TotalSkillCalls, "agent TotalSkillCalls")
f = baseFilter()
f.Machine = "linux"
resp, err = d.GetAnalyticsSkills(ctx, f, "week")
require.NoError(t, err, "machine GetAnalyticsSkills")
assert.Equal(t, 1, resp.TotalSkillCalls, "machine TotalSkillCalls")
f = baseFilter()
f.From = "2024-06-01"
f.To = "2024-06-01"
resp, err = d.GetAnalyticsSkills(ctx, f, "week")
require.NoError(t, err, "date GetAnalyticsSkills")
assert.Equal(t, 3, resp.TotalSkillCalls, "date TotalSkillCalls")
f = baseFilter()
f.ExcludeAutomated = true
resp, err = d.GetAnalyticsSkills(ctx, f, "week")
require.NoError(t, err, "automation GetAnalyticsSkills")
assert.Equal(t, 4, resp.TotalSkillCalls, "automation TotalSkillCalls")
})
t.Run("EmptyDateRange", func(t *testing.T) {
resp, err := d.GetAnalyticsSkills(ctx, emptyFilter(), "week")
require.NoError(t, err, "GetAnalyticsSkills")
assert.Equal(t, 0, resp.TotalSkillCalls, "TotalSkillCalls")
assert.Equal(t, 0, resp.DistinctSkills, "DistinctSkills")
})
}
func TestGetAnalyticsSkillsUsesMessageTimestamp(t *testing.T) {
d := testDB(t)
ctx := context.Background()
filter := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-30",
Timezone: "UTC",
}
// Long-running session: starts early, uses the skill weeks later.
insertSession(t, d, "lr1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
lrMsg := asstMsgAt("lr1", 0, "late skill call", "2024-06-20T15:00:00Z")
lrMsg.HasToolUse = true
lrMsg.ToolCalls = []ToolCall{
{SessionID: "lr1", ToolName: "Skill", Category: "Skill", SkillName: "deploy"},
}
insertMessages(t, d, lrMsg)
// Fallback session: skill-call message has no timestamp.
insertSession(t, d, "fb1", "beta", func(s *Session) {
s.StartedAt = new("2024-06-05T08:00:00Z")
s.MessageCount = 1
s.Agent = "codex"
})
fbMsg := asstMsg("fb1", 0, "skill call")
fbMsg.Timestamp = ""
fbMsg.HasToolUse = true
fbMsg.ToolCalls = []ToolCall{
{SessionID: "fb1", ToolName: "Skill", Category: "Skill", SkillName: "build"},
}
insertMessages(t, d, fbMsg)
resp, err := d.GetAnalyticsSkills(ctx, filter, "week")
require.NoError(t, err, "GetAnalyticsSkills")
require.Len(t, resp.BySkill, 2, "BySkill")
bySkill := map[string]SkillUsage{}
for _, s := range resp.BySkill {
bySkill[s.SkillName] = s
}
deploy, ok := bySkill["deploy"]
require.True(t, ok, "deploy present")
assert.Equal(t, "2024-06-20T15:00:00Z", deploy.LastUsedAt,
"deploy LastUsedAt uses message timestamp, not session start")
build, ok := bySkill["build"]
require.True(t, ok, "build present")
assert.Equal(t, "2024-06-05T08:00:00Z", build.LastUsedAt,
"build LastUsedAt falls back to session timestamp")
trend := map[string]int{}
for _, e := range resp.Trend {
if e.BySkill["deploy"] > 0 {
trend[e.Date] += e.BySkill["deploy"]
}
}
assert.Equal(t, map[string]int{"2024-06-17": 1}, trend,
"deploy trend bucket follows message timestamp week")
}
func TestGetAnalyticsSkillsSpreadsTrendAcrossWeeks(t *testing.T) {
d := testDB(t)
ctx := context.Background()
filter := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-30",
Timezone: "UTC",
}
// One long-running session invokes the same skill in two messages
// that fall in different weeks. Each call must bucket into its own
// week, not roll up onto the latest message's week.
insertSession(t, d, "sp1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-03T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
early := asstMsgAt("sp1", 0, "early skill call", "2024-06-03T09:00:00Z")
early.HasToolUse = true
early.ToolCalls = []ToolCall{
{SessionID: "sp1", ToolName: "Skill", Category: "Skill", SkillName: "deploy"},
}
late := asstMsgAt("sp1", 1, "late skill call", "2024-06-17T15:00:00Z")
late.HasToolUse = true
late.ToolCalls = []ToolCall{
{SessionID: "sp1", ToolName: "Skill", Category: "Skill", SkillName: "deploy"},
{SessionID: "sp1", ToolName: "Skill", Category: "Skill", SkillName: "deploy"},
}
insertMessages(t, d, early, late)
resp, err := d.GetAnalyticsSkills(ctx, filter, "week")
require.NoError(t, err, "GetAnalyticsSkills")
require.Len(t, resp.BySkill, 1, "BySkill")
assert.Equal(t, 3, resp.BySkill[0].CallCount, "rolled-up CallCount")
assert.Equal(t, 1, resp.BySkill[0].SessionCount, "SessionCount")
trend := map[string]int{}
for _, e := range resp.Trend {
if c := e.BySkill["deploy"]; c > 0 {
trend[e.Date] += c
}
}
assert.Equal(t, map[string]int{
"2024-06-03": 1,
"2024-06-17": 2,
}, trend, "each call buckets into its own message-timestamp week")
}
func TestBuildSkillsAnalyticsTrendGranularity(t *testing.T) {
rows := []SkillAnalyticsRow{
{SessionID: "a", SkillName: "deploy", Date: "2024-06-04", Count: 1},
{SessionID: "a", SkillName: "deploy", Date: "2024-06-05", Count: 2},
{SessionID: "b", SkillName: "review", Date: "2024-07-19", Count: 1},
}
tests := []struct {
name string
granularity string
want []SkillTrendEntry
}{
{
name: "day keeps each date",
granularity: "day",
want: []SkillTrendEntry{
{Date: "2024-06-04", BySkill: map[string]int{"deploy": 1}},
{Date: "2024-06-05", BySkill: map[string]int{"deploy": 2}},
{Date: "2024-07-19", BySkill: map[string]int{"review": 1}},
},
},
{
name: "week folds onto the ISO Monday",
granularity: "week",
want: []SkillTrendEntry{
{Date: "2024-06-03", BySkill: map[string]int{"deploy": 3}},
{Date: "2024-07-15", BySkill: map[string]int{"review": 1}},
},
},
{
name: "month folds onto the first of the month",
granularity: "month",
want: []SkillTrendEntry{
{Date: "2024-06-01", BySkill: map[string]int{"deploy": 3}},
{Date: "2024-07-01", BySkill: map[string]int{"review": 1}},
},
},
{
name: "empty defaults to week",
granularity: "",
want: []SkillTrendEntry{
{Date: "2024-06-03", BySkill: map[string]int{"deploy": 3}},
{Date: "2024-07-15", BySkill: map[string]int{"review": 1}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := BuildSkillsAnalytics(rows, "", "", tt.granularity)
assert.Equal(t, tt.want, resp.Trend, "Trend buckets")
})
}
}
func TestBuildSkillsAnalyticsIncludesEmptyTrendBuckets(t *testing.T) {
rows := []SkillAnalyticsRow{
{SessionID: "a", SkillName: "deploy", Date: "2024-06-03", Count: 2},
{SessionID: "b", SkillName: "deploy", Date: "2024-06-17", Count: 1},
}
resp := BuildSkillsAnalytics(
rows, "2024-06-03", "2024-06-17", "week",
)
assert.Equal(t, []SkillTrendEntry{
{Date: "2024-06-03", BySkill: map[string]int{"deploy": 2}},
{Date: "2024-06-10", BySkill: map[string]int{}},
{Date: "2024-06-17", BySkill: map[string]int{"deploy": 1}},
}, resp.Trend)
}
func TestBuildSkillsAnalyticsLastUsedChronological(t *testing.T) {
// The fractional-second timestamp is chronologically later but
// lexically smaller ('.' sorts before 'Z'), so a string compare
// would pick the wrong value.
rows := []SkillAnalyticsRow{
{
SessionID: "a", SkillName: "deploy", Date: "2024-06-10",
LastUsedAt: "2024-06-10T09:00:00Z", Count: 1,
},
{
SessionID: "b", SkillName: "deploy", Date: "2024-06-10",
LastUsedAt: "2024-06-10T09:00:00.500Z", Count: 1,
},
}
resp := BuildSkillsAnalytics(rows, "", "", "week")
require.Len(t, resp.BySkill, 1, "BySkill")
assert.Equal(t, "2024-06-10T09:00:00.500Z",
resp.BySkill[0].LastUsedAt,
"LastUsedAt picks the chronologically latest timestamp")
}
func TestResolveSkillRowTime(t *testing.T) {
hour := 10
monday := 0 // ISO Mon=0
base := func() AnalyticsFilter {
return AnalyticsFilter{From: "2024-06-01", To: "2024-06-30", Timezone: "UTC"}
}
tests := []struct {
name string
f AnalyticsFilter
msgTS, sessTS string
wantUsed string
wantDate string
wantKeep bool
}{
{"in range", base(), "2024-06-10T10:00:00Z", "2024-05-01T00:00:00Z",
"2024-06-10T10:00:00Z", "2024-06-10", true},
{"before range", base(), "2024-05-31T10:00:00Z", "ignored",
"2024-05-31T10:00:00Z", "2024-05-31", false},
{"after range", base(), "2024-07-01T10:00:00Z", "ignored",
"2024-07-01T10:00:00Z", "2024-07-01", false},
{"fallback to session ts", base(), "", "2024-06-15T08:00:00Z",
"2024-06-15T08:00:00Z", "2024-06-15", true},
{"hour excludes", func() AnalyticsFilter {
f := base()
f.Hour = &hour
return f
}(), "2024-06-10T09:00:00Z", "", "2024-06-10T09:00:00Z", "2024-06-10", false},
{"hour includes", func() AnalyticsFilter {
f := base()
f.Hour = &hour
return f
}(), "2024-06-10T10:00:00Z", "", "2024-06-10T10:00:00Z", "2024-06-10", true},
{"day-of-week excludes", func() AnalyticsFilter {
f := base()
f.DayOfWeek = &monday
return f
}(), "2024-06-11T10:00:00Z", "", "2024-06-11T10:00:00Z", "2024-06-11", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
used, date, keep := tc.f.ResolveSkillRowTime(tc.msgTS, tc.sessTS)
assert.Equal(t, tc.wantUsed, used, "usedTS")
assert.Equal(t, tc.wantDate, date, "date")
assert.Equal(t, tc.wantKeep, keep, "keep")
})
}
}
func TestGetAnalyticsSkillsDateBoundaries(t *testing.T) {
d := testDB(t)
ctx := context.Background()
filter := AnalyticsFilter{From: "2024-06-01", To: "2024-06-30", Timezone: "UTC"}
// The session starts before the range and uses the skill before,
// inside, and after it. Only the in-range call should count.
insertSession(t, d, "span", "alpha", func(s *Session) {
s.StartedAt = new("2024-05-20T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
})
mkCall := func(ord int, ts string) Message {
m := asstMsgAt("span", ord, "call", ts)
m.HasToolUse = true
m.ToolCalls = []ToolCall{
{SessionID: "span", ToolName: "Skill", Category: "Skill", SkillName: "deploy"},
}
return m
}
insertMessages(t, d,
mkCall(0, "2024-05-25T10:00:00Z"), // before From
mkCall(1, "2024-06-10T10:00:00Z"), // in range
mkCall(2, "2024-07-05T10:00:00Z"), // after To
)
resp, err := d.GetAnalyticsSkills(ctx, filter, "week")
require.NoError(t, err, "GetAnalyticsSkills")
require.Len(t, resp.BySkill, 1, "BySkill")
assert.Equal(t, "deploy", resp.BySkill[0].SkillName)
assert.Equal(t, 1, resp.BySkill[0].CallCount,
"only the in-range call counts, even though the session started "+
"before the range")
assert.Equal(t, "2024-06-10T10:00:00Z", resp.BySkill[0].LastUsedAt)
trend := map[string]int{}
for _, e := range resp.Trend {
if c := e.BySkill["deploy"]; c > 0 {
trend[e.Date] += c
}
}
assert.Equal(t, map[string]int{"2024-06-10": 1}, trend,
"only the in-range week is bucketed")
}
func TestAnalyticsSkillsToolCallsQueryAggregatesInSQL(t *testing.T) {
q := analyticsSkillsQuery("(?,?)", "")
normalized := strings.Join(strings.Fields(strings.ToLower(q)), " ")
assert.Contains(t, normalized,
"select tc.session_id, trim(tc.skill_name), count(*), "+
"coalesce(m.timestamp, '')")
assert.Contains(t, normalized,
"left join messages m on m.session_id = tc.session_id "+
"and m.id = tc.message_id")
assert.Contains(t, normalized,
"trim(coalesce(tc.skill_name, '')) != ''")
assert.Contains(t, normalized,
"group by tc.session_id, trim(tc.skill_name), "+
"coalesce(m.timestamp, '')")
}
func TestGetAnalyticsSkillsModelFilterCountsOnlyMatchingSkillCalls(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "skill-model-1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
m1 := asstMsgAt("skill-model-1", 0, "skill call", "2024-06-01T09:00:00Z")
m1.Model = "gpt-4o"
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{
SessionID: "skill-model-1",
ToolName: "Skill",
Category: "Skill",
SkillName: "review-code",
},
}
m2 := asstMsgAt("skill-model-1", 1, "skill call", "2024-06-01T09:05:00Z")
m2.Model = "claude-3-5-sonnet"
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{
SessionID: "skill-model-1",
ToolName: "Skill",
Category: "Skill",
SkillName: "write-tests",
},
}
insertMessages(t, d, m1, m2)
resp, err := d.GetAnalyticsSkills(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "week")
require.NoError(t, err, "GetAnalyticsSkills")
assert.Equal(t, 1, resp.TotalSkillCalls, "TotalSkillCalls")
assert.Equal(t, 1, resp.DistinctSkills, "DistinctSkills")
require.Len(t, resp.BySkill, 1, "len(BySkill)")
assert.Equal(t, "review-code", resp.BySkill[0].SkillName, "SkillName")
assert.Equal(t, 1, resp.BySkill[0].CallCount, "CallCount")
assert.Equal(t, "2024-06-01T09:00:00Z", resp.BySkill[0].LastUsedAt,
"LastUsedAt")
}
func TestGetAnalyticsToolsCanceled(t *testing.T) {
d := testDB(t)
ctx := canceledCtx()
_, err := d.GetAnalyticsTools(ctx, baseFilter())
requireCanceledErr(t, err)
}
func TestGetAnalyticsSkillsCanceled(t *testing.T) {
d := testDB(t)
ctx := canceledCtx()
_, err := d.GetAnalyticsSkills(ctx, baseFilter(), "week")
requireCanceledErr(t, err)
}
func TestActivityToolAndThinkingCounts(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "at1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 3
s.Agent = "claude"
})
// User message, assistant with thinking, assistant with tool
u := userMsg("at1", 0, "hello")
a1 := asstMsg("at1", 1, "thinking response")
a1.HasThinking = true
a2 := asstMsg("at1", 2, "[Read: a.go]")
a2.HasToolUse = true
a2.ToolCalls = []ToolCall{
{SessionID: "at1", ToolName: "Read", Category: "Read"},
{SessionID: "at1", ToolName: "Bash", Category: "Bash"},
}
insertMessages(t, d, u, a1, a2)
resp := mustActivity(t, d, ctx, baseFilter(), "day")
require.NotEmpty(t, resp.Series, "expected non-empty series")
entry := resp.Series[0]
assert.Equal(t, 1, entry.ThinkingMessages, "ThinkingMessages")
assert.Equal(t, 2, entry.ToolCalls, "ToolCalls")
}
func TestActivityToolCallsRespectModelFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "at-model", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
m1 := asstMsgAt("at-model", 0, "[Read: a.go]", "2024-06-01T09:00:00Z")
m1.Model = "gpt-4o"
m1.HasToolUse = true
m1.ToolCalls = []ToolCall{
{SessionID: "at-model", ToolName: "Read", Category: "Read"},
{SessionID: "at-model", ToolName: "Bash", Category: "Bash"},
}
m2 := asstMsgAt("at-model", 1, "[Grep: b.go]", "2024-06-01T09:05:00Z")
m2.Model = "claude-3-5-sonnet"
m2.HasToolUse = true
m2.ToolCalls = []ToolCall{
{SessionID: "at-model", ToolName: "Grep", Category: "Grep"},
}
insertMessages(t, d, m1, m2)
resp := mustActivity(t, d, ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "day")
require.Len(t, resp.Series, 1, "len(Series)")
assert.Equal(t, 1, resp.Series[0].Messages, "Messages")
assert.Equal(t, 2, resp.Series[0].ToolCalls, "ToolCalls")
}
func TestGetAnalyticsTopSessions(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(
ctx, baseFilter(), "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Len(t, resp.Sessions, 0, "len(Sessions)")
assert.Equal(t, "messages", resp.Metric, "Metric")
})
stats := seedAnalyticsData(t, d)
t.Run("ByMessages", func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(
ctx, baseFilter(), "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, stats.TotalSessions,
"len(Sessions)")
// First should be the session with most messages (b1=30)
assert.Equal(t, 30, resp.Sessions[0].MessageCount, "top session messages")
assert.Equal(t, "project-beta", resp.Sessions[0].Project, "top session project")
})
t.Run("ByDuration", func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(
ctx, baseFilter(), "duration",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Equal(t, "duration", resp.Metric, "Metric")
// All seeded sessions have 1h duration except a1
// which runs from 09:00 to midyear
require.NotEmpty(t, resp.Sessions,
"expected non-empty sessions")
// All sessions should have positive duration
for _, s := range resp.Sessions {
assert.Greater(t, s.DurationMin, 0.0,
"session %s duration", s.ID)
}
})
t.Run("ByDurationRanksByActiveDuration", func(t *testing.T) {
insertSession(t, d, "wall-dominant", "project-gamma", func(s *Session) {
s.StartedAt = Ptr("2024-06-02T09:00:00Z")
s.EndedAt = Ptr("2024-06-02T11:00:00Z")
s.MessageCount = 3
})
insertMessages(
t,
d,
userMsgAt("wall-dominant", 0, "noop", "2024-06-02T09:00:00Z"),
func() Message {
m := asstMsgAt(
"wall-dominant",
1,
"idle wait",
"2024-06-02T10:59:00Z",
)
m.HasToolUse = true
return m
}(),
userMsgAt(
"wall-dominant",
2,
"done",
"2024-06-02T11:00:00Z",
),
)
insertSession(t, d, "actively-working", "project-gamma", func(s *Session) {
s.StartedAt = Ptr("2024-06-02T09:30:00Z")
s.EndedAt = Ptr("2024-06-02T09:50:00Z")
s.MessageCount = 3
})
insertMessages(
t,
d,
userMsgAt("actively-working", 0, "start", "2024-06-02T09:30:00Z"),
func() Message {
m := asstMsgAt(
"actively-working",
1,
"tooling",
"2024-06-02T09:35:00Z",
)
m.HasToolUse = true
return m
}(),
userMsgAt("actively-working", 2, "finish", "2024-06-02T09:50:00Z"),
)
resp, err := d.GetAnalyticsTopSessions(
ctx, AnalyticsFilter{Project: "project-gamma"}, "duration",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Equal(t, "duration", resp.Metric, "Metric")
require.Len(t, resp.Sessions, 2, "sessions")
assert.Equal(t, "actively-working", resp.Sessions[0].ID, "top session by active duration")
assert.Equal(t, 20.0, resp.Sessions[0].DurationMin, "active total duration")
// 5 min user->asst gap + a 15 min gap capped at the 5 min idle
// cap = 10.
assert.Equal(t, 10.0, resp.Sessions[0].ActiveDurationMin, "active duration")
assert.Equal(t, 120.0, resp.Sessions[1].DurationMin, "wall-only duration")
// 119 min idle gap capped to 5 + a 1 min gap = 6, so the
// mostly-idle 2-hour session ranks below the engaged 20-min one.
assert.Equal(t, 6.0, resp.Sessions[1].ActiveDurationMin, "idle active duration")
})
t.Run("ByDurationKeepsNearTieOrderBeforeDisplayRounding", func(t *testing.T) {
// Two sessions whose active durations differ only below the
// display-rounding granularity (2.504 vs 2.496 min). Both render
// as "2.5", but the raw value must decide the rank. The single
// gap stays under the 5 min idle cap so the clamp does not
// flatten the sub-second difference.
insertSession(t, d, "near-tie-longer", "project-precision", func(s *Session) {
s.StartedAt = Ptr("2024-06-03T09:00:00.000Z")
s.EndedAt = Ptr("2024-06-03T09:02:30.240Z")
s.MessageCount = 2
})
insertMessages(
t,
d,
userMsgAt("near-tie-longer", 0, "start", "2024-06-03T09:00:00.000Z"),
asstMsgAt("near-tie-longer", 1, "work", "2024-06-03T09:02:30.240Z"),
)
insertSession(t, d, "near-tie-shorter", "project-precision", func(s *Session) {
s.StartedAt = Ptr("2024-06-03T09:00:00.000Z")
s.EndedAt = Ptr("2024-06-03T09:02:29.760Z")
s.MessageCount = 2
})
insertMessages(
t,
d,
userMsgAt("near-tie-shorter", 0, "start", "2024-06-03T09:00:00.000Z"),
asstMsgAt("near-tie-shorter", 1, "work", "2024-06-03T09:02:29.760Z"),
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-precision",
From: "2024-06-03",
To: "2024-06-03",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 2, "precision sessions")
assert.Equal(t, "near-tie-longer", resp.Sessions[0].ID, "raw active duration should decide the rank")
assert.Equal(t, 2.5, resp.Sessions[0].ActiveDurationMin, "display rounding")
assert.Equal(t, 2.5, resp.Sessions[1].ActiveDurationMin, "display rounding")
})
t.Run("ByDurationRanksByActiveDurationInGoFallback", func(t *testing.T) {
writes := make([]SessionBatchWrite, 0, 202)
for i := range 201 {
id := fmt.Sprintf("dst-wall-%03d", i)
writes = append(writes, SessionBatchWrite{
Session: Session{
ID: id,
Project: "project-dst",
Machine: defaultMachine,
Agent: defaultAgent,
StartedAt: Ptr("2026-03-10T09:00:00Z"),
EndedAt: Ptr("2026-03-10T11:00:00Z"),
MessageCount: 3,
UserMessageCount: 2,
},
Messages: []Message{
userMsgAt(id, 0, "noop", "2026-03-10T09:00:00Z"),
func() Message {
m := asstMsgAt(
id,
1,
"idle wait",
"2026-03-10T10:59:00Z",
)
m.HasToolUse = true
return m
}(),
userMsgAt(id, 2, "done", "2026-03-10T11:00:00Z"),
},
})
}
writes = append(writes, SessionBatchWrite{
Session: Session{
ID: "dst-actively-working",
Project: "project-dst",
Machine: defaultMachine,
Agent: defaultAgent,
StartedAt: Ptr("2026-03-10T09:30:00Z"),
EndedAt: Ptr("2026-03-10T09:50:00Z"),
MessageCount: 3,
UserMessageCount: 2,
},
Messages: []Message{
userMsgAt("dst-actively-working", 0, "start", "2026-03-10T09:30:00Z"),
func() Message {
m := asstMsgAt(
"dst-actively-working",
1,
"tooling",
"2026-03-10T09:35:00Z",
)
m.HasToolUse = true
return m
}(),
userMsgAt("dst-actively-working", 2, "finish", "2026-03-10T09:50:00Z"),
},
})
result, err := d.WriteSessionBatchAtomic(writes)
require.NoError(t, err)
require.Equal(t, len(writes), result.WrittenSessions)
require.Equal(t, 3*len(writes), result.WrittenMessages)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-dst",
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.NotEmpty(t, resp.Sessions, "sessions")
assert.Equal(t, "dst-actively-working", resp.Sessions[0].ID, "top session by active duration in fallback")
// 5 + 5 (15 min gap capped at the 5 min idle cap) = 10.
assert.Equal(t, 10.0, resp.Sessions[0].ActiveDurationMin, "fallback active duration")
})
t.Run("ByDurationKeepsNearTieOrderInGoFallback", func(t *testing.T) {
// Same near-tie invariant as the SQLite SQL case, but the
// timezone filter forces the Go fallback ranking path.
insertSession(t, d, "dst-near-tie-longer", "project-dst-precision", func(s *Session) {
s.StartedAt = Ptr("2026-03-10T09:00:00.000Z")
s.EndedAt = Ptr("2026-03-10T09:02:30.240Z")
s.MessageCount = 2
})
insertMessages(
t,
d,
userMsgAt("dst-near-tie-longer", 0, "start", "2026-03-10T09:00:00.000Z"),
asstMsgAt("dst-near-tie-longer", 1, "work", "2026-03-10T09:02:30.240Z"),
)
insertSession(t, d, "dst-near-tie-shorter", "project-dst-precision", func(s *Session) {
s.StartedAt = Ptr("2026-03-10T09:00:00.000Z")
s.EndedAt = Ptr("2026-03-10T09:02:29.760Z")
s.MessageCount = 2
})
insertMessages(
t,
d,
userMsgAt("dst-near-tie-shorter", 0, "start", "2026-03-10T09:00:00.000Z"),
asstMsgAt("dst-near-tie-shorter", 1, "work", "2026-03-10T09:02:29.760Z"),
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-dst-precision",
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 2, "fallback precision sessions")
assert.Equal(t, "dst-near-tie-longer", resp.Sessions[0].ID, "fallback should keep raw active ordering")
assert.Equal(t, 2.5, resp.Sessions[0].ActiveDurationMin, "fallback display rounding")
assert.Equal(t, 2.5, resp.Sessions[1].ActiveDurationMin, "fallback display rounding")
})
t.Run("ByDurationCapsLongGapsAndCountsGeneration", func(t *testing.T) {
// No tool calls anywhere: under the old tool-execution-only
// definition this session scored 0. The clamp counts every gap
// -- model generation included -- and bounds the one long idle
// gap at the 5 min cap: 4 (gen) + 60->5 (idle cap) + 1 = 10.
insertSession(t, d, "clamp-mixed", "project-clamp-sql", func(s *Session) {
s.StartedAt = Ptr("2024-06-04T09:00:00Z")
s.EndedAt = Ptr("2024-06-04T10:05:00Z")
s.MessageCount = 4
})
insertMessages(
t,
d,
userMsgAt("clamp-mixed", 0, "start", "2024-06-04T09:00:00Z"),
asstMsgAt("clamp-mixed", 1, "thinking", "2024-06-04T09:04:00Z"),
userMsgAt("clamp-mixed", 2, "back", "2024-06-04T10:04:00Z"),
asstMsgAt("clamp-mixed", 3, "reply", "2024-06-04T10:05:00Z"),
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-clamp-sql",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 1, "clamp session")
assert.Equal(t, "clamp-mixed", resp.Sessions[0].ID)
assert.Equal(t, 65.0, resp.Sessions[0].DurationMin, "wall duration")
assert.Equal(t, 10.0, resp.Sessions[0].ActiveDurationMin, "clamped active duration")
})
t.Run("ByDurationCapsLongGapsAndCountsGenerationInGoFallback", func(t *testing.T) {
// Same clamp + generation accounting as the SQLite SQL case,
// exercised through the timezone-aware Go fallback path.
insertSession(t, d, "dst-clamp-mixed", "project-clamp-fallback", func(s *Session) {
s.StartedAt = Ptr("2026-03-10T09:00:00Z")
s.EndedAt = Ptr("2026-03-10T10:05:00Z")
s.MessageCount = 4
})
insertMessages(
t,
d,
userMsgAt("dst-clamp-mixed", 0, "start", "2026-03-10T09:00:00Z"),
asstMsgAt("dst-clamp-mixed", 1, "thinking", "2026-03-10T09:04:00Z"),
userMsgAt("dst-clamp-mixed", 2, "back", "2026-03-10T10:04:00Z"),
asstMsgAt("dst-clamp-mixed", 3, "reply", "2026-03-10T10:05:00Z"),
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-clamp-fallback",
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 1, "fallback clamp session")
assert.Equal(t, "dst-clamp-mixed", resp.Sessions[0].ID)
assert.Equal(t, 65.0, resp.Sessions[0].DurationMin, "wall duration")
assert.Equal(t, 10.0, resp.Sessions[0].ActiveDurationMin, "fallback clamped active duration")
})
t.Run("ByDurationExcludesReversedAndEmptyTimestamps", func(t *testing.T) {
// A reversed (ended < started) or empty-timestamp session can
// still accumulate positive message-gap active duration, so
// without an eligibility guard it would rank into the duration
// list. The SQL path must reject both, matching DuckDB and the Go
// fallback.
insertSession(t, d, "elig-valid", "project-elig-sql", func(s *Session) {
s.StartedAt = Ptr("2024-07-01T09:00:00Z")
s.EndedAt = Ptr("2024-07-01T09:30:00Z")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-valid", 0, "start", "2024-07-01T09:00:00Z"),
asstMsgAt("elig-valid", 1, "work", "2024-07-01T09:03:00Z"),
)
insertSession(t, d, "elig-reversed", "project-elig-sql", func(s *Session) {
s.StartedAt = Ptr("2024-07-01T10:00:00Z")
s.EndedAt = Ptr("2024-07-01T09:00:00Z")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-reversed", 0, "start", "2024-07-01T09:00:00Z"),
asstMsgAt("elig-reversed", 1, "work", "2024-07-01T09:04:00Z"),
)
insertSession(t, d, "elig-empty", "project-elig-sql", func(s *Session) {
s.StartedAt = Ptr("")
s.EndedAt = Ptr("")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-empty", 0, "start", "2024-07-01T09:00:00Z"),
asstMsgAt("elig-empty", 1, "work", "2024-07-01T09:04:00Z"),
)
resp, err := d.GetAnalyticsTopSessions(
ctx, AnalyticsFilter{Project: "project-elig-sql"}, "duration",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
var ids []string
for _, s := range resp.Sessions {
ids = append(ids, s.ID)
}
assert.Equal(t, []string{"elig-valid"}, ids,
"reversed and empty duration rows must be excluded")
})
t.Run("ByDurationExcludesReversedAndEmptyTimestampsInGoFallback", func(t *testing.T) {
// Same eligibility guard as the SQL path, but the timezone filter
// forces the Go fallback ranking path. Parity matters because the
// ranking is by message-gap active duration, which stays positive
// regardless of reversed or missing wall-clock timestamps.
insertSession(t, d, "elig-fb-valid", "project-elig-fb", func(s *Session) {
s.StartedAt = Ptr("2026-03-12T09:00:00Z")
s.EndedAt = Ptr("2026-03-12T09:30:00Z")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-fb-valid", 0, "start", "2026-03-12T09:00:00Z"),
asstMsgAt("elig-fb-valid", 1, "work", "2026-03-12T09:03:00Z"),
)
insertSession(t, d, "elig-fb-reversed", "project-elig-fb", func(s *Session) {
s.StartedAt = Ptr("2026-03-12T10:00:00Z")
s.EndedAt = Ptr("2026-03-12T09:00:00Z")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-fb-reversed", 0, "start", "2026-03-12T09:00:00Z"),
asstMsgAt("elig-fb-reversed", 1, "work", "2026-03-12T09:04:00Z"),
)
insertSession(t, d, "elig-fb-empty", "project-elig-fb", func(s *Session) {
s.StartedAt = Ptr("")
s.EndedAt = Ptr("")
s.MessageCount = 2
})
insertMessages(
t, d,
userMsgAt("elig-fb-empty", 0, "start", "2026-03-12T09:00:00Z"),
asstMsgAt("elig-fb-empty", 1, "work", "2026-03-12T09:04:00Z"),
)
// created_at is not bound by UpsertSession and otherwise defaults
// to insertion time, which the date filter would drop before the
// eligibility guard is reached. Pin it into the filter window so
// this row genuinely exercises the empty-timestamp NULLIF guard
// rather than being excluded by the date range.
_, err := d.getWriter().Exec(
"UPDATE sessions SET created_at = ? WHERE id = ?",
"2026-03-12T00:00:00Z", "elig-fb-empty",
)
require.NoError(t, err, "pin elig-fb-empty created_at")
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
Project: "project-elig-fb",
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
}, "duration")
require.NoError(t, err, "GetAnalyticsTopSessions")
var ids []string
for _, s := range resp.Sessions {
ids = append(ids, s.ID)
}
assert.Equal(t, []string{"elig-fb-valid"}, ids,
"reversed and empty duration rows must be excluded in fallback")
})
t.Run("DefaultMetric", func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(
ctx, baseFilter(), "",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Equal(t, "messages", resp.Metric, "Metric")
})
t.Run("ProjectFilter", func(t *testing.T) {
f := baseFilter()
f.Project = "project-alpha"
resp, err := d.GetAnalyticsTopSessions(
ctx, f, "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Len(t, resp.Sessions, 3, "len(Sessions)")
for _, s := range resp.Sessions {
assert.Equal(t, "project-alpha", s.Project, "session project")
}
})
t.Run("EmptyDateRange", func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(
ctx, emptyFilter(), "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
assert.Len(t, resp.Sessions, 0, "len(Sessions)")
})
}
func TestGetAnalyticsTopSessionsOutputTokensUseFilteredModelTotals(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "top-output-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 510
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "top-output-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:00:00Z",
Model: "gpt-4o", OutputTokens: 10, HasOutputTokens: true,
},
Message{
SessionID: "top-output-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 500, HasOutputTokens: true,
},
)
insertSession(t, d, "top-output-gpt", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T11:00:00Z")
s.EndedAt = new("2024-06-01T12:00:00Z")
s.MessageCount = 1
s.Agent = "gpt"
s.TotalOutputTokens = 30
s.HasTotalOutputTokens = true
})
insertMessages(t, d, Message{
SessionID: "top-output-gpt", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T11:00:00Z",
Model: "gpt-4o", OutputTokens: 30, HasOutputTokens: true,
})
insertSession(t, d, "top-output-uncovered", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T13:00:00Z")
s.EndedAt = new("2024-06-01T14:00:00Z")
s.MessageCount = 2
s.Agent = "mixed"
s.TotalOutputTokens = 900
s.HasTotalOutputTokens = true
})
insertMessages(t, d,
Message{
SessionID: "top-output-uncovered", Ordinal: 0,
Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T13:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "top-output-uncovered", Ordinal: 1,
Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T13:05:00Z",
Model: "claude-3-5-sonnet",
OutputTokens: 900, HasOutputTokens: true,
},
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "output_tokens")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 2, "len(Sessions)")
assert.Equal(t, "top-output-gpt", resp.Sessions[0].ID, "top session")
assert.Equal(t, 30, resp.Sessions[0].OutputTokens,
"top OutputTokens")
assert.Equal(t, "top-output-mixed", resp.Sessions[1].ID,
"second session")
assert.Equal(t, 10, resp.Sessions[1].OutputTokens,
"second OutputTokens")
}
func TestGetAnalyticsTopSessionsDisplayName(t *testing.T) {
d := testDB(t)
ctx := context.Background()
rawFirst := "raw first user message"
sessionName := "Agent generated title"
insertSession(t, d, "session-name", "project-alpha", func(s *Session) {
s.FirstMessage = &rawFirst
s.SessionName = &sessionName
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 10
s.UserMessageCount = 2
})
customName := "User renamed title"
customSessionName := "Generated title hidden by rename"
insertSession(t, d, "custom-name", "project-alpha", func(s *Session) {
s.FirstMessage = &rawFirst
s.SessionName = &customSessionName
s.StartedAt = new("2024-06-01T11:00:00Z")
s.EndedAt = new("2024-06-01T12:00:00Z")
s.MessageCount = 9
s.UserMessageCount = 2
})
require.NoError(t, d.RenameSession("custom-name", &customName),
"RenameSession")
resp, err := d.GetAnalyticsTopSessions(
ctx, baseFilter(), "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
byID := map[string]TopSession{}
for _, session := range resp.Sessions {
byID[session.ID] = session
}
named, ok := byID["session-name"]
require.True(t, ok, "session-name missing from top sessions")
require.NotNil(t, named.DisplayName,
"session_name should be exposed as display_name")
assert.Equal(t, sessionName, *named.DisplayName)
custom, ok := byID["custom-name"]
require.True(t, ok, "custom-name missing from top sessions")
require.NotNil(t, custom.DisplayName,
"custom display_name should be exposed")
assert.Equal(t, customName, *custom.DisplayName)
}
func TestGetAnalyticsTopSessionsMessagesUseFilteredModelCounts(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "top-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 3
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "top-mixed", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T09:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "top-mixed", Ordinal: 1, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
},
Message{
SessionID: "top-mixed", Ordinal: 2, Role: "assistant",
Content: "claude", ContentLength: 6,
Timestamp: "2024-06-01T09:06:00Z",
Model: "claude-3-5-sonnet",
},
)
insertSession(t, d, "top-gpt", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T11:00:00Z")
s.EndedAt = new("2024-06-01T12:00:00Z")
s.MessageCount = 2
s.Agent = "gpt"
})
insertMessages(t, d,
Message{
SessionID: "top-gpt", Ordinal: 0, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T11:00:00Z",
Model: "gpt-4o",
},
Message{
SessionID: "top-gpt", Ordinal: 1, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T11:05:00Z",
Model: "gpt-4o",
},
)
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, "messages")
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 2, "len(Sessions)")
assert.Equal(t, "top-gpt", resp.Sessions[0].ID, "top session")
assert.Equal(t, 2, resp.Sessions[0].MessageCount, "top MessageCount")
assert.Equal(t, "top-mixed", resp.Sessions[1].ID, "second session")
assert.Equal(t, 1, resp.Sessions[1].MessageCount, "second MessageCount")
}
func TestGetAnalyticsTopSessionsModelFilterCapsAtTen(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Twelve sessions all match the gpt-4o filter, so the model-scoped
// re-sort drops the SQL LIMIT and ranks every matching session. The
// caller must still cap the result at the top ten. Session i carries
// i+1 gpt-4o assistant messages of ten output tokens each, so
// "top-cap-11" has the most messages and the most tokens.
for i := range 12 {
id := fmt.Sprintf("top-cap-%d", i)
count := i + 1
hour := fmt.Sprintf("%02d", 8+i)
insertSession(t, d, id, "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T" + hour + ":00:00Z")
s.EndedAt = new("2024-06-01T" + hour + ":30:00Z")
s.MessageCount = count
s.Agent = "gpt"
s.TotalOutputTokens = count * 10
s.HasTotalOutputTokens = true
})
msgs := make([]Message, count)
for j := range msgs {
msgs[j] = Message{
SessionID: id, Ordinal: j, Role: "assistant",
Content: "gpt", ContentLength: 3,
Timestamp: "2024-06-01T" + hour + ":00:00Z",
Model: "gpt-4o",
OutputTokens: 10, HasOutputTokens: true,
}
}
insertMessages(t, d, msgs...)
}
for _, metric := range []string{"messages", "output_tokens"} {
t.Run(metric, func(t *testing.T) {
resp, err := d.GetAnalyticsTopSessions(ctx, AnalyticsFilter{
From: "2024-06-01", To: "2024-06-01", Timezone: "UTC",
Model: "gpt-4o",
}, metric)
require.NoError(t, err, "GetAnalyticsTopSessions")
require.Len(t, resp.Sessions, 10,
"model-filtered top sessions capped at ten")
assert.Equal(t, "top-cap-11", resp.Sessions[0].ID,
"highest-count session ranks first")
})
}
}
func TestBuildWhereProjectFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
seedAnalyticsData(t, d)
t.Run("SummaryWithProject", func(t *testing.T) {
f := baseFilter()
f.Project = "project-alpha"
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 3, s.TotalSessions, "TotalSessions")
})
t.Run("SummaryWithNonexistentProject", func(t *testing.T) {
f := baseFilter()
f.Project = "nonexistent"
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 0, s.TotalSessions, "TotalSessions")
})
}
func TestAnalyticsTerminationFilter(t *testing.T) {
t.Run("BuildWherePredicate", func(t *testing.T) {
cases := []struct {
name string
termination string
want string
}{
{"empty adds nothing", "", ""},
{
"clean", "clean",
"termination_status = 'clean'",
},
{
"unclean", "unclean",
"termination_status IN ('tool_call_pending', 'truncated')",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := baseFilter()
f.Termination = tc.termination
where, _ := f.buildWhere(
"COALESCE(NULLIF(started_at, ''), created_at)",
)
if tc.want == "" {
assert.NotContains(t, where, "termination_status",
"empty termination produced predicate")
return
}
assert.Contains(t, where, tc.want,
"buildWhere(%q)", tc.termination)
})
}
})
t.Run("RoundTripQueries", func(t *testing.T) {
d := testDB(t)
ctx := context.Background()
clean := "clean"
pending := "tool_call_pending"
truncated := "truncated"
insert := func(id string, term *string) {
insertSession(t, d, id, "p", func(s *Session) {
s.StartedAt = new(string("2024-06-02T09:00:00Z"))
s.EndedAt = new(string("2024-06-02T10:00:00Z"))
s.MessageCount = 5
s.UserMessageCount = 2
s.TerminationStatus = term
})
msgs := make([]Message, 5)
for i := range msgs {
role := "user"
if i%2 == 1 {
role = "assistant"
}
msgs[i] = Message{
SessionID: id,
Ordinal: i,
Role: role,
Content: "msg",
ContentLength: 3,
Timestamp: "2024-06-02T09:00:00Z",
}
}
insertMessages(t, d, msgs...)
}
insert("c1", &clean)
insert("c2", &clean)
insert("p1", &pending)
insert("t1", &truncated)
insert("n1", nil)
collect := func(termination string) []string {
f := baseFilter()
f.Termination = termination
resp, err := d.GetAnalyticsTopSessions(
ctx, f, "messages",
)
require.NoError(t, err,
"GetAnalyticsTopSessions(%q)", termination)
ids := make([]string, len(resp.Sessions))
for i, s := range resp.Sessions {
ids[i] = s.ID
}
return ids
}
cases := []struct {
name string
termination string
want []string
}{
{"all", "", []string{"c1", "c2", "p1", "t1", "n1"}},
{"clean", "clean", []string{"c1", "c2"}},
{"unclean", "unclean", []string{"p1", "t1"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := collect(tc.termination)
assertStringSetsEqual(t, got, tc.want)
})
}
t.Run("TopSessionsCarriesTerminationStatus", func(t *testing.T) {
f := baseFilter()
f.Termination = "clean"
resp, err := d.GetAnalyticsTopSessions(
ctx, f, "messages",
)
require.NoError(t, err, "GetAnalyticsTopSessions")
require.NotEmpty(t, resp.Sessions,
"expected sessions, got 0")
for _, s := range resp.Sessions {
if !assert.NotNil(t, s.TerminationStatus,
"session %s TerminationStatus want clean",
s.ID) {
continue
}
assert.Equal(t, "clean", *s.TerminationStatus,
"session %s TerminationStatus", s.ID)
}
})
t.Run("SummaryFilteredByTermination", func(t *testing.T) {
f := baseFilter()
f.Termination = "unclean"
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
})
}
// TestTerminationFilterEmptyEndedAt guards the shared SQLite termination
// activity expression. A flagged session whose ended_at was persisted as the
// empty string must still classify by its started_at fallback. Before the fix
// strftime('%s', ”) returned NULL, silently dropping such sessions from the
// active, stale, and unclean scopes that analytics and session selection share
// (usage already used the NULLIF form, so the two diverged).
func TestTerminationFilterEmptyEndedAt(t *testing.T) {
d := testDB(t)
ctx := context.Background()
pending := "tool_call_pending"
const oldStart = "2024-06-02T09:00:00Z"
// Two flagged sessions old enough to be "unclean": one with a normal
// ended_at (control) and one whose ended_at is the empty string.
seed := func(id, ended string) {
insertSession(t, d, id, "p", func(s *Session) {
s.StartedAt = new(oldStart)
s.EndedAt = new(ended)
s.MessageCount = 4
s.UserMessageCount = 2
s.TerminationStatus = &pending
})
insertMessages(t, d, Message{
SessionID: id, Ordinal: 0, Role: "assistant",
Content: "m", ContentLength: 1, Timestamp: oldStart,
})
}
seed("ended", "2024-06-02T10:00:00Z")
seed("untimed", "")
// Analytics termination filter.
f := baseFilter()
f.Termination = "unclean"
resp, err := d.GetAnalyticsTopSessions(ctx, f, "messages")
require.NoError(t, err, "GetAnalyticsTopSessions unclean")
analyticsIDs := make([]string, len(resp.Sessions))
for i, s := range resp.Sessions {
analyticsIDs[i] = s.ID
}
assert.ElementsMatch(t, []string{"ended", "untimed"}, analyticsIDs,
"analytics unclean must include the empty-ended_at session")
// Session-selection termination filter shares the same expression.
page, err := d.ListSessions(ctx, SessionFilter{
Termination: "unclean", Limit: 50,
})
require.NoError(t, err, "ListSessions unclean")
sessionIDs := make([]string, len(page.Sessions))
for i, s := range page.Sessions {
sessionIDs[i] = s.ID
}
assert.ElementsMatch(t, []string{"ended", "untimed"}, sessionIDs,
"session list unclean must include the empty-ended_at session")
}
func TestTimeFilter(t *testing.T) {
d := testDB(t)
ctx := context.Background()
// Create sessions with messages at known day/hour combos.
// 2024-06-01 = Saturday (ISO dow 5)
// 2024-06-03 = Monday (ISO dow 0)
insertSession(t, d, "tf1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 2
s.Agent = "claude"
})
insertMessages(t, d,
Message{
SessionID: "tf1", Ordinal: 0, Role: "user",
Timestamp: "2024-06-01T09:05:00Z",
Content: "hello", ContentLength: 5,
},
Message{
SessionID: "tf1", Ordinal: 1, Role: "assistant",
Timestamp: "2024-06-01T09:10:00Z",
Content: "hi", ContentLength: 2,
},
)
insertSession(t, d, "tf2", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T14:00:00Z")
s.EndedAt = new("2024-06-01T15:00:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d, Message{
SessionID: "tf2", Ordinal: 0, Role: "user",
Timestamp: "2024-06-01T14:05:00Z",
Content: "world", ContentLength: 5,
})
insertSession(t, d, "tf3", "proj", func(s *Session) {
s.StartedAt = new("2024-06-03T09:00:00Z")
s.EndedAt = new("2024-06-03T10:00:00Z")
s.MessageCount = 1
s.Agent = "claude"
})
insertMessages(t, d, Message{
SessionID: "tf3", Ordinal: 0, Role: "user",
Timestamp: "2024-06-03T09:30:00Z",
Content: "test", ContentLength: 4,
})
f := AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-03",
Timezone: "UTC",
}
t.Run("FilterByHour", func(t *testing.T) {
ff := f
hour := 9
ff.Hour = &hour
s := mustSummary(t, d, ctx, ff)
// tf1 and tf3 have messages at hour 9
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
t.Run("FilterByDow", func(t *testing.T) {
ff := f
dow := 5 // Saturday
ff.DayOfWeek = &dow
s := mustSummary(t, d, ctx, ff)
// tf1 and tf2 are on Saturday
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
t.Run("FilterByDowAndHour", func(t *testing.T) {
ff := f
dow := 5
hour := 14
ff.DayOfWeek = &dow
ff.Hour = &hour
s := mustSummary(t, d, ctx, ff)
// Only tf2 has messages on Saturday at hour 14
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
})
t.Run("NoTimeFilter", func(t *testing.T) {
s := mustSummary(t, d, ctx, f)
// All 3 sessions
assert.Equal(t, 3, s.TotalSessions, "TotalSessions")
})
}
func TestAnalyticsFilterAgentAndMinUserMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "c1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T10:00:00Z")
s.MessageCount = 10
s.UserMessageCount = 5
s.Agent = "claude"
})
insertSession(t, d, "c2", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T11:00:00Z")
s.EndedAt = new("2024-06-01T12:00:00Z")
s.MessageCount = 4
s.UserMessageCount = 1
s.Agent = "claude"
})
insertSession(t, d, "x1", "proj", func(s *Session) {
s.StartedAt = new("2024-06-01T14:00:00Z")
s.EndedAt = new("2024-06-01T15:00:00Z")
s.MessageCount = 20
s.UserMessageCount = 8
s.Agent = "codex"
})
f := baseFilter()
t.Run("NoFilters", func(t *testing.T) {
s := mustSummary(t, d, ctx, f)
assert.Equal(t, 3, s.TotalSessions, "TotalSessions")
})
t.Run("AgentOnly", func(t *testing.T) {
af := f
af.Agent = "claude"
s := mustSummary(t, d, ctx, af)
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
t.Run("MinUserMessagesOnly", func(t *testing.T) {
af := f
af.MinUserMessages = 5
s := mustSummary(t, d, ctx, af)
assert.Equal(t, 2, s.TotalSessions, "TotalSessions")
})
t.Run("AgentAndMinUserMessages", func(t *testing.T) {
af := f
af.Agent = "claude"
af.MinUserMessages = 2
s := mustSummary(t, d, ctx, af)
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
})
t.Run("ActiveSince", func(t *testing.T) {
af := f
af.ActiveSince = "2024-06-01T13:00:00Z"
s := mustSummary(t, d, ctx, af)
assert.Equal(t, 1, s.TotalSessions, "TotalSessions")
})
}
func TestAutonomyExcludesSystemMessages(t *testing.T) {
d := testDB(t)
insertSession(t, d, "s1", "proj", func(s *Session) {
s.Agent = "zencoder"
s.StartedAt = new(tsMidYear)
s.EndedAt = new(tsMidYear)
s.MessageCount = 4
})
msgs := []Message{
{SessionID: "s1", Ordinal: 0, Role: "user",
Content: "system banner", ContentLength: 13,
Timestamp: tsMidYear, IsSystem: true},
{SessionID: "s1", Ordinal: 1, Role: "user",
Content: "real question", ContentLength: 13,
Timestamp: tsMidYear},
{SessionID: "s1", Ordinal: 2, Role: "assistant",
Content: "answer", ContentLength: 6,
Timestamp: tsMidYear, HasToolUse: true},
{SessionID: "s1", Ordinal: 3, Role: "user",
Content: "finish marker", ContentLength: 13,
Timestamp: tsMidYear, IsSystem: true},
}
insertMessages(t, d, msgs...)
resp, err := d.GetAnalyticsSessionShape(
context.Background(),
AnalyticsFilter{
From: "2024-01-01", To: "2024-12-31",
},
)
requireNoError(t, err, "GetAnalyticsSessionShape")
// The autonomy ratio should be based on 1 real user message
// (not 3), yielding ratio = 1/1 = 1.0 -> bucket "1-2".
for _, b := range resp.AutonomyDistribution {
if b.Label == "1-2" && b.Count == 1 {
return // found the expected bucket
}
}
assert.Failf(t, "missing autonomy bucket",
"expected autonomy bucket '1-2' with count 1, got %v",
resp.AutonomyDistribution)
}
func TestActivityExcludesSystemUserMessages(t *testing.T) {
d := testDB(t)
insertSession(t, d, "s1", "proj", func(s *Session) {
s.Agent = "zencoder"
s.StartedAt = new(tsMidYear)
s.EndedAt = new(tsMidYear)
s.MessageCount = 3
})
msgs := []Message{
{SessionID: "s1", Ordinal: 0, Role: "user",
Content: "system banner", ContentLength: 13,
Timestamp: tsMidYear, IsSystem: true},
{SessionID: "s1", Ordinal: 1, Role: "user",
Content: "real question", ContentLength: 13,
Timestamp: tsMidYear},
{SessionID: "s1", Ordinal: 2, Role: "assistant",
Content: "answer", ContentLength: 6,
Timestamp: tsMidYear},
}
insertMessages(t, d, msgs...)
resp, err := d.GetAnalyticsActivity(
context.Background(),
AnalyticsFilter{
From: "2024-01-01", To: "2024-12-31",
},
"day",
)
requireNoError(t, err, "GetAnalyticsActivity")
require.Len(t, resp.Series, 1, "len")
entry := resp.Series[0]
// 3 total messages but only 1 real user message
assert.Equal(t, 3, entry.Messages, "Messages")
assert.Equal(t, 1, entry.UserMessages, "UserMessages")
assert.Equal(t, 1, entry.AssistantMessages, "AssistantMessages")
}
func TestGetAnalyticsSignals(t *testing.T) {
d := testDB(t)
ctx := context.Background()
t.Run("EmptyDB", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "ScoredSessions", resp.ScoredSessions, 0)
assertEq(t, "UnscoredSessions",
resp.UnscoredSessions, 0)
assertEq(t, "len(Trend)", len(resp.Trend), 0)
assertEq(t, "len(ByAgent)", len(resp.ByAgent), 0)
assertEq(t, "len(ByProject)", len(resp.ByProject), 0)
})
// Seed sessions with signal data.
// UpsertSession only writes core fields; signal columns
// are written by UpdateSessionSignals.
insertSession(t, d, "sig1", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.MessageCount = 10
s.Agent = "claude"
})
cp1 := 0.6
updateSignals(t, d, "sig1", SessionSignalUpdate{
HealthScore: new(85),
HealthGrade: new("B"),
Outcome: "completed",
OutcomeConfidence: "high",
ToolFailureSignalCount: 2,
ToolRetryCount: 1,
CompactionCount: 1,
ContextPressureMax: &cp1,
QualitySignals: QualitySignals{
Version: CurrentQualitySignalVersion,
ShortPromptCount: 2,
UnstructuredStart: true,
MissingSuccessCriteriaCount: 1,
DuplicatePromptCount: 1,
},
})
insertSession(t, d, "sig2", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T14:00:00Z")
s.MessageCount = 5
s.Agent = "codex"
})
cp2 := 0.9
updateSignals(t, d, "sig2", SessionSignalUpdate{
HealthScore: new(45),
HealthGrade: new("D"),
Outcome: "errored",
OutcomeConfidence: "medium",
ToolFailureSignalCount: 5,
ToolRetryCount: 3,
EditChurnCount: 2,
ContextPressureMax: &cp2,
QualitySignals: QualitySignals{
Version: CurrentQualitySignalVersion,
MissingVerificationCount: 1,
NoCodeContextCount: 1,
RunawayToolLoopCount: 1,
},
})
insertSession(t, d, "sig3", "beta", func(s *Session) {
s.StartedAt = new("2024-06-02T10:00:00Z")
s.MessageCount = 8
s.Agent = "claude"
})
updateSignals(t, d, "sig3", SessionSignalUpdate{
Outcome: "abandoned",
OutcomeConfidence: "low",
CompactionCount: 3,
// No health score (unscored)
})
t.Run("ScoredVsUnscored", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "ScoredSessions", resp.ScoredSessions, 2)
assertEq(t, "UnscoredSessions",
resp.UnscoredSessions, 1)
})
t.Run("GradeDistribution", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "grade B", resp.GradeDistribution["B"], 1)
assertEq(t, "grade D", resp.GradeDistribution["D"], 1)
})
t.Run("AvgHealthScore", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
require.NotNil(t, resp.AvgHealthScore, "AvgHealthScore")
// (85 + 45) / 2 = 65.0
assertEq(t, "AvgHealthScore",
*resp.AvgHealthScore, 65.0)
})
t.Run("QualityHealth", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
if err != nil {
t.Fatalf("GetAnalyticsSignals: %v", err)
}
assertEq(t, "ComputedSessions",
resp.QualityHealth.ComputedSessions, 2)
assertEq(t, "ShortPromptCount total",
resp.QualityHealth.Totals.ShortPromptCount, 2)
assertEq(t, "ShortPromptCount sessions",
resp.QualityHealth.SessionsWithSignal.ShortPromptCount, 1)
assertEq(t, "UnstructuredStart total",
resp.QualityHealth.Totals.UnstructuredStart, 1)
assertEq(t, "MissingSuccessCriteriaCount total",
resp.QualityHealth.Totals.MissingSuccessCriteriaCount, 1)
assertEq(t, "MissingVerificationCount total",
resp.QualityHealth.Totals.MissingVerificationCount, 1)
assertEq(t, "DuplicatePromptCount total",
resp.QualityHealth.Totals.DuplicatePromptCount, 1)
assertEq(t, "NoCodeContextCount total",
resp.QualityHealth.Totals.NoCodeContextCount, 1)
assertEq(t, "RunawayToolLoopCount total",
resp.QualityHealth.Totals.RunawayToolLoopCount, 1)
})
t.Run("OutcomeDistribution", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "completed",
resp.OutcomeDistribution["completed"], 1)
assertEq(t, "errored",
resp.OutcomeDistribution["errored"], 1)
assertEq(t, "abandoned",
resp.OutcomeDistribution["abandoned"], 1)
})
t.Run("ToolHealth", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
// 2 + 5 + 0 = 7
assertEq(t, "TotalFailureSignals",
resp.ToolHealth.TotalFailureSignals, 7)
// 1 + 3 + 0 = 4
assertEq(t, "TotalRetries",
resp.ToolHealth.TotalRetries, 4)
// 0 + 2 + 0 = 2
assertEq(t, "TotalEditChurn",
resp.ToolHealth.TotalEditChurn, 2)
// sig1 and sig2 have failures
assertEq(t, "SessionsWithFailures",
resp.ToolHealth.SessionsWithFailures, 2)
})
t.Run("ContextHealth", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
// sig1 (1) and sig3 (3) have compaction > 0
assertEq(t, "SessionsWithCompaction",
resp.ContextHealth.SessionsWithCompaction, 2)
// sig1 and sig2 have context_pressure_max
assertEq(t, "SessionsWithContextData",
resp.ContextHealth.SessionsWithContextData, 2)
// sig2 has pressure >= 0.8
assertEq(t, "HighPressureSessions",
resp.ContextHealth.HighPressureSessions, 1)
})
t.Run("Trend", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
// 2 dates: 2024-06-01 (2 sessions), 2024-06-02 (1)
assertEq(t, "len(Trend)", len(resp.Trend), 2)
// Sorted by date
assertEq(t, "Trend[0].Date",
resp.Trend[0].Date, "2024-06-01")
assertEq(t, "Trend[0].SessionCount",
resp.Trend[0].SessionCount, 2)
assertEq(t, "Trend[0].Completed",
resp.Trend[0].Completed, 1)
assertEq(t, "Trend[0].Errored",
resp.Trend[0].Errored, 1)
assertEq(t, "Trend[1].Date",
resp.Trend[1].Date, "2024-06-02")
assertEq(t, "Trend[1].Abandoned",
resp.Trend[1].Abandoned, 1)
})
t.Run("ByAgent", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
// 2 agents: claude (2 sessions), codex (1)
assertEq(t, "len(ByAgent)", len(resp.ByAgent), 2)
// Alphabetical: claude first
assertEq(t, "ByAgent[0].Agent",
resp.ByAgent[0].Agent, "claude")
assertEq(t, "ByAgent[0].SessionCount",
resp.ByAgent[0].SessionCount, 2)
assertEq(t, "ByAgent[1].Agent",
resp.ByAgent[1].Agent, "codex")
})
t.Run("ByProject", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(ctx, baseFilter())
require.NoError(t, err, "GetAnalyticsSignals")
// 2 projects: alpha (2), beta (1)
// Sorted by session count desc
assertEq(t, "len(ByProject)", len(resp.ByProject), 2)
assertEq(t, "ByProject[0].Project",
resp.ByProject[0].Project, "alpha")
assertEq(t, "ByProject[0].SessionCount",
resp.ByProject[0].SessionCount, 2)
})
t.Run("ProjectFilter", func(t *testing.T) {
f := baseFilter()
f.Project = "beta"
resp, err := d.GetAnalyticsSignals(ctx, f)
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "ScoredSessions", resp.ScoredSessions, 0)
assertEq(t, "UnscoredSessions",
resp.UnscoredSessions, 1)
assertEq(t, "len(ByProject)", len(resp.ByProject), 1)
})
t.Run("EmptyDateRange", func(t *testing.T) {
resp, err := d.GetAnalyticsSignals(
ctx, emptyFilter(),
)
require.NoError(t, err, "GetAnalyticsSignals")
assertEq(t, "ScoredSessions", resp.ScoredSessions, 0)
})
}
func TestBuildSignalExamplesUsesObservedOrdinal(t *testing.T) {
tests := []struct {
name string
signal string
row SignalRow
msgs []SignalMessage
want int
}{
{
name: "short prompts skip controls",
signal: "short_prompt_count",
row: SignalRow{
ID: "short",
ShortPromptCount: 1,
},
msgs: []SignalMessage{
{SessionID: "short", Ordinal: 0, Role: "user", Content: "yes"},
{SessionID: "short", Ordinal: 3, Role: "user", Content: "fix bug"},
},
want: 3,
},
{
name: "repeated prompts point at repeat",
signal: "duplicate_prompt_count",
row: SignalRow{
ID: "repeat",
DuplicatePromptCount: 1,
},
msgs: []SignalMessage{
{SessionID: "repeat", Ordinal: 0, Role: "user", Content: "Fix the backend test."},
{SessionID: "repeat", Ordinal: 2, Role: "user", Content: "Fix the backend test."},
},
want: 2,
},
{
name: "tool signals point at tool turn",
signal: "tool_failure_signals",
row: SignalRow{
ID: "tool",
ToolFailureSignalCount: 1,
},
msgs: []SignalMessage{
{SessionID: "tool", Ordinal: 0, Role: "user", Content: "run tests"},
{SessionID: "tool", Ordinal: 4, Role: "assistant", HasToolUse: true},
},
want: 4,
},
{
name: "outcomes point at last observed turn",
signal: "outcome_errored",
row: SignalRow{
ID: "outcome",
Outcome: "errored",
},
msgs: []SignalMessage{
{SessionID: "outcome", Ordinal: 0, Role: "user", Content: "start"},
{SessionID: "outcome", Ordinal: 6, Role: "assistant", Content: "failed"},
},
want: 6,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
examples := BuildSignalExamples(
[]SignalRow{tt.row},
map[string][]SignalMessage{tt.row.ID: tt.msgs},
tt.signal,
)
if len(examples) != 1 {
t.Fatalf("len(examples) = %d, want 1",
len(examples))
}
if examples[0].MessageOrdinal == nil {
t.Fatal("MessageOrdinal is nil")
}
if *examples[0].MessageOrdinal != tt.want {
t.Fatalf("MessageOrdinal = %d, want %d",
*examples[0].MessageOrdinal, tt.want)
}
})
}
}
func TestGetAnalyticsSignalSessionsRejectsUnsupportedSignal(t *testing.T) {
d := testDB(t)
ctx := context.Background()
_, err := d.GetAnalyticsSignalSessions(
ctx,
baseFilter(),
"not_a_signal",
10,
)
if !errors.Is(err, ErrUnsupportedAnalyticsSignal) {
t.Fatalf("err = %v, want ErrUnsupportedAnalyticsSignal", err)
}
}
func TestGetAnalyticsSignalSessionsModelFilterUsesMatchingMessages(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "signal-mixed", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:10:00Z")
s.MessageCount = 2
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "signal-mixed", Ordinal: 0, Role: "assistant",
Content: "claude tool evidence", ContentLength: 20,
Timestamp: "2024-06-01T09:05:00Z",
Model: "claude-3-5-sonnet",
HasToolUse: true,
},
Message{
SessionID: "signal-mixed", Ordinal: 1, Role: "assistant",
Content: "gpt tool evidence", ContentLength: 17,
Timestamp: "2024-06-01T09:06:00Z",
Model: "gpt-4o",
HasToolUse: true,
},
)
require.NoError(t, d.UpdateSessionSignals(
"signal-mixed",
SessionSignalUpdate{ToolFailureSignalCount: 1},
))
resp, err := d.GetAnalyticsSignalSessions(
ctx,
AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
Model: "gpt-4o",
},
"tool_failure_signals",
10,
)
require.NoError(t, err, "GetAnalyticsSignalSessions")
require.Len(t, resp.Sessions, 1, "len(Sessions)")
assert.Equal(t, "gpt tool evidence", resp.Sessions[0].Excerpt)
require.NotNil(t, resp.Sessions[0].MessageOrdinal)
assert.Equal(t, 1, *resp.Sessions[0].MessageOrdinal)
}
func TestGetAnalyticsSignalSessionsModelFilterKeepsParserUserEvidence(
t *testing.T,
) {
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "signal-parser-user", "alpha", func(s *Session) {
s.StartedAt = new("2024-06-01T09:00:00Z")
s.EndedAt = new("2024-06-01T09:10:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.Agent = "mixed"
})
insertMessages(t, d,
Message{
SessionID: "signal-parser-user", Ordinal: 0, Role: "user",
Content: "help", ContentLength: 4,
Timestamp: "2024-06-01T09:00:00Z",
Model: "",
},
Message{
SessionID: "signal-parser-user", Ordinal: 1, Role: "assistant",
Content: "reply", ContentLength: 5,
Timestamp: "2024-06-01T09:01:00Z",
Model: "gpt-4o",
HasToolUse: true,
},
)
updateSignals(t, d, "signal-parser-user", SessionSignalUpdate{
QualitySignals: QualitySignals{
Version: 1,
ShortPromptCount: 1,
},
})
resp, err := d.GetAnalyticsSignalSessions(
ctx,
AnalyticsFilter{
From: "2024-06-01",
To: "2024-06-01",
Timezone: "UTC",
Model: "gpt-4o",
},
"short_prompt_count",
10,
)
require.NoError(t, err, "GetAnalyticsSignalSessions")
require.Len(t, resp.Sessions, 1, "len(Sessions)")
assert.Equal(t, "help", resp.Sessions[0].Excerpt)
require.NotNil(t, resp.Sessions[0].MessageOrdinal)
assert.Equal(t, 0, *resp.Sessions[0].MessageOrdinal)
}
func TestParseEvidenceTimeAcceptsPostgresUTCFormat(t *testing.T) {
got, ok := parseEvidenceTime("2024-06-01T12:34:56.123456Z")
if !ok {
t.Fatal("parseEvidenceTime did not accept PG UTC format")
}
if got.UTC().Format(time.RFC3339Nano) !=
"2024-06-01T12:34:56.123456Z" {
t.Fatalf("got %s", got.UTC().Format(time.RFC3339Nano))
}
}
func TestLocalTime(t *testing.T) {
tests := []struct {
name string
ts string
valid bool
}{
{"RFC3339", "2024-06-01T15:00:00Z", true},
{"RFC3339Nano", "2024-06-01T15:00:00.123Z", true},
{"NoFraction", "2024-06-01T15:00:00Z", true},
{"BadFormat", "2024-06-01", false},
{"Empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok := localTime(tt.ts, time.UTC)
assert.Equal(t, tt.valid, ok,
"localTime(%q) ok", tt.ts)
})
}
}
func TestSQLiteTimeModifier(t *testing.T) {
t.Run("UTC", func(t *testing.T) {
modifier, ok := AnalyticsFilter{
From: "2026-06-01",
To: "2026-06-07",
Timezone: "UTC",
}.sqliteTimeModifier()
require.True(t, ok)
assert.Equal(t, "", modifier)
})
t.Run("StableOffset", func(t *testing.T) {
modifier, ok := AnalyticsFilter{
From: "2026-06-01",
To: "2026-06-07",
Timezone: "America/New_York",
}.sqliteTimeModifier()
require.True(t, ok)
assert.Equal(t, "-04:00", modifier)
})
t.Run("DSTCrossingFallsBack", func(t *testing.T) {
_, ok := AnalyticsFilter{
From: "2026-03-01",
To: "2026-03-31",
Timezone: "America/New_York",
}.sqliteTimeModifier()
assert.False(t, ok)
})
}